diff --git a/.changeset/two-dingos-dream.md b/.changeset/two-dingos-dream.md new file mode 100644 index 0000000000..9ebefacc69 --- /dev/null +++ b/.changeset/two-dingos-dream.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-kubernetes-backend': patch +'@backstage/plugin-kubernetes-react': patch +'@backstage/plugin-kubernetes-common': patch +--- + +The kubernetes APIs invokes Authentication Strategies when Backstage-Kubernetes-Authorization-X-X headers are provided, this enable the possibility to invoke strategies that executes additional steps to get a kubernetes token like on pinniped or custom strategies diff --git a/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts b/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts index 85a4326d60..c676cc6246 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts @@ -520,6 +520,26 @@ metadata: expect(response.body).toStrictEqual({ items: [] }); }); + + it('should not permit custom auth strategies with dashes', async () => { + const throwError = () => + KubernetesBuilder.createBuilder({ + logger: getVoidLogger(), + config, + catalogApi, + permissions, + }).addAuthStrategy('custom-strategy', { + getCredential: jest + .fn< + Promise, + [ClusterDetails, KubernetesRequestAuth] + >() + .mockResolvedValue({ type: 'anonymous' }), + validateCluster: jest.fn().mockReturnValue([]), + }); + + expect(throwError).toThrow('Strategy name can not include dashes'); + }); }); describe('get /.well-known/backstage/permissions/metadata', () => { it('lists permissions supported by the kubernetes plugin', async () => { diff --git a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts index f3766d8513..9ac1bf6e5d 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts @@ -205,6 +205,9 @@ export class KubernetesBuilder { } public addAuthStrategy(key: string, strategy: AuthenticationStrategy) { + if (key.includes('-')) { + throw new Error('Strategy name can not include dashes'); + } this.getAuthStrategyMap()[key] = strategy; return this; } diff --git a/plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts b/plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts index 327d580d10..27d57cd854 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts @@ -509,6 +509,134 @@ describe('KubernetesProxy', () => { }); }); + it('should invoke AuthStrategy if Backstage-Kubernetes-Authorization-X-X are provided', async () => { + const strategy: jest.Mocked = { + getCredential: jest + .fn() + .mockReturnValue({ type: 'bearer token', token: 'MY_TOKEN3' }), + validateCluster: jest.fn(), + }; + + proxy = new KubernetesProxy({ + logger: getVoidLogger(), + clusterSupplier: clusterSupplier, + authStrategy: strategy, + }); + + worker.use( + rest.get('https://localhost:9999/api/v1/namespaces', (req, res, ctx) => { + if (!req.headers.get('Authorization')) { + return res(ctx.status(401)); + } + + if (req.headers.get('Authorization') !== 'Bearer MY_TOKEN3') { + return res(ctx.status(403)); + } + + return res( + ctx.status(200), + ctx.json({ + kind: 'NamespaceList', + apiVersion: 'v1', + items: [], + }), + ); + }), + ); + + clusterSupplier.getClusters.mockResolvedValue([ + { + name: 'cluster1', + url: 'https://localhost:9999', + authMetadata: {}, + }, + ]); + + const requestPromise = setupProxyPromise({ + proxyPath: '/mountpath', + requestPath: '/api/v1/namespaces', + + headers: { + [HEADER_KUBERNETES_CLUSTER]: 'cluster1', + 'Backstage-Kubernetes-Authorization-google': 'MY_TOKEN1', + 'Backstage-Kubernetes-Authorization-aks': 'MY_TOKEN2', + 'Backstage-Kubernetes-Authorization-oidc-okta': 'MY_TOKEN3', + 'Backstage-Kubernetes-Authorization-oidc-gitlab': 'MY_TOKEN4', + 'Backstage-Kubernetes-Authorization-pinniped-audience1': 'MY_TOKEN5', + 'Backstage-Kubernetes-Authorization-pinniped-au-b-c-d-e': 'MY_TOKEN6', + }, + }); + + const response = await requestPromise; + + const authObj = { + google: 'MY_TOKEN1', + aks: 'MY_TOKEN2', + oidc: { okta: 'MY_TOKEN3', gitlab: 'MY_TOKEN4' }, + pinniped: { audience1: 'MY_TOKEN5', 'au-b-c-d-e': 'MY_TOKEN6' }, + }; + + expect(strategy.getCredential).toHaveBeenCalledTimes(1); + expect(strategy.getCredential).toHaveBeenCalledWith( + expect.anything(), + authObj, + ); + expect(response.status).toEqual(200); + expect(response.body).toStrictEqual({ + kind: 'NamespaceList', + apiVersion: 'v1', + items: [], + }); + }); + + it('should invoke the Auth strategy with an empty auth object when no Backstage-Kubernetes-Authorization-X-X are provided', async () => { + worker.use( + rest.get('https://localhost:9999/api/v1/namespaces', (_, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + kind: 'NamespaceList', + apiVersion: 'v1', + items: [], + }), + ); + }), + ); + + clusterSupplier.getClusters.mockResolvedValue([ + { + name: 'cluster1', + url: 'https://localhost:9999', + authMetadata: {}, + }, + ]); + + const requestPromise = setupProxyPromise({ + proxyPath: '/mountpath', + requestPath: '/api/v1/namespaces', + + headers: { + [HEADER_KUBERNETES_CLUSTER]: 'cluster1', + }, + }); + + const response = await requestPromise; + + const authObj = {}; + + expect(authStrategy.getCredential).toHaveBeenCalledTimes(1); + expect(authStrategy.getCredential).toHaveBeenCalledWith( + expect.anything(), + authObj, + ); + expect(response.status).toEqual(200); + expect(response.body).toStrictEqual({ + kind: 'NamespaceList', + apiVersion: 'v1', + items: [], + }); + }); + it('returns a response with a localKubectlProxy auth provider configuration', async () => { proxy = new KubernetesProxy({ logger: getVoidLogger(), diff --git a/plugins/kubernetes-backend/src/service/KubernetesProxy.ts b/plugins/kubernetes-backend/src/service/KubernetesProxy.ts index d2a3649c27..8a07267cde 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesProxy.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesProxy.ts @@ -21,7 +21,10 @@ import { serializeError, } from '@backstage/errors'; import { getBearerTokenFromAuthorizationHeader } from '@backstage/plugin-auth-node'; -import { kubernetesProxyPermission } from '@backstage/plugin-kubernetes-common'; +import { + KubernetesRequestAuth, + kubernetesProxyPermission, +} from '@backstage/plugin-kubernetes-common'; import { AuthorizeResult, PermissionEvaluator, @@ -34,6 +37,7 @@ import { AuthenticationStrategy } from '../auth'; import { ClusterDetails, KubernetesClustersSupplier } from '../types/types'; import type { Request } from 'express'; +import { IncomingHttpHeaders } from 'http'; export const APPLICATION_JSON: string = 'application/json'; @@ -113,9 +117,15 @@ export class KubernetesProxy { if (authHeader) { req.headers.authorization = authHeader; } else { + // Map Backstage-Kubernetes-Authorization-X-X headers to a KubernetesRequestAuth object + const authObj = KubernetesProxy.authHeadersToKubernetesRequestAuth( + req.headers, + ); + const credential = await this.getClusterForRequest(req).then(cd => { - return this.authStrategy.getCredential(cd, {}); + return this.authStrategy.getCredential(cd, authObj); }); + if (credential.type === 'bearer token') { req.headers.authorization = `Bearer ${credential.token}`; } @@ -221,4 +231,52 @@ export class KubernetesProxy { return cluster; } + + private static authHeadersToKubernetesRequestAuth( + originalHeaders: IncomingHttpHeaders, + ): KubernetesRequestAuth { + return Object.keys(originalHeaders) + .filter(header => header.startsWith('backstage-kubernetes-authorization')) + .map(header => + KubernetesProxy.headerToDictionary(header, originalHeaders), + ) + .filter(headerAsDic => Object.keys(headerAsDic).length !== 0) + .reduce(KubernetesProxy.combineHeaders, {}); + } + + private static headerToDictionary( + header: string, + originalHeaders: IncomingHttpHeaders, + ): KubernetesRequestAuth { + const obj: KubernetesRequestAuth = {}; + const headerSplitted = header.split('-'); + if (headerSplitted.length >= 4) { + const framework = headerSplitted[3].toLowerCase(); + if (headerSplitted.length >= 5) { + const provider = headerSplitted.slice(4).join('-').toLowerCase(); + obj[framework] = { [provider]: originalHeaders[header] }; + } else { + obj[framework] = originalHeaders[header]; + } + } + return obj; + } + + private static combineHeaders( + authObj: any, + header: any, + ): KubernetesRequestAuth { + const framework = Object.keys(header)[0]; + + if (authObj[framework]) { + authObj[framework] = { + ...authObj[framework], + ...header[framework], + }; + } else { + authObj[framework] = header[framework]; + } + + return authObj; + } } diff --git a/plugins/kubernetes-common/api-report.md b/plugins/kubernetes-common/api-report.md index 0840278394..8daeb9a875 100644 --- a/plugins/kubernetes-common/api-report.md +++ b/plugins/kubernetes-common/api-report.md @@ -7,6 +7,7 @@ import { BasicPermission } from '@backstage/plugin-permission-common'; import { Entity } from '@backstage/catalog-model'; import { FetchResponse as FetchResponse_2 } from '@backstage/plugin-kubernetes-common'; import type { JsonObject } from '@backstage/types'; +import type { JsonValue } from '@backstage/types'; import { ObjectsByEntityResponse as ObjectsByEntityResponse_2 } from '@backstage/plugin-kubernetes-common'; import { PodStatus } from '@kubernetes/client-node'; import { V1ConfigMap } from '@kubernetes/client-node'; @@ -319,7 +320,9 @@ export const kubernetesPermissions: BasicPermission[]; export const kubernetesProxyPermission: BasicPermission; // @public (undocumented) -export type KubernetesRequestAuth = JsonObject; +export type KubernetesRequestAuth = { + [providerKey: string]: JsonValue | undefined; +}; // @public (undocumented) export interface KubernetesRequestBody { diff --git a/plugins/kubernetes-common/src/types.ts b/plugins/kubernetes-common/src/types.ts index 9648293432..9f8d5eed82 100644 --- a/plugins/kubernetes-common/src/types.ts +++ b/plugins/kubernetes-common/src/types.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { JsonObject } from '@backstage/types'; +import type { JsonObject, JsonValue } from '@backstage/types'; import { PodStatus, V1ConfigMap, @@ -33,7 +33,9 @@ import { import { Entity } from '@backstage/catalog-model'; /** @public */ -export type KubernetesRequestAuth = JsonObject; +export type KubernetesRequestAuth = { + [providerKey: string]: JsonValue | undefined; +}; /** @public */ export interface CustomResourceMatcher { diff --git a/plugins/kubernetes-react/src/api/KubernetesBackendClient.test.ts b/plugins/kubernetes-react/src/api/KubernetesBackendClient.test.ts index 7d2ef36602..fd999ab335 100644 --- a/plugins/kubernetes-react/src/api/KubernetesBackendClient.test.ts +++ b/plugins/kubernetes-react/src/api/KubernetesBackendClient.test.ts @@ -398,9 +398,26 @@ describe('KubernetesBackendClient', () => { identityApi.getCredentials.mockResolvedValue({ token: 'idToken' }); }); - it('hits the /proxy API', async () => { + it('hits the /proxy API with oidc as protocol and okta as auth provider', async () => { + worker.use( + rest.get( + 'http://localhost:1234/api/kubernetes/clusters', + (_, res, ctx) => + res( + ctx.json({ + items: [ + { + name: 'cluster-a', + authProvider: 'oidc', + oidcTokenProvider: 'okta', + }, + ], + }), + ), + ), + ); kubernetesAuthProvidersApi.getCredentials.mockResolvedValue({ - token: 'k8-token', + token: 'k8-token3', }); const nsResponse = { kind: 'Namespace', @@ -414,8 +431,56 @@ describe('KubernetesBackendClient', () => { 'http://localhost:1234/api/kubernetes/proxy/api/v1/namespaces', (req, res, ctx) => res( - req.headers.get('Backstage-Kubernetes-Authorization') === - 'Bearer k8-token' + req.headers.get( + 'Backstage-Kubernetes-Authorization-oidc-okta', + ) === 'k8-token3' + ? ctx.json(nsResponse) + : ctx.status(403), + ), + ), + ); + + const request = { + clusterName: 'cluster-a', + path: '/api/v1/namespaces', + }; + + const response = await backendClient.proxy(request); + + await expect(response.json()).resolves.toStrictEqual(nsResponse); + }); + + it('hits the /proxy API with serviceAccount as auth provider', async () => { + worker.use( + rest.get( + 'http://localhost:1234/api/kubernetes/clusters', + (_, res, ctx) => + res( + ctx.json({ + items: [ + { + name: 'cluster-a', + authProvider: 'serviceAccount', + }, + ], + }), + ), + ), + ); + + const nsResponse = { + kind: 'Namespace', + apiVersion: 'v1', + metadata: { + name: 'new-ns', + }, + }; + worker.use( + rest.get( + 'http://localhost:1234/api/kubernetes/proxy/api/v1/namespaces', + (req, res, ctx) => + res( + req.headers.get('Authorization') === 'Bearer idToken' ? ctx.json(nsResponse) : ctx.status(403), ), @@ -450,8 +515,8 @@ describe('KubernetesBackendClient', () => { 'http://localhost:1234/api/kubernetes/proxy/api/v1/namespaces', (req, res, ctx) => res( - req.headers.get('Backstage-Kubernetes-Authorization') === - 'Bearer k8-token' + req.headers.get('Backstage-Kubernetes-Authorization-aws') === + 'k8-token' ? ctx.json(nsResponse) : ctx.status(403), ), diff --git a/plugins/kubernetes-react/src/api/KubernetesBackendClient.ts b/plugins/kubernetes-react/src/api/KubernetesBackendClient.ts index b483c267d8..d4e11c43da 100644 --- a/plugins/kubernetes-react/src/api/KubernetesBackendClient.ts +++ b/plugins/kubernetes-react/src/api/KubernetesBackendClient.ts @@ -75,9 +75,11 @@ export class KubernetesBackendClient implements KubernetesApi { return this.handleResponse(response); } - private async getCluster( - clusterName: string, - ): Promise<{ name: string; authProvider: string }> { + private async getCluster(clusterName: string): Promise<{ + name: string; + authProvider: string; + oidcTokenProvider?: string; + }> { const cluster = await this.getClusters().then(clusters => clusters.find(c => c.name === clusterName), ); @@ -140,23 +142,62 @@ export class KubernetesBackendClient implements KubernetesApi { path: string; init?: RequestInit; }): Promise { - const { authProvider } = await this.getCluster(options.clusterName); - const { token: k8sToken } = await this.getCredentials(authProvider); + const { authProvider, oidcTokenProvider } = await this.getCluster( + options.clusterName, + ); + const kubernetesCredentials = await this.getCredentials(authProvider); const url = `${await this.discoveryApi.getBaseUrl('kubernetes')}/proxy${ options.path }`; const identityResponse = await this.identityApi.getCredentials(); - const headers = { + const headers = KubernetesBackendClient.getKubernetesHeaders( + options, + kubernetesCredentials?.token, + identityResponse, + authProvider, + oidcTokenProvider, + ); + return await fetch(url, { ...options.init, headers }); + } + + private static getKubernetesHeaders( + options: { + clusterName: string; + path: string; + init?: RequestInit; + }, + k8sToken: string | undefined, + identityResponse: { token?: string }, + authProvider: string, + oidcTokenProvider: string | undefined, + ) { + const kubernetesAuthHeader = + KubernetesBackendClient.getKubernetesAuthHeaderByAuthProvider( + authProvider, + oidcTokenProvider, + ); + return { ...options.init?.headers, [`Backstage-Kubernetes-Cluster`]: options.clusterName, ...(k8sToken && { - [`Backstage-Kubernetes-Authorization`]: `Bearer ${k8sToken}`, + [kubernetesAuthHeader]: k8sToken, }), ...(identityResponse.token && { Authorization: `Bearer ${identityResponse.token}`, }), }; + } - return await fetch(url, { ...options.init, headers }); + private static getKubernetesAuthHeaderByAuthProvider( + authProvider: string, + oidcTokenProvider: string | undefined, + ): string { + let header: string = 'Backstage-Kubernetes-Authorization'; + + header = header.concat('-', authProvider); + + if (oidcTokenProvider) header = header.concat('-', oidcTokenProvider); + + return header; } }