diff --git a/.changeset/tall-tools-compare.md b/.changeset/tall-tools-compare.md new file mode 100644 index 0000000000..c1d8e9b3c6 --- /dev/null +++ b/.changeset/tall-tools-compare.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-kubernetes-node': patch +--- + +Introducing PinnipedHelper class to enable authentication to kubernetes clusters throught Pinniped and PinnipedTMC diff --git a/plugins/kubernetes-node/api-report.md b/plugins/kubernetes-node/api-report.md index 7afac72574..ede0d213f1 100644 --- a/plugins/kubernetes-node/api-report.md +++ b/plugins/kubernetes-node/api-report.md @@ -4,6 +4,7 @@ ```ts import { AuthenticationStrategy as AuthenticationStrategy_2 } from '@backstage/plugin-kubernetes-node'; +import { ClusterDetails as ClusterDetails_2 } from '@backstage/plugin-kubernetes-node'; import { CustomResourceMatcher } from '@backstage/plugin-kubernetes-common'; import { Entity } from '@backstage/catalog-model'; import { ExtensionPoint } from '@backstage/backend-plugin-api'; @@ -15,6 +16,7 @@ import { KubernetesFetchError } from '@backstage/plugin-kubernetes-common'; import { KubernetesObjectsProvider as KubernetesObjectsProvider_2 } from '@backstage/plugin-kubernetes-node'; import { KubernetesRequestAuth } from '@backstage/plugin-kubernetes-common'; import { KubernetesServiceLocator as KubernetesServiceLocator_2 } from '@backstage/plugin-kubernetes-node'; +import { Logger } from 'winston'; import { ObjectsByEntityResponse } from '@backstage/plugin-kubernetes-common'; // @public (undocumented) @@ -231,6 +233,31 @@ export interface ObjectToFetch { plural: string; } +// @public (undocumented) +export type PinnipedClientCerts = { + key: string; + cert: string; + expirationTimestamp: string; +}; + +// @public (undocumented) +export class PinnipedHelper { + constructor(logger: Logger, flavour?: 'pinniped' | 'pinniped-tmc'); + // (undocumented) + readonly flavour: 'pinniped' | 'pinniped-tmc'; + // (undocumented) + tokenCredentialRequest( + clusterDetails: ClusterDetails_2, + pinnipedParams: PinnipedParameters, + ): Promise; +} + +// @public (undocumented) +export type PinnipedParameters = { + clusterIdToken: string; + JWTAuthenticatorName: string; +}; + // @public (undocumented) export interface ServiceLocatorRequestContext { // (undocumented) diff --git a/plugins/kubernetes-node/package.json b/plugins/kubernetes-node/package.json index 317f75063d..e2200eb6b1 100644 --- a/plugins/kubernetes-node/package.json +++ b/plugins/kubernetes-node/package.json @@ -22,7 +22,13 @@ "postpack": "backstage-cli package postpack" }, "devDependencies": { - "@backstage/cli": "workspace:^" + "@backstage/backend-app-api": "workspace:^", + "@backstage/backend-common": "workspace:^", + "@backstage/backend-test-utils": "workspace:^", + "@backstage/cli": "workspace:^", + "@backstage/plugin-kubernetes-backend": "workspace:^", + "msw": "^1.3.1", + "supertest": "^6.1.3" }, "files": [ "dist" @@ -31,6 +37,9 @@ "@backstage/backend-plugin-api": "workspace:^", "@backstage/catalog-model": "workspace:^", "@backstage/plugin-kubernetes-common": "workspace:^", - "@backstage/types": "workspace:^" + "@backstage/types": "workspace:^", + "@kubernetes/client-node": "^0.20.0", + "node-fetch": "^2.6.7", + "winston": "^3.2.1" } } diff --git a/plugins/kubernetes-node/src/auth/PinnipedHelper.test.ts b/plugins/kubernetes-node/src/auth/PinnipedHelper.test.ts new file mode 100644 index 0000000000..a112be26d3 --- /dev/null +++ b/plugins/kubernetes-node/src/auth/PinnipedHelper.test.ts @@ -0,0 +1,387 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ExtendedHttpServer } from '@backstage/backend-app-api'; +import { ClusterDetails } from '../types'; +import { + mockServices, + setupRequestMockHandlers, + startTestBackend, +} from '@backstage/backend-test-utils'; +import { createBackendModule } from '@backstage/backend-plugin-api'; +import { + kubernetesAuthStrategyExtensionPoint, + kubernetesClusterSupplierExtensionPoint, +} from '../extensions'; +import request from 'supertest'; +import { + ANNOTATION_KUBERNETES_AUTH_PROVIDER, + KubernetesRequestAuth, +} from '@backstage/plugin-kubernetes-common'; +import { PinnipedHelper, PinnipedParameters } from './PinnipedHelper'; +import { getVoidLogger } from '@backstage/backend-common'; +import { HEADER_KUBERNETES_CLUSTER } from '@backstage/plugin-kubernetes-backend'; +import { JsonObject } from '@backstage/types'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; + +describe('Pinniped - tokenCredentialRequest', () => { + let app: ExtendedHttpServer; + const logger = getVoidLogger(); + let httpsRequest: jest.SpyInstance; + const worker = setupServer(); + setupRequestMockHandlers(worker); + + beforeAll(() => { + httpsRequest = jest.spyOn( + // this is pretty egregious reverse engineering of msw. + // If the SetupServerApi constructor was exported, we wouldn't need + // to be quite so hacky here + (worker as any).interceptor.interceptors[0].modules.get('https'), + 'request', + ); + }); + + beforeEach(async () => { + httpsRequest.mockClear(); + + const clusterSupplierMock = { + getClusters: jest.fn().mockImplementation(_ => { + return Promise.resolve([ + { + name: 'custom-cluster', + url: 'https://my.cluster.url', + authMetadata: { + [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'pinniped', + }, + skipTLSVerify: true, + }, + ]); + }), + }; + + const { server } = await startTestBackend({ + features: [ + mockServices.rootConfig.factory({ + data: { + kubernetes: { + serviceLocatorMethod: { + type: 'multiTenant', + }, + clusterLocatorMethods: [ + { + type: 'config', + clusters: [], + }, + ], + }, + }, + }), + import('@backstage/plugin-kubernetes-backend/alpha'), + createBackendModule({ + pluginId: 'kubernetes', + moduleId: 'testClusterSupplier', + register(env) { + env.registerInit({ + deps: { extension: kubernetesClusterSupplierExtensionPoint }, + async init({ extension }) { + extension.addClusterSupplier(clusterSupplierMock); + }, + }); + }, + }), + createBackendModule({ + pluginId: 'kubernetes', + moduleId: 'PinnipedAuthStrategy', + register(env) { + env.registerInit({ + deps: { extension: kubernetesAuthStrategyExtensionPoint }, + async init({ extension }) { + extension.addAuthStrategy('pinniped', { + getCredential: async ( + clusterDetails: ClusterDetails, + authConfig: KubernetesRequestAuth, + ) => { + const pinnipedHelper = new PinnipedHelper(logger); + const pinnipedParams: PinnipedParameters = { + clusterIdToken: + ((authConfig.pinniped as JsonObject) + ?.clusteridtoken as string) || '', + JWTAuthenticatorName: 'supervisor', + }; + const clientCerts = + await pinnipedHelper.tokenCredentialRequest( + clusterDetails, + pinnipedParams, + ); + return { + type: 'x509 client certificate', + key: clientCerts.key, + cert: clientCerts.cert, + }; + }, + validateCluster: jest.fn().mockReturnValue([]), + }); + }, + }); + }, + }), + ], + }); + + app = server; + }); + + describe('TLS Clusters', () => { + it('Should get certs data from Concierge', async () => { + worker.use( + rest.get('https://my.cluster.url/api/v1/namespaces', (_, res, ctx) => { + return res(ctx.json({ items: [] })); + }), + ); + + const myCert = 'MOCKCert'; + const myKey = 'MOCKKey'; + + worker.use( + rest.post( + 'https://my.cluster.url/apis/login.concierge.pinniped.dev/v1alpha1/tokencredentialrequests', + (_, res, ctx) => { + return res( + ctx.json({ + status: { + credential: { + clientKeyData: myKey, + clientCertificateData: myCert, + expirationTimestamp: '2024-01-04T14:30:30.373Z', + }, + }, + }), + ); + }, + ), + ); + + const proxyEndpointRequest = request(app) + .get('/api/kubernetes/proxy/api/v1/namespaces') + .set(HEADER_KUBERNETES_CLUSTER, 'custom-cluster') + .set( + 'Backstage-Kubernetes-Authorization-Pinniped-ClusterIDToken', + 'ClusterID Specific Token', + ); + + worker.use(rest.all(proxyEndpointRequest.url, req => req.passthrough())); + + const result = await proxyEndpointRequest; + + expect(JSON.stringify(result)).toMatch(/PEM/); + + expect(httpsRequest).toHaveBeenCalledTimes(2); + const [{ cert, key }] = httpsRequest.mock.calls[1]; + expect(cert).toEqual(myCert); + expect(key).toEqual(myKey); + }); + + it('Should get certs data from TMC-flavoured Pinniped', async () => { + worker.use( + rest.get('https://my.cluster.url/api/v1/namespaces', (_, res, ctx) => { + return res(ctx.json({ items: [] })); + }), + ); + + const myCert = 'MOCKCert2'; + const myKey = 'MOCKKey2'; + + worker.use( + rest.post( + 'https://my.cluster.url/apis/login.concierge.pinniped.tmc.cloud.vmware.com/v1alpha1/tokencredentialrequests', + (_, res, ctx) => { + return res( + ctx.json({ + status: { + credential: { + clientKeyData: myKey, + clientCertificateData: myCert, + expirationTimestamp: '2024-01-04T14:30:30.373Z', + }, + }, + }), + ); + }, + ), + ); + + const clusterSupplierMock = { + getClusters: jest.fn().mockImplementation(_ => { + return Promise.resolve([ + { + name: 'tmc-cluster', + url: 'https://my.cluster.url', + authMetadata: { + [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'pinnipedtmc', + }, + skipTLSVerify: true, + }, + ]); + }), + }; + + const { server } = await startTestBackend({ + features: [ + mockServices.rootConfig.factory({ + data: { + kubernetes: { + serviceLocatorMethod: { + type: 'multiTenant', + }, + clusterLocatorMethods: [ + { + type: 'config', + clusters: [], + }, + ], + }, + }, + }), + import('@backstage/plugin-kubernetes-backend/alpha'), + createBackendModule({ + pluginId: 'kubernetes', + moduleId: 'testClusterSupplier', + register(env) { + env.registerInit({ + deps: { extension: kubernetesClusterSupplierExtensionPoint }, + async init({ extension }) { + extension.addClusterSupplier(clusterSupplierMock); + }, + }); + }, + }), + createBackendModule({ + pluginId: 'kubernetes', + moduleId: 'PinnipedAuthStrategy', + register(env) { + env.registerInit({ + deps: { extension: kubernetesAuthStrategyExtensionPoint }, + async init({ extension }) { + extension.addAuthStrategy('pinnipedtmc', { + getCredential: async ( + clusterDetails: ClusterDetails, + authConfig: KubernetesRequestAuth, + ) => { + const pinnipedHelper = new PinnipedHelper( + logger, + 'pinniped-tmc', + ); + const pinnipedParams: PinnipedParameters = { + clusterIdToken: + ((authConfig.pinniped as JsonObject) + ?.clusteridtoken as string) || '', + JWTAuthenticatorName: 'supervisor', + }; + const clientCerts = + await pinnipedHelper.tokenCredentialRequest( + clusterDetails, + pinnipedParams, + ); + return { + type: 'x509 client certificate', + key: clientCerts.key, + cert: clientCerts.cert, + }; + }, + validateCluster: jest.fn().mockReturnValue([]), + }); + }, + }); + }, + }), + ], + }); + + app = server; + + const proxyEndpointRequest = request(app) + .get('/api/kubernetes/proxy/api/v1/namespaces') + .set(HEADER_KUBERNETES_CLUSTER, 'tmc-cluster') + .set( + 'Backstage-Kubernetes-Authorization-Pinniped-ClusterIDToken', + 'ClusterID Specific Token', + ); + + worker.use(rest.all(proxyEndpointRequest.url, req => req.passthrough())); + + const result = await proxyEndpointRequest; + + expect(JSON.stringify(result)).toMatch(/PEM/); + + expect(httpsRequest).toHaveBeenCalledTimes(2); + const [{ cert, key }] = httpsRequest.mock.calls[1]; + expect(cert).toEqual(myCert); + expect(key).toEqual(myKey); + }); + + it('Should get an error when Concierge return an error', async () => { + worker.use( + rest.get('https://my.cluster.url/api/v1/namespaces', (_, res, ctx) => { + return res(ctx.json({ items: [] })); + }), + ); + + worker.use( + rest.post( + 'https://my.cluster.url/apis/login.concierge.pinniped.dev/v1alpha1/tokencredentialrequests', + (_, res, ctx) => { + return res( + ctx.json({ + kind: 'TokenCredentialRequest', + apiVersion: 'login.concierge.pinniped.dev/v1alpha1', + metadata: { + creationTimestamp: null, + }, + spec: { + authenticator: { + apiGroup: null, + kind: '', + name: '', + }, + }, + status: { + message: 'authentication failed', + }, + }), + ); + }, + ), + ); + + const proxyEndpointRequest = request(app) + .get('/api/kubernetes/proxy/api/v1/namespaces') + .set(HEADER_KUBERNETES_CLUSTER, 'custom-cluster') + .set( + 'Backstage-Kubernetes-Authorization-Pinniped-ClusterIDToken', + 'ClusterID Specific Token', + ); + + worker.use(rest.all(proxyEndpointRequest.url, req => req.passthrough())); + + const result = await proxyEndpointRequest; + + expect(JSON.stringify(result)).toMatch(/error/); + + expect(httpsRequest).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/plugins/kubernetes-node/src/auth/PinnipedHelper.ts b/plugins/kubernetes-node/src/auth/PinnipedHelper.ts new file mode 100644 index 0000000000..ce48927ad9 --- /dev/null +++ b/plugins/kubernetes-node/src/auth/PinnipedHelper.ts @@ -0,0 +1,177 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ClusterDetails } from '@backstage/plugin-kubernetes-node'; +import * as https from 'https'; +import { bufferFromFileOrString } from '@kubernetes/client-node'; +import fetch, { RequestInit } from 'node-fetch'; +import { Logger } from 'winston'; + +/** + * + * @public + */ +export type PinnipedClientCerts = { + key: string; + cert: string; + expirationTimestamp: string; +}; + +/** + * + * @public + */ +export type PinnipedParameters = { + clusterIdToken: string; + JWTAuthenticatorName: string; +}; + +type ApiResourcePinniped = { + authenticator: { + apiGroup: string; + kind: string; + }; + apiVersion: string; +}; + +/** + * + * @public + */ +export class PinnipedHelper { + readonly flavour: 'pinniped' | 'pinniped-tmc'; + + constructor( + private readonly logger: Logger, + flavour: 'pinniped' | 'pinniped-tmc' = 'pinniped', + ) { + this.flavour = flavour; + } + + public async tokenCredentialRequest( + clusterDetails: ClusterDetails, + pinnipedParams: PinnipedParameters, + ): Promise { + this.logger.debug('Pinniped: Requesting client Certs to Concierge'); + return await this.exchangeClusterTokentoClientCerts( + clusterDetails, + pinnipedParams, + ); + } + + private async exchangeClusterTokentoClientCerts( + clusterDetails: ClusterDetails, + pinnipedParams: PinnipedParameters, + ): Promise { + const url: URL = new URL(clusterDetails.url); + const apiResourcePinniped: ApiResourcePinniped = + this.getApiResourcePinniped(); + + url.pathname = `/apis/${apiResourcePinniped.apiVersion}/tokencredentialrequests`; + + const requestInit: RequestInit = this.buildRequestForPinniped( + url, + clusterDetails, + pinnipedParams, + apiResourcePinniped, + ); + + this.logger.info( + 'Fetching client certs for mTLS authentication on Pinniped', + ); + let response; + try { + response = await fetch(url, requestInit); + } catch (error) { + this.logger.error('Pinniped request error', error); + throw error; + } + + const data: any = await response.json(); + + if (data.status.credential) { + const result = { + key: data.status.credential.clientKeyData, + cert: data.status.credential.clientCertificateData, + expirationTimestamp: data.status.credential.expirationTimestamp, + }; + return Promise.resolve(result); + } + + this.logger.error('Unable to fetch client certs,', data.status); + return Promise.reject(data.status.message); + } + + private buildRequestForPinniped( + url: URL, + clusterDetails: ClusterDetails, + pinnipedParams: PinnipedParameters, + apiResourcePinniped: ApiResourcePinniped, + ): RequestInit { + const body = { + apiVersion: apiResourcePinniped.apiVersion, + kind: 'TokenCredentialRequest', + spec: { + authenticator: { + apiGroup: apiResourcePinniped.authenticator.apiGroup, + kind: apiResourcePinniped.authenticator.kind, + name: pinnipedParams.JWTAuthenticatorName, + }, + token: pinnipedParams.clusterIdToken, + }, + }; + const requestInit: RequestInit = { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }; + + if (url.protocol === 'https:') { + requestInit.agent = new https.Agent({ + ca: + bufferFromFileOrString( + clusterDetails.caFile, + clusterDetails.caData, + ) ?? undefined, + rejectUnauthorized: !clusterDetails.skipTLSVerify, + }); + } + + return requestInit; + } + + private getApiResourcePinniped(): ApiResourcePinniped { + if (this.flavour === 'pinniped') { + return { + authenticator: { + apiGroup: 'authentication.concierge.pinniped.dev', + kind: 'JWTAuthenticator', + }, + apiVersion: 'login.concierge.pinniped.dev/v1alpha1', + }; + } + return { + authenticator: { + apiGroup: 'authentication.concierge.pinniped.tmc.cloud.vmware.com', + kind: 'WebhookAuthenticator', + }, + apiVersion: 'login.concierge.pinniped.tmc.cloud.vmware.com/v1alpha1', + }; + } +} diff --git a/plugins/kubernetes-node/src/auth/index.ts b/plugins/kubernetes-node/src/auth/index.ts new file mode 100644 index 0000000000..8459f04f81 --- /dev/null +++ b/plugins/kubernetes-node/src/auth/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './PinnipedHelper'; diff --git a/plugins/kubernetes-node/src/index.ts b/plugins/kubernetes-node/src/index.ts index 162d5baf26..e0f2688ca2 100644 --- a/plugins/kubernetes-node/src/index.ts +++ b/plugins/kubernetes-node/src/index.ts @@ -31,3 +31,4 @@ export * from './extensions'; export * from './types'; +export * from './auth'; diff --git a/plugins/kubernetes-node/src/types/types.ts b/plugins/kubernetes-node/src/types/types.ts index d7b2b67265..2881338868 100644 --- a/plugins/kubernetes-node/src/types/types.ts +++ b/plugins/kubernetes-node/src/types/types.ts @@ -182,7 +182,7 @@ export type KubernetesObjectTypes = * @public */ export interface ObjectToFetch { - objectType: KubernetesObjectTypes; // TODO - Review + objectType: KubernetesObjectTypes; group: string; apiVersion: string; plural: string; diff --git a/yarn.lock b/yarn.lock index 0342b37b52..32225de9dd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7180,11 +7180,20 @@ __metadata: version: 0.0.0-use.local resolution: "@backstage/plugin-kubernetes-node@workspace:plugins/kubernetes-node" dependencies: + "@backstage/backend-app-api": "workspace:^" + "@backstage/backend-common": "workspace:^" "@backstage/backend-plugin-api": "workspace:^" + "@backstage/backend-test-utils": "workspace:^" "@backstage/catalog-model": "workspace:^" "@backstage/cli": "workspace:^" + "@backstage/plugin-kubernetes-backend": "workspace:^" "@backstage/plugin-kubernetes-common": "workspace:^" "@backstage/types": "workspace:^" + "@kubernetes/client-node": ^0.20.0 + msw: ^1.3.1 + node-fetch: ^2.6.7 + supertest: ^6.1.3 + winston: ^3.2.1 languageName: unknown linkType: soft