diff --git a/.changeset/sweet-eels-hunt.md b/.changeset/sweet-eels-hunt.md new file mode 100644 index 0000000000..39f124a860 --- /dev/null +++ b/.changeset/sweet-eels-hunt.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-kubernetes': patch +--- + +`KubernetesBackendClient` now requires a `kubernetesAuthProvidersApi` value to be provided. `KubernetesApi` interface now has a proxy method requirement. diff --git a/docs/features/kubernetes/proxy.md b/docs/features/kubernetes/proxy.md index 7b6837dd7d..5811ff8ec4 100644 --- a/docs/features/kubernetes/proxy.md +++ b/docs/features/kubernetes/proxy.md @@ -14,42 +14,16 @@ Kubernetes backend plugin's proxy endpoint to allow them to make arbitrary requests to the [REST API](https://kubernetes.io/docs/reference/using-api/api-concepts/). -Here is a snippet fetching namespaces from a cluster configured with the -`google` [auth provider](https://backstage.io/docs/features/kubernetes/configuration#clustersauthprovider): +Here is a snippet fetching namespaces using the `KubernetesBackendClient` library ```typescript -import { - discoveryApiRef, - googleAuthApiRef, - useApi, - identityApiRef, -} from '@backstage/core-plugin-api'; +import { useApi } from '@backstage/core-plugin-api'; +import { kubernetesApiRef } from '@backstage/plugin-kubernetes'; const CLUSTER_NAME = ''; // use a known cluster name -// get a bearer token from Google -const googleAuthApi = useApi(googleAuthApiRef); -const token = await googleAuthApi.getAccessToken( - 'https://www.googleapis.com/auth/cloud-platform', -); - -// get a backstage ID token -const identityApi = useApi(identityApiRef); -const { token: userToken } = await identityApi.getCredentials(); - -const discoveryApi = useApi(discoveryApiRef); -const kubernetesBaseUrl = await discoveryApi.getBaseUrl('kubernetes'); -const kubernetesProxyEndpoint = `${kubernetesBaseUrl}/proxy`; - -// fetch namespaces -await fetch(`${kubernetesProxyEndpoint}/api/v1/namespaces`, { - method: 'GET', - headers: { - 'Backstage-Kubernetes-Cluster': CLUSTER_NAME, - 'Backstage-Kubernetes-Authorization': `Bearer ${token}`, - Authorization: `Bearer ${userToken}`, - }, -}); +const kubernetesApi = useApi(kubernetesApiRef); +await kubernetesApi.proxy(CLUSTER_NAME, '/api/v1/namespaces'); ``` ## How it works diff --git a/plugins/kubernetes/api-report.md b/plugins/kubernetes/api-report.md index ad1217e257..3344e8aa49 100644 --- a/plugins/kubernetes/api-report.md +++ b/plugins/kubernetes/api-report.md @@ -182,6 +182,10 @@ export class GoogleKubernetesAuthProvider implements KubernetesAuthProvider { decorateRequestBodyForAuth( requestBody: KubernetesRequestBody, ): Promise; + // (undocumented) + getCredentials(): Promise<{ + token: string; + }>; } // Warning: (ae-missing-release-tag) "GroupedResponses" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -257,6 +261,12 @@ export interface KubernetesApi { getWorkloadsByEntity( request: WorkloadsByEntityRequest, ): Promise; + // (undocumented) + proxy(options: { + clusterName: string; + path: string; + init?: RequestInit; + }): Promise; } // Warning: (ae-missing-release-tag) "kubernetesApiRef" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -279,6 +289,10 @@ export class KubernetesAuthProviders implements KubernetesAuthProvidersApi { authProvider: string, requestBody: KubernetesRequestBody, ): Promise; + // (undocumented) + getCredentials(authProvider: string): Promise<{ + token: string; + }>; } // Warning: (ae-missing-release-tag) "KubernetesAuthProvidersApi" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -290,6 +304,10 @@ export interface KubernetesAuthProvidersApi { authProvider: string, requestBody: KubernetesRequestBody, ): Promise; + // (undocumented) + getCredentials(authProvider: string): Promise<{ + token: string; + }>; } // Warning: (ae-missing-release-tag) "kubernetesAuthProvidersApiRef" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -304,6 +322,7 @@ export class KubernetesBackendClient implements KubernetesApi { constructor(options: { discoveryApi: DiscoveryApi; identityApi: IdentityApi; + kubernetesAuthProvidersApi: KubernetesAuthProvidersApi; }); // (undocumented) getClusters(): Promise< @@ -324,6 +343,12 @@ export class KubernetesBackendClient implements KubernetesApi { getWorkloadsByEntity( request: WorkloadsByEntityRequest, ): Promise; + // (undocumented) + proxy(options: { + clusterName: string; + path: string; + init?: RequestInit; + }): Promise; } // Warning: (ae-forgotten-export) The symbol "KubernetesContentProps" needs to be exported by the entry point index.d.ts @@ -414,6 +439,10 @@ export class ServerSideKubernetesAuthProvider decorateRequestBodyForAuth( requestBody: KubernetesRequestBody, ): Promise; + // (undocumented) + getCredentials(): Promise<{ + token: string; + }>; } // Warning: (ae-forgotten-export) The symbol "ServicesAccordionsProps" needs to be exported by the entry point index.d.ts diff --git a/plugins/kubernetes/dev/index.tsx b/plugins/kubernetes/dev/index.tsx index 77708f1287..5780d649fb 100644 --- a/plugins/kubernetes/dev/index.tsx +++ b/plugins/kubernetes/dev/index.tsx @@ -106,6 +106,16 @@ class MockKubernetesClient implements KubernetesApi { async getClusters(): Promise<{ name: string; authProvider: string }[]> { return [{ name: 'mock-cluster', authProvider: 'serviceAccount' }]; } + + async proxy(_options: { clusterName: String; path: String }): Promise { + return { + kind: 'Namespace', + apiVersion: 'v1', + metadata: { + name: 'mock-ns', + }, + }; + } } createDevApp() diff --git a/plugins/kubernetes/package.json b/plugins/kubernetes/package.json index edff7348a4..9fcb92c669 100644 --- a/plugins/kubernetes/package.json +++ b/plugins/kubernetes/package.json @@ -37,6 +37,7 @@ "@backstage/config": "workspace:^", "@backstage/core-components": "workspace:^", "@backstage/core-plugin-api": "workspace:^", + "@backstage/errors": "workspace:^", "@backstage/plugin-catalog-react": "workspace:^", "@backstage/plugin-kubernetes-common": "workspace:^", "@backstage/theme": "workspace:^", diff --git a/plugins/kubernetes/src/api/KubernetesBackendClient.test.ts b/plugins/kubernetes/src/api/KubernetesBackendClient.test.ts new file mode 100644 index 0000000000..2c01f15b18 --- /dev/null +++ b/plugins/kubernetes/src/api/KubernetesBackendClient.test.ts @@ -0,0 +1,539 @@ +/* + * Copyright 2023 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 { KubernetesAuthProvidersApi } from '../kubernetes-auth-provider'; +import { KubernetesBackendClient } from './KubernetesBackendClient'; +import { rest } from 'msw'; +import { UrlPatternDiscovery } from '@backstage/core-app-api'; +import { setupServer } from 'msw/node'; +import { setupRequestMockHandlers } from '@backstage/test-utils'; +import { + CustomObjectsByEntityRequest, + KubernetesRequestBody, + ObjectsByEntityResponse, + WorkloadsByEntityRequest, +} from '@backstage/plugin-kubernetes-common'; +import { NotFoundError } from '@backstage/errors'; + +describe('KubernetesBackendClient', () => { + let backendClient: KubernetesBackendClient; + const kubernetesAuthProvidersApi: jest.Mocked = { + decorateRequestBodyForAuth: jest.fn(), + getCredentials: jest.fn(), + }; + let mockResponse: ObjectsByEntityResponse; + const worker = setupServer(); + setupRequestMockHandlers(worker); + + const identityApi = { + getCredentials: jest.fn(), + getProfileInfo: jest.fn(), + getBackstageIdentity: jest.fn(), + signOut: jest.fn(), + }; + + beforeEach(() => { + jest.resetAllMocks(); + backendClient = new KubernetesBackendClient({ + discoveryApi: UrlPatternDiscovery.compile( + 'http://localhost:1234/api/{{ pluginId }}', + ), + identityApi, + kubernetesAuthProvidersApi, + }); + mockResponse = { + items: [ + { + cluster: { + name: 'cluster-a', + }, + resources: [{ type: 'pods', resources: [] }], + podMetrics: [ + { + pod: {}, + cpu: { currentUsage: 8, requestTotal: 2, limitTotal: 1 }, + memory: { currentUsage: 8, requestTotal: 2, limitTotal: 1 }, + containers: [ + { + container: 'test', + cpuUsage: { currentUsage: 8, requestTotal: 2, limitTotal: 1 }, + memoryUsage: { + currentUsage: 8, + requestTotal: 2, + limitTotal: 1, + }, + }, + ], + }, + ], + errors: [], + }, + ], + }; + }); + + it('hits the /clusters API', async () => { + identityApi.getCredentials.mockResolvedValue({ token: 'idToken' }); + worker.use( + rest.get('http://localhost:1234/api/kubernetes/clusters', (_, res, ctx) => + res(ctx.json({ items: [{ name: 'cluster-a', authProvider: 'aws' }] })), + ), + ); + + const clusters = await backendClient.getClusters(); + + expect(clusters).toStrictEqual([ + { name: 'cluster-a', authProvider: 'aws' }, + ]); + }); + + it('/clusters API throws a 404 Error', async () => { + identityApi.getCredentials.mockResolvedValue({ token: 'idToken' }); + worker.use( + rest.get('http://localhost:1234/api/kubernetes/clusters', (_, res, ctx) => + res(ctx.status(404)), + ), + ); + + await expect(backendClient.getClusters()).rejects.toThrow( + 'Could not find the Kubernetes Backend (HTTP 404). Make sure the plugin has been fully installed.', + ); + }); + + it('/clusters API throws a 500 Error', async () => { + identityApi.getCredentials.mockResolvedValue({ token: 'idToken' }); + worker.use( + rest.get('http://localhost:1234/api/kubernetes/clusters', (_, res, ctx) => + res(ctx.status(500)), + ), + ); + + await expect(backendClient.getClusters()).rejects.toThrow( + 'Request failed with 500 Internal Server Error, ', + ); + }); + + it('hits the /resources/custom/query API', async () => { + identityApi.getCredentials.mockResolvedValue({ token: 'idToken' }); + worker.use( + rest.post( + 'http://localhost:1234/api/kubernetes/resources/custom/query', + (_, res, ctx) => res(ctx.json(mockResponse)), + ), + ); + + const request: CustomObjectsByEntityRequest = { + auth: {}, + customResources: [ + { + group: 'test-group', + apiVersion: 'v1', + plural: 'none', + }, + ], + entity: { + apiVersion: 'v1', + kind: 'pod', + metadata: { + name: 'test-name', + }, + }, + }; + + const customObject: ObjectsByEntityResponse = + await backendClient.getCustomObjectsByEntity(request); + + expect(customObject).toStrictEqual(mockResponse); + }); + + it('/resources/custom/query API throws a 404 error', async () => { + identityApi.getCredentials.mockResolvedValue({ token: 'idToken' }); + worker.use( + rest.post( + 'http://localhost:1234/api/kubernetes/resources/custom/query', + (_, res, ctx) => res(ctx.status(404)), + ), + ); + + const request: CustomObjectsByEntityRequest = { + auth: {}, + customResources: [ + { + group: 'test-group', + apiVersion: 'v1', + plural: 'none', + }, + ], + entity: { + apiVersion: 'v1', + kind: 'pod', + metadata: { + name: 'test-name', + }, + }, + }; + + const response = backendClient.getCustomObjectsByEntity(request); + + await expect(response).rejects.toThrow( + 'Could not find the Kubernetes Backend (HTTP 404). Make sure the plugin has been fully installed.', + ); + }); + + it('/resources/custom/query API throws a 500 error', async () => { + identityApi.getCredentials.mockResolvedValue({ token: 'idToken' }); + worker.use( + rest.post( + 'http://localhost:1234/api/kubernetes/resources/custom/query', + (_, res, ctx) => res(ctx.status(500)), + ), + ); + + const request: CustomObjectsByEntityRequest = { + auth: {}, + customResources: [ + { + group: 'test-group', + apiVersion: 'v1', + plural: 'none', + }, + ], + entity: { + apiVersion: 'v1', + kind: 'pod', + metadata: { + name: 'test-name', + }, + }, + }; + + const response = backendClient.getCustomObjectsByEntity(request); + + await expect(response).rejects.toThrow( + 'Request failed with 500 Internal Server Error, ', + ); + }); + + it('hits the /services/{entityName} API', async () => { + identityApi.getCredentials.mockResolvedValue({ token: 'idToken' }); + worker.use( + rest.post( + 'http://localhost:1234/api/kubernetes/services/test-name', + (_, res, ctx) => res(ctx.json(mockResponse)), + ), + ); + + const request: KubernetesRequestBody = { + entity: { + apiVersion: 'v1', + kind: 'pod', + metadata: { + name: 'test-name', + }, + }, + }; + + const entityObject: ObjectsByEntityResponse = + await backendClient.getObjectsByEntity(request); + + expect(entityObject).toStrictEqual(mockResponse); + }); + + it('services/{entityName} API throws a 404 error', async () => { + identityApi.getCredentials.mockResolvedValue({ token: 'idToken' }); + worker.use( + rest.post( + 'http://localhost:1234/api/kubernetes/services/test-name', + (_, res, ctx) => res(ctx.status(404)), + ), + ); + + const request: KubernetesRequestBody = { + entity: { + apiVersion: 'v1', + kind: 'pod', + metadata: { + name: 'test-name', + }, + }, + }; + + const response = backendClient.getObjectsByEntity(request); + + await expect(response).rejects.toThrow( + 'Could not find the Kubernetes Backend (HTTP 404). Make sure the plugin has been fully installed.', + ); + }); + + it('services/{entityName} API throws a 500 error', async () => { + identityApi.getCredentials.mockResolvedValue({ token: 'idToken' }); + worker.use( + rest.post( + 'http://localhost:1234/api/kubernetes/services/test-name', + (_, res, ctx) => res(ctx.status(500)), + ), + ); + + const request: KubernetesRequestBody = { + entity: { + apiVersion: 'v1', + kind: 'pod', + metadata: { + name: 'test-name', + }, + }, + }; + + const response = backendClient.getObjectsByEntity(request); + + await expect(response).rejects.toThrow( + 'Request failed with 500 Internal Server Error, ', + ); + }); + + it('hits the /resources/workloads/query API', async () => { + identityApi.getCredentials.mockResolvedValue({ token: 'idToken' }); + worker.use( + rest.post( + 'http://localhost:1234/api/kubernetes/resources/workloads/query', + (_, res, ctx) => res(ctx.json(mockResponse)), + ), + ); + + const request: WorkloadsByEntityRequest = { + auth: {}, + entity: { + apiVersion: 'v1', + kind: 'pod', + metadata: { + name: 'test-name', + }, + }, + }; + + const response: ObjectsByEntityResponse = + await backendClient.getWorkloadsByEntity(request); + + expect(response).toStrictEqual(mockResponse); + }); + + it('/resources/workloads/query API throws a 404 error', async () => { + identityApi.getCredentials.mockResolvedValue({ token: 'idToken' }); + worker.use( + rest.post( + 'http://localhost:1234/api/kubernetes/resources/workloads/query', + (_, res, ctx) => res(ctx.status(404)), + ), + ); + + const request: WorkloadsByEntityRequest = { + auth: {}, + entity: { + apiVersion: 'v1', + kind: 'pod', + metadata: { + name: 'test-name', + }, + }, + }; + + const response = backendClient.getWorkloadsByEntity(request); + + await expect(response).rejects.toThrow( + 'Could not find the Kubernetes Backend (HTTP 404). Make sure the plugin has been fully installed.', + ); + }); + + it('/resources/workloads/query API throws a 500 error', async () => { + identityApi.getCredentials.mockResolvedValue({ token: 'idToken' }); + worker.use( + rest.post( + 'http://localhost:1234/api/kubernetes/resources/workloads/query', + (_, res, ctx) => res(ctx.status(500)), + ), + ); + + const request: WorkloadsByEntityRequest = { + auth: {}, + entity: { + apiVersion: 'v1', + kind: 'pod', + metadata: { + name: 'test-name', + }, + }, + }; + + const response = backendClient.getWorkloadsByEntity(request); + + await expect(response).rejects.toThrow( + 'Request failed with 500 Internal Server Error, ', + ); + }); + + describe('proxy', () => { + beforeEach(() => { + worker.use( + rest.get( + 'http://localhost:1234/api/kubernetes/clusters', + (_, res, ctx) => + res( + ctx.json({ items: [{ name: 'cluster-a', authProvider: 'aws' }] }), + ), + ), + ); + }); + + it('hits the /proxy API', async () => { + identityApi.getCredentials.mockResolvedValue({ token: 'idToken' }); + kubernetesAuthProvidersApi.getCredentials.mockResolvedValue({ + token: 'k8-token', + }); + 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('Backstage-Kubernetes-Authorization') === + 'Bearer k8-token' + ? 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 /proxy api when signed in as a guest', async () => { + // when a user is signed in as a guest the result of the getCredentials() method resolves to the {} value. + identityApi.getCredentials.mockResolvedValue({}); + kubernetesAuthProvidersApi.getCredentials.mockResolvedValue({ + token: 'k8-token', + }); + 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('Backstage-Kubernetes-Authorization') === + 'Bearer k8-token' + ? 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('/proxy API throws a 404 error', async () => { + identityApi.getCredentials.mockResolvedValue({ token: 'idToken' }); + kubernetesAuthProvidersApi.getCredentials.mockResolvedValue({ + token: 'k8-token', + }); + worker.use( + rest.get( + 'http://localhost:1234/api/kubernetes/proxy/api/v1/namespaces', + (_, res, ctx) => res(ctx.status(404)), + ), + ); + + const request = { + clusterName: 'cluster-a', + path: '/api/v1/namespaces', + }; + + const response = await backendClient.proxy(request); + + expect(response.status).toEqual(404); + }); + + it('throws a ERROR_NOT_FOUND if the cluster in the request is not found', async () => { + identityApi.getCredentials.mockResolvedValue({ token: 'idToken' }); + + const request = { + clusterName: 'cluster-b', + path: '/api/v1/namespaces', + }; + + await expect(backendClient.proxy(request)).rejects.toThrow(NotFoundError); + }); + + it('responds with an 403 error when invalid k8 token is provided', async () => { + // when a user is signed in as a guest the result of the getCredentials() method resolves to the {} value. + identityApi.getCredentials.mockResolvedValue({}); + kubernetesAuthProvidersApi.getCredentials.mockResolvedValue({ + token: 'wrong-token', + }); + + 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('Backstage-Kubernetes-Authorization') === + 'Bearer k8-token' + ? ctx.json(nsResponse) + : ctx.status(403), + ), + ), + ); + + const request = { + clusterName: 'cluster-a', + path: '/api/v1/namespaces', + }; + + const response = await backendClient.proxy(request); + expect(response.status).toEqual(403); + }); + }); +}); diff --git a/plugins/kubernetes/src/api/KubernetesBackendClient.ts b/plugins/kubernetes/src/api/KubernetesBackendClient.ts index bffc966333..bc510589c8 100644 --- a/plugins/kubernetes/src/api/KubernetesBackendClient.ts +++ b/plugins/kubernetes/src/api/KubernetesBackendClient.ts @@ -23,17 +23,22 @@ import { } from '@backstage/plugin-kubernetes-common'; import { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api'; import { stringifyEntityRef } from '@backstage/catalog-model'; +import { KubernetesAuthProvidersApi } from '../kubernetes-auth-provider'; +import { NotFoundError } from '@backstage/errors'; export class KubernetesBackendClient implements KubernetesApi { private readonly discoveryApi: DiscoveryApi; private readonly identityApi: IdentityApi; + private readonly kubernetesAuthProvidersApi: KubernetesAuthProvidersApi; constructor(options: { discoveryApi: DiscoveryApi; identityApi: IdentityApi; + kubernetesAuthProvidersApi: KubernetesAuthProvidersApi; }) { this.discoveryApi = options.discoveryApi; this.identityApi = options.identityApi; + this.kubernetesAuthProvidersApi = options.kubernetesAuthProvidersApi; } private async handleResponse(response: Response): Promise { @@ -69,6 +74,25 @@ export class KubernetesBackendClient implements KubernetesApi { return this.handleResponse(response); } + private async getCluster( + clusterName: string, + ): Promise<{ name: string; authProvider: string }> { + const cluster = await this.getClusters().then(clusters => + clusters.find(c => c.name === clusterName), + ); + if (!cluster) { + throw new NotFoundError(`Cluster ${clusterName} not found`); + } + + return cluster; + } + + private async getCredentials( + authProvider: string, + ): Promise<{ token: string }> { + return await this.kubernetesAuthProvidersApi.getCredentials(authProvider); + } + async getObjectsByEntity( requestBody: KubernetesRequestBody, ): Promise { @@ -100,7 +124,6 @@ export class KubernetesBackendClient implements KubernetesApi { async getClusters(): Promise<{ name: string; authProvider: string }[]> { const { token: idToken } = await this.identityApi.getCredentials(); const url = `${await this.discoveryApi.getBaseUrl('kubernetes')}/clusters`; - const response = await fetch(url, { method: 'GET', headers: { @@ -110,4 +133,27 @@ export class KubernetesBackendClient implements KubernetesApi { return (await this.handleResponse(response)).items; } + + async proxy(options: { + clusterName: string; + path: string; + init?: RequestInit; + }): Promise { + const { authProvider } = await this.getCluster(options.clusterName); + const { token: k8sToken } = await this.getCredentials(authProvider); + const url = `${await this.discoveryApi.getBaseUrl('kubernetes')}/proxy${ + options.path + }`; + const identityResponse = await this.identityApi.getCredentials(); + const headers = { + ...options.init?.headers, + [`Backstage-Kubernetes-Cluster`]: options.clusterName, + [`Backstage-Kubernetes-Authorization`]: `Bearer ${k8sToken}`, + ...(identityResponse.token && { + Authorization: `Bearer ${identityResponse.token}`, + }), + }; + + return await fetch(url, { ...options.init, headers }); + } } diff --git a/plugins/kubernetes/src/api/types.ts b/plugins/kubernetes/src/api/types.ts index 0005a8d069..02f2fd3521 100644 --- a/plugins/kubernetes/src/api/types.ts +++ b/plugins/kubernetes/src/api/types.ts @@ -43,4 +43,9 @@ export interface KubernetesApi { getCustomObjectsByEntity( request: CustomObjectsByEntityRequest, ): Promise; + proxy(options: { + clusterName: string; + path: string; + init?: RequestInit; + }): Promise; } diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/GoogleKubernetesAuthProvider.ts b/plugins/kubernetes/src/kubernetes-auth-provider/GoogleKubernetesAuthProvider.ts index 058ecf07a3..5652549b8a 100644 --- a/plugins/kubernetes/src/kubernetes-auth-provider/GoogleKubernetesAuthProvider.ts +++ b/plugins/kubernetes/src/kubernetes-auth-provider/GoogleKubernetesAuthProvider.ts @@ -28,9 +28,7 @@ export class GoogleKubernetesAuthProvider implements KubernetesAuthProvider { async decorateRequestBodyForAuth( requestBody: KubernetesRequestBody, ): Promise { - const googleAuthToken: string = await this.authProvider.getAccessToken( - 'https://www.googleapis.com/auth/cloud-platform', - ); + const googleAuthToken: string = (await this.getCredentials()).token; if ('auth' in requestBody) { requestBody.auth!.google = googleAuthToken; } else { @@ -38,4 +36,11 @@ export class GoogleKubernetesAuthProvider implements KubernetesAuthProvider { } return requestBody; } + async getCredentials(): Promise<{ token: string }> { + return { + token: await this.authProvider.getAccessToken( + 'https://www.googleapis.com/auth/cloud-platform', + ), + }; + } } diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts b/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts index eb3ea456ea..1873376487 100644 --- a/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts +++ b/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts @@ -91,4 +91,22 @@ export class KubernetesAuthProviders implements KubernetesAuthProvidersApi { `authProvider "${authProvider}" has no KubernetesAuthProvider defined for it`, ); } + + async getCredentials(authProvider: string): Promise<{ token: string }> { + const kubernetesAuthProvider: KubernetesAuthProvider | undefined = + this.kubernetesAuthProviderMap.get(authProvider); + + if (kubernetesAuthProvider) { + return await kubernetesAuthProvider.getCredentials(); + } + + if (authProvider.startsWith('oidc.')) { + throw new Error( + `KubernetesAuthProviders has no oidcProvider configured for ${authProvider}`, + ); + } + throw new Error( + `authProvider "${authProvider}" has no KubernetesAuthProvider defined for it`, + ); + } } diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/OidcKubernetesAuthProvider.ts b/plugins/kubernetes/src/kubernetes-auth-provider/OidcKubernetesAuthProvider.ts index f86df5e73e..bd5760bde2 100644 --- a/plugins/kubernetes/src/kubernetes-auth-provider/OidcKubernetesAuthProvider.ts +++ b/plugins/kubernetes/src/kubernetes-auth-provider/OidcKubernetesAuthProvider.ts @@ -30,7 +30,7 @@ export class OidcKubernetesAuthProvider implements KubernetesAuthProvider { async decorateRequestBodyForAuth( requestBody: KubernetesRequestBody, ): Promise { - const authToken: string = await this.authProvider.getIdToken(); + const authToken: string = (await this.getCredentials()).token; const auth = { ...requestBody.auth }; if (auth.oidc) { auth.oidc[this.providerName] = authToken; @@ -40,4 +40,10 @@ export class OidcKubernetesAuthProvider implements KubernetesAuthProvider { requestBody.auth = auth; return requestBody; } + + async getCredentials(): Promise<{ token: string }> { + return { + token: await this.authProvider.getIdToken(), + }; + } } diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/ServerSideAuthProvider.ts b/plugins/kubernetes/src/kubernetes-auth-provider/ServerSideAuthProvider.ts index 1c475b9568..8194f1e213 100644 --- a/plugins/kubernetes/src/kubernetes-auth-provider/ServerSideAuthProvider.ts +++ b/plugins/kubernetes/src/kubernetes-auth-provider/ServerSideAuthProvider.ts @@ -31,4 +31,8 @@ export class ServerSideKubernetesAuthProvider // No-op, auth will be taken care of on the server-side return requestBody; } + + async getCredentials(): Promise<{ token: string }> { + return { token: '' }; + } } diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/types.ts b/plugins/kubernetes/src/kubernetes-auth-provider/types.ts index ffa0fd5961..117c966d36 100644 --- a/plugins/kubernetes/src/kubernetes-auth-provider/types.ts +++ b/plugins/kubernetes/src/kubernetes-auth-provider/types.ts @@ -21,6 +21,7 @@ export interface KubernetesAuthProvider { decorateRequestBodyForAuth( requestBody: KubernetesRequestBody, ): Promise; + getCredentials(): Promise<{ token: string }>; } export const kubernetesAuthProvidersApiRef = @@ -33,4 +34,5 @@ export interface KubernetesAuthProvidersApi { authProvider: string, requestBody: KubernetesRequestBody, ): Promise; + getCredentials(authProvider: string): Promise<{ token: string }>; } diff --git a/plugins/kubernetes/src/plugin.ts b/plugins/kubernetes/src/plugin.ts index e2b13f43fe..cb85cf06fb 100644 --- a/plugins/kubernetes/src/plugin.ts +++ b/plugins/kubernetes/src/plugin.ts @@ -43,9 +43,14 @@ export const kubernetesPlugin = createPlugin({ deps: { discoveryApi: discoveryApiRef, identityApi: identityApiRef, + kubernetesAuthProvidersApi: kubernetesAuthProvidersApiRef, }, - factory: ({ discoveryApi, identityApi }) => - new KubernetesBackendClient({ discoveryApi, identityApi }), + factory: ({ discoveryApi, identityApi, kubernetesAuthProvidersApi }) => + new KubernetesBackendClient({ + discoveryApi, + identityApi, + kubernetesAuthProvidersApi, + }), }), createApiFactory({ api: kubernetesAuthProvidersApiRef, diff --git a/plugins/kubernetes/src/setupTests.ts b/plugins/kubernetes/src/setupTests.ts index a373310a1a..f7752030ed 100644 --- a/plugins/kubernetes/src/setupTests.ts +++ b/plugins/kubernetes/src/setupTests.ts @@ -16,6 +16,7 @@ import '@testing-library/jest-dom'; // eslint-disable-next-line no-restricted-imports import { TextDecoder, TextEncoder } from 'util'; +import 'cross-fetch/polyfill'; // These are missing from jest-node, so not available on global. Object.defineProperty(global, 'TextEncoder', { diff --git a/yarn.lock b/yarn.lock index 3652a87b61..085a76bb6a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7155,6 +7155,7 @@ __metadata: "@backstage/core-components": "workspace:^" "@backstage/core-plugin-api": "workspace:^" "@backstage/dev-utils": "workspace:^" + "@backstage/errors": "workspace:^" "@backstage/plugin-catalog-react": "workspace:^" "@backstage/plugin-kubernetes-common": "workspace:^" "@backstage/test-utils": "workspace:^"