diff --git a/.changeset/tame-numbers-smile.md b/.changeset/tame-numbers-smile.md new file mode 100644 index 0000000000..1d675f8c9c --- /dev/null +++ b/.changeset/tame-numbers-smile.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-kubernetes-backend': patch +'@backstage/plugin-kubernetes-node': patch +--- + +Enabling authentication to kubernetes clusters with mTLS x509 client certs diff --git a/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts b/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts index e388589e74..3b435fad74 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts @@ -812,6 +812,129 @@ describe('KubernetesFetcher', () => { const [[{ agent }]] = httpsRequest.mock.calls; expect(agent.options.rejectUnauthorized).toBe(false); }); + + it('fetchObjectsForService authenticates with k8s using x509 client cert from authentication strategy', async () => { + worker.use( + rest.get('https://localhost:9999/api/v1/pods', (req, res, ctx) => + res( + checkToken(req, ctx, 'token'), + withLabels(req, ctx, { + items: [{ metadata: { name: 'pod-name' } }], + }), + ), + ), + ); + + const myCert = 'MOCKCert'; + const myKey = 'MOCKKey'; + + const result = sut.fetchObjectsForService({ + serviceId: 'some-service', + clusterDetails: { + name: 'cluster1', + url: 'https://localhost:9999', + authMetadata: {}, + caData: 'MOCKCA', + }, + credential: { + type: 'x509 client certificate', + cert: myCert, + key: myKey, + }, + objectTypesToFetch: new Set([ + { + group: '', + apiVersion: 'v1', + plural: 'pods', + objectType: 'pods', + }, + ]), + labelSelector: '', + customResources: [], + }); + + await expect(result).rejects.toThrow(/PEM/); + + expect(httpsRequest).toHaveBeenCalledTimes(1); + const [[{ agent }]] = httpsRequest.mock.calls; + expect(agent.options.ca.toString('base64')).toMatch('MOCKCA'); + expect(agent.options.cert).toEqual(myCert); + expect(agent.options.key).toEqual(myKey); + }); + + it('fetchPodMetricsByNamespaces authenticates with k8s using x509 client cert from authentication strategy', async () => { + worker.use( + rest.get( + 'https://localhost:9999/api/v1/namespaces/:namespace/pods', + (req, res, ctx) => + res( + withLabels(req, ctx, { + items: [ + { + metadata: { name: 'pod-name' }, + spec: { + containers: [ + { + name: 'container-name', + resources: { + requests: { cpu: '500m', memory: '512M' }, + limits: { cpu: '1000m', memory: '1G' }, + }, + }, + ], + }, + }, + ], + }), + ), + ), + rest.get( + 'https://localhost:9999/apis/metrics.k8s.io/v1beta1/namespaces/:namespace/pods', + (req, res, ctx) => + res( + withLabels(req, ctx, { + items: [ + { + metadata: { name: 'pod-name' }, + containers: [ + { + name: 'container-name', + usage: { cpu: '0', memory: '0' }, + }, + ], + }, + ], + }), + ), + ), + ); + + const myCert = 'MOCKCert'; + const myKey = 'MOCKKey'; + + const result = sut.fetchPodMetricsByNamespaces( + { + name: 'cluster1', + url: 'https://localhost:9999', + authMetadata: {}, + caData: 'MOCKCA', + }, + { + type: 'x509 client certificate', + cert: myCert, + key: myKey, + }, + new Set(['ns-a']), + ); + + await expect(result).rejects.toThrow(/PEM/); + + expect(httpsRequest).toHaveBeenCalledTimes(2); + const [[{ agent }]] = httpsRequest.mock.calls; + expect(agent.options.ca.toString('base64')).toMatch('MOCKCA'); + expect(agent.options.cert).toEqual(myCert); + expect(agent.options.key).toEqual(myKey); + }); }); it('should use namespace if provided', async () => { @@ -895,7 +1018,7 @@ describe('KubernetesFetcher', () => { customResources: [], }); return expect(result).rejects.toThrow( - "no bearer token for cluster 'unauthenticated-cluster' and not running in Kubernetes", + "no bearer token or client cert for cluster 'unauthenticated-cluster' and not running in Kubernetes", ); }); }); diff --git a/plugins/kubernetes-backend/src/service/KubernetesFetcher.ts b/plugins/kubernetes-backend/src/service/KubernetesFetcher.ts index 99501c48c6..b7354d54d4 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFetcher.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFetcher.ts @@ -216,21 +216,15 @@ export class KubernetesClientBasedFetcher implements KubernetesFetcher { let requestInit: RequestInit; const authProvider = clusterDetails.authMetadata[ANNOTATION_KUBERNETES_AUTH_PROVIDER]; - if ( - authProvider === 'serviceAccount' && - !clusterDetails.authMetadata.serviceAccountToken && - fs.pathExistsSync(Config.SERVICEACCOUNT_CA_PATH) - ) { + + if (this.isServiceAccountAuthentication(authProvider, clusterDetails)) { [url, requestInit] = this.fetchArgsInCluster(credential); - } else if ( - credential.type === 'bearer token' || - authProvider === 'localKubectlProxy' - ) { + } else if (!this.isCredentialMissing(authProvider, credential)) { [url, requestInit] = this.fetchArgs(clusterDetails, credential); } else { return Promise.reject( new Error( - `no bearer token for cluster '${clusterDetails.name}' and not running in Kubernetes`, + `no bearer token or client cert for cluster '${clusterDetails.name}' and not running in Kubernetes`, ), ); } @@ -248,6 +242,26 @@ export class KubernetesClientBasedFetcher implements KubernetesFetcher { return fetch(url, requestInit); } + private isServiceAccountAuthentication( + authProvider: string, + clusterDetails: ClusterDetails, + ) { + return ( + authProvider === 'serviceAccount' && + !clusterDetails.authMetadata.serviceAccountToken && + fs.pathExistsSync(Config.SERVICEACCOUNT_CA_PATH) + ); + } + + private isCredentialMissing( + authProvider: string, + credential: KubernetesCredential, + ) { + return ( + authProvider !== 'localKubectlProxy' && credential.type === 'anonymous' + ); + } + private fetchArgs( clusterDetails: ClusterDetails, credential: KubernetesCredential, @@ -272,6 +286,10 @@ export class KubernetesClientBasedFetcher implements KubernetesFetcher { clusterDetails.caData, ) ?? undefined, rejectUnauthorized: !clusterDetails.skipTLSVerify, + ...(credential.type === 'x509 client certificate' && { + cert: credential.cert, + key: credential.key, + }), }); } return [url, requestInit]; diff --git a/plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts b/plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts index 9bee21867d..94a8b2918e 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts @@ -820,6 +820,67 @@ describe('KubernetesProxy', () => { expect(ca).toMatch('MOCKCA'); }); }); + + it('should use a x509 client cert authentication strategy to consume kubeapi when backstage-kubernetes-auth field is not provided and the authStrategy enables x509 client cert authentication', async () => { + worker.use( + rest.get( + 'https://localhost:9999/api/v1/namespaces', + (req: any, res: any, ctx: any) => { + if (req.headers.get('Authorization')) { + 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 myCert = 'MOCKCert'; + const myKey = 'MOCKKey'; + + authStrategy.getCredential.mockResolvedValue({ + type: 'x509 client certificate', + cert: myCert, + key: myKey, + }); + + const requestPromise = setupProxyPromise({ + proxyPath: '/mountpath', + requestPath: '/api/v1/namespaces', + + headers: { [HEADER_KUBERNETES_CLUSTER]: 'cluster1' }, + }); + + const response = await requestPromise; + + expect(authStrategy.getCredential).toHaveBeenCalledTimes(1); + expect(authStrategy.getCredential).toHaveBeenCalledWith( + expect.anything(), + {}, + ); + + const [[{ key, cert }]] = httpsRequest.mock.calls; + expect(cert).toEqual(myCert); + expect(key).toEqual(myKey); + + // 500 Since the key and cert are fake + expect(response.status).toEqual(500); + }); }); describe('WebSocket', () => { diff --git a/plugins/kubernetes-backend/src/service/KubernetesProxy.ts b/plugins/kubernetes-backend/src/service/KubernetesProxy.ts index 8a07267cde..209d01b703 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesProxy.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesProxy.ts @@ -113,24 +113,6 @@ export class KubernetesProxy { return; } - const authHeader = req.header(HEADER_KUBERNETES_AUTH); - 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, authObj); - }); - - if (credential.type === 'bearer token') { - req.headers.authorization = `Bearer ${credential.token}`; - } - } - const middleware = await this.getMiddleware(req); // If req is an upgrade handshake, use middleware upgrade instead of http request handler https://github.com/chimurai/http-proxy-middleware#external-websocket-upgrade @@ -173,7 +155,7 @@ export class KubernetesProxy { const cluster = await this.getClusterForRequest(req); const url = new URL(cluster.url); - return { + const target: any = { protocol: url.protocol, host: url.hostname, port: url.port, @@ -182,6 +164,29 @@ export class KubernetesProxy { cluster.caData, )?.toString(), }; + + const authHeader = req.header(HEADER_KUBERNETES_AUTH); + 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, authObj); + }); + + if (credential.type === 'bearer token') { + req.headers.authorization = `Bearer ${credential.token}`; + } else if (credential.type === 'x509 client certificate') { + target.key = credential.key; + target.cert = credential.cert; + } + } + + return target; }, onError: (error, req, res) => { const wrappedError = new ForwardedError( diff --git a/plugins/kubernetes-node/api-report.md b/plugins/kubernetes-node/api-report.md index 2272035742..4c858ec969 100644 --- a/plugins/kubernetes-node/api-report.md +++ b/plugins/kubernetes-node/api-report.md @@ -100,6 +100,11 @@ export type KubernetesCredential = type: 'bearer token'; token: string; } + | { + type: 'x509 client certificate'; + cert: string; + key: string; + } | { type: 'anonymous'; }; diff --git a/plugins/kubernetes-node/src/types/types.ts b/plugins/kubernetes-node/src/types/types.ts index a0f48d0c26..036b54ed98 100644 --- a/plugins/kubernetes-node/src/types/types.ts +++ b/plugins/kubernetes-node/src/types/types.ts @@ -139,6 +139,7 @@ export interface KubernetesClustersSupplier { */ export type KubernetesCredential = | { type: 'bearer token'; token: string } + | { type: 'x509 client certificate'; cert: string; key: string } | { type: 'anonymous' }; /**