From 2e622932a4f5f3027589fce740969ffa1bb00113 Mon Sep 17 00:00:00 2001 From: Ruben Vallejo Date: Wed, 8 Mar 2023 16:22:09 -0500 Subject: [PATCH 1/4] Add proxy method to KubernetesBackendClient Signed-off-by: Ruben Vallejo --- plugins/kubernetes/package.json | 1 + .../src/api/KubernetesBackendClient.ts | 69 +++++++++++++++++++ plugins/kubernetes/src/api/types.ts | 5 ++ .../GoogleKubernetesAuthProvider.ts | 5 ++ .../KubernetesAuthProviders.ts | 23 ++++++- .../OidcKubernetesAuthProvider.ts | 4 ++ .../ServerSideAuthProvider.ts | 4 ++ .../src/kubernetes-auth-provider/types.ts | 7 +- plugins/kubernetes/src/plugin.ts | 9 ++- yarn.lock | 1 + 10 files changed, 124 insertions(+), 4 deletions(-) diff --git a/plugins/kubernetes/package.json b/plugins/kubernetes/package.json index 7b3b3d7b25..f7d7430952 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.ts b/plugins/kubernetes/src/api/KubernetesBackendClient.ts index bffc966333..b54046fbcd 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 { @@ -110,4 +115,68 @@ export class KubernetesBackendClient implements KubernetesApi { return (await this.handleResponse(response)).items; } + + async proxy( + clusterName: string, + path: string, + init?: RequestInit, + ): Promise { + const { authProvider } = await this.getCluster(clusterName); + const token = await this.getBearerToken(authProvider); + + const url = `${await this.discoveryApi.getBaseUrl( + 'kubernetes', + )}/proxy${path}`; + const headers = { + Authorization: `Bearer ${token}`, + ...init?.headers, + [`X-Kubernetes-Cluster`]: clusterName, + }; + const response = await fetch(url, { ...init, headers }); + 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 getBearerToken(authProvider: string): Promise { + // const { auth } = + // await this.kubernetesAuthProvidersApi.decorateRequestBodyForAuth( + // authProvider, + // { + // entity: { + // apiVersion: 'v1', + // kind: 'Pods', + // metadata: { + // name: 'podName', + // annotations: { + // 'backstage.io/kubernetes-label-selector': 'k8s-app=kube-dns', + // }, + // }, + // }, + // }, + // ); + return await this.kubernetesAuthProvidersApi.getBearerToken(authProvider); + + // if (auth) { + // if (authProvider === 'google' && auth.google) { + // return auth.google; + // } else if (authProvider.startsWith('oidc.') && auth.oidc) { + // const oidcTokenProvider = authProvider.replace(/^oidc\./, ''); + // return auth.oidc[oidcTokenProvider]; + // } else { + // throw new Error(`invalid auth provider '${authProvider}'`); + // } + // } + // return ''; + } } diff --git a/plugins/kubernetes/src/api/types.ts b/plugins/kubernetes/src/api/types.ts index 0005a8d069..22e0644461 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( + 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..171ebc9960 100644 --- a/plugins/kubernetes/src/kubernetes-auth-provider/GoogleKubernetesAuthProvider.ts +++ b/plugins/kubernetes/src/kubernetes-auth-provider/GoogleKubernetesAuthProvider.ts @@ -38,4 +38,9 @@ export class GoogleKubernetesAuthProvider implements KubernetesAuthProvider { } return requestBody; } + async getBearerToken(): Promise { + return 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..65dc45e131 100644 --- a/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts +++ b/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts @@ -14,7 +14,10 @@ * limitations under the License. */ -import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common'; +import { + KubernetesRequestAuth, + KubernetesRequestBody, +} from '@backstage/plugin-kubernetes-common'; import { KubernetesAuthProvider, KubernetesAuthProvidersApi } from './types'; import { GoogleKubernetesAuthProvider } from './GoogleKubernetesAuthProvider'; import { ServerSideKubernetesAuthProvider } from './ServerSideAuthProvider'; @@ -91,4 +94,22 @@ export class KubernetesAuthProviders implements KubernetesAuthProvidersApi { `authProvider "${authProvider}" has no KubernetesAuthProvider defined for it`, ); } + + async getBearerToken(authProvider: string): Promise { + const kubernetesAuthProvider: KubernetesAuthProvider | undefined = + this.kubernetesAuthProviderMap.get(authProvider); + + if (kubernetesAuthProvider) { + return await kubernetesAuthProvider.getBearerToken(); + } + + 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..b992c71391 100644 --- a/plugins/kubernetes/src/kubernetes-auth-provider/OidcKubernetesAuthProvider.ts +++ b/plugins/kubernetes/src/kubernetes-auth-provider/OidcKubernetesAuthProvider.ts @@ -40,4 +40,8 @@ export class OidcKubernetesAuthProvider implements KubernetesAuthProvider { requestBody.auth = auth; return requestBody; } + + async getBearerToken(): Promise { + return 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..9290a2eda3 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 getBearerToken(): Promise { + return ''; + } } diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/types.ts b/plugins/kubernetes/src/kubernetes-auth-provider/types.ts index ffa0fd5961..de9f91c44e 100644 --- a/plugins/kubernetes/src/kubernetes-auth-provider/types.ts +++ b/plugins/kubernetes/src/kubernetes-auth-provider/types.ts @@ -14,13 +14,17 @@ * limitations under the License. */ -import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common'; +import { + KubernetesRequestAuth, + KubernetesRequestBody, +} from '@backstage/plugin-kubernetes-common'; import { createApiRef } from '@backstage/core-plugin-api'; export interface KubernetesAuthProvider { decorateRequestBodyForAuth( requestBody: KubernetesRequestBody, ): Promise; + getBearerToken(): Promise; } export const kubernetesAuthProvidersApiRef = @@ -33,4 +37,5 @@ export interface KubernetesAuthProvidersApi { authProvider: string, requestBody: KubernetesRequestBody, ): Promise; + getBearerToken(authProvider: string): Promise; } 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/yarn.lock b/yarn.lock index 75f4a41905..9a4399e5ec 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7153,6 +7153,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:^" From faac72e5def585d8fe7e96dedab228d483f4ed7c Mon Sep 17 00:00:00 2001 From: Ruben Vallejo Date: Thu, 16 Mar 2023 11:02:28 -0400 Subject: [PATCH 2/4] add proxy method with tests for kubernetesbackendclient Signed-off-by: Ruben Vallejo --- plugins/kubernetes/api-report.md | 21 ++ plugins/kubernetes/dev/index.tsx | 10 + .../src/api/KubernetesBackendClient.test.ts | 266 ++++++++++++++++++ .../src/api/KubernetesBackendClient.ts | 102 +++---- plugins/kubernetes/src/api/types.ts | 10 +- .../GoogleKubernetesAuthProvider.ts | 4 +- .../KubernetesAuthProviders.ts | 5 +- .../OidcKubernetesAuthProvider.ts | 2 +- .../src/kubernetes-auth-provider/types.ts | 5 +- plugins/kubernetes/src/setupTests.ts | 1 + 10 files changed, 348 insertions(+), 78 deletions(-) create mode 100644 plugins/kubernetes/src/api/KubernetesBackendClient.test.ts diff --git a/plugins/kubernetes/api-report.md b/plugins/kubernetes/api-report.md index 2cc2674a9e..7c150689b4 100644 --- a/plugins/kubernetes/api-report.md +++ b/plugins/kubernetes/api-report.md @@ -184,6 +184,8 @@ export class GoogleKubernetesAuthProvider implements KubernetesAuthProvider { decorateRequestBodyForAuth( requestBody: KubernetesRequestBody, ): Promise; + // (undocumented) + getBearerToken(): Promise; } // 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) @@ -259,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) @@ -281,6 +289,8 @@ export class KubernetesAuthProviders implements KubernetesAuthProvidersApi { authProvider: string, requestBody: KubernetesRequestBody, ): Promise; + // (undocumented) + getBearerToken(authProvider: string): Promise; } // 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) @@ -292,6 +302,8 @@ export interface KubernetesAuthProvidersApi { authProvider: string, requestBody: KubernetesRequestBody, ): Promise; + // (undocumented) + getBearerToken(authProvider: string): Promise; } // 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) @@ -306,6 +318,7 @@ export class KubernetesBackendClient implements KubernetesApi { constructor(options: { discoveryApi: DiscoveryApi; identityApi: IdentityApi; + kubernetesAuthProvidersApi: KubernetesAuthProvidersApi; }); // (undocumented) getClusters(): Promise< @@ -326,6 +339,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 @@ -416,6 +435,8 @@ export class ServerSideKubernetesAuthProvider decorateRequestBodyForAuth( requestBody: KubernetesRequestBody, ): Promise; + // (undocumented) + getBearerToken(): Promise; } // 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/src/api/KubernetesBackendClient.test.ts b/plugins/kubernetes/src/api/KubernetesBackendClient.test.ts new file mode 100644 index 0000000000..ec18dd50bb --- /dev/null +++ b/plugins/kubernetes/src/api/KubernetesBackendClient.test.ts @@ -0,0 +1,266 @@ +/* + * 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(), + getBearerToken: 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('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('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('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('hits the /proxy API', async () => { + identityApi.getCredentials.mockResolvedValue({ token: 'idToken' }); + kubernetesAuthProvidersApi.getBearerToken.mockResolvedValue( + 'Bearer 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', + (_, res, ctx) => res(ctx.json(nsResponse)), + ), + rest.get('http://localhost:1234/api/kubernetes/clusters', (_, res, ctx) => + res(ctx.json({ items: [{ name: 'cluster-a', authProvider: 'aws' }] })), + ), + ); + + const request = { + clusterName: 'cluster-a', + path: '/api/v1/namespaces', + }; + + const response = await backendClient.proxy(request); + + expect(response).toStrictEqual(nsResponse); + }); + + it('/proxy API throws a ERROR_NOT_FOUND if the cluster in the request is not found', async () => { + identityApi.getCredentials.mockResolvedValue({ token: 'idToken' }); + worker.use( + rest.get('http://localhost:1234/api/kubernetes/clusters', (_, res, ctx) => + res(ctx.json({ items: [{ name: 'cluster-b', authProvider: 'aws' }] })), + ), + ); + + const request = { + clusterName: 'cluster-a', + path: '/api/v1/namespaces', + }; + + await expect(backendClient.proxy(request)).rejects.toThrow(NotFoundError); + }); + + 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.getBearerToken.mockResolvedValue( + 'Bearer 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', + (_, res, ctx) => res(ctx.status(200), ctx.json(nsResponse)), + ), + rest.get('http://localhost:1234/api/kubernetes/clusters', (_, res, ctx) => + res(ctx.json({ items: [{ name: 'cluster-a', authProvider: 'aws' }] })), + ), + ); + + const request = { + clusterName: 'cluster-a', + path: '/api/v1/namespaces', + }; + + const response = await backendClient.proxy(request); + expect(response).toStrictEqual(nsResponse); + }); +}); diff --git a/plugins/kubernetes/src/api/KubernetesBackendClient.ts b/plugins/kubernetes/src/api/KubernetesBackendClient.ts index b54046fbcd..dbeb1c3035 100644 --- a/plugins/kubernetes/src/api/KubernetesBackendClient.ts +++ b/plugins/kubernetes/src/api/KubernetesBackendClient.ts @@ -74,6 +74,23 @@ 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 getBearerToken(authProvider: string): Promise { + return await this.kubernetesAuthProvidersApi.getBearerToken(authProvider); + } + async getObjectsByEntity( requestBody: KubernetesRequestBody, ): Promise { @@ -105,7 +122,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: { @@ -116,67 +132,31 @@ export class KubernetesBackendClient implements KubernetesApi { return (await this.handleResponse(response)).items; } - async proxy( - clusterName: string, - path: string, - init?: RequestInit, - ): Promise { - const { authProvider } = await this.getCluster(clusterName); - const token = await this.getBearerToken(authProvider); + async proxy(options: { + clusterName: string; + path: string; + init?: RequestInit; + }): Promise { + const { authProvider } = await this.getCluster(options.clusterName); + const k8sToken = await this.getBearerToken(authProvider); + const url = `${await this.discoveryApi.getBaseUrl('kubernetes')}/proxy${ + options.path + }`; + const identityResponse = await this.identityApi.getCredentials(); + const headers = identityResponse.token + ? { + ...options.init?.headers, + [`Backstage-Kubernetes-Cluster`]: options.clusterName, + [`Backstage-Kubernetes-Authorization`]: `Bearer ${k8sToken}`, + Authorization: `Bearer ${identityResponse.token}`, + } + : { + ...options.init?.headers, + [`Backstage-Kubernetes-Cluster`]: options.clusterName, + [`Backstage-Kubernetes-Authorization`]: `Bearer ${k8sToken}`, + }; + const response = await fetch(url, { ...options.init, headers }); - const url = `${await this.discoveryApi.getBaseUrl( - 'kubernetes', - )}/proxy${path}`; - const headers = { - Authorization: `Bearer ${token}`, - ...init?.headers, - [`X-Kubernetes-Cluster`]: clusterName, - }; - const response = await fetch(url, { ...init, headers }); 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 getBearerToken(authProvider: string): Promise { - // const { auth } = - // await this.kubernetesAuthProvidersApi.decorateRequestBodyForAuth( - // authProvider, - // { - // entity: { - // apiVersion: 'v1', - // kind: 'Pods', - // metadata: { - // name: 'podName', - // annotations: { - // 'backstage.io/kubernetes-label-selector': 'k8s-app=kube-dns', - // }, - // }, - // }, - // }, - // ); - return await this.kubernetesAuthProvidersApi.getBearerToken(authProvider); - - // if (auth) { - // if (authProvider === 'google' && auth.google) { - // return auth.google; - // } else if (authProvider.startsWith('oidc.') && auth.oidc) { - // const oidcTokenProvider = authProvider.replace(/^oidc\./, ''); - // return auth.oidc[oidcTokenProvider]; - // } else { - // throw new Error(`invalid auth provider '${authProvider}'`); - // } - // } - // return ''; - } } diff --git a/plugins/kubernetes/src/api/types.ts b/plugins/kubernetes/src/api/types.ts index 22e0644461..02f2fd3521 100644 --- a/plugins/kubernetes/src/api/types.ts +++ b/plugins/kubernetes/src/api/types.ts @@ -43,9 +43,9 @@ export interface KubernetesApi { getCustomObjectsByEntity( request: CustomObjectsByEntityRequest, ): Promise; - proxy( - clusterName: string, - path: string, - init?: RequestInit, - ): 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 171ebc9960..4b46884b39 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.getBearerToken(); if ('auth' in requestBody) { requestBody.auth!.google = googleAuthToken; } else { diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts b/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts index 65dc45e131..45ba243d23 100644 --- a/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts +++ b/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts @@ -14,10 +14,7 @@ * limitations under the License. */ -import { - KubernetesRequestAuth, - KubernetesRequestBody, -} from '@backstage/plugin-kubernetes-common'; +import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common'; import { KubernetesAuthProvider, KubernetesAuthProvidersApi } from './types'; import { GoogleKubernetesAuthProvider } from './GoogleKubernetesAuthProvider'; import { ServerSideKubernetesAuthProvider } from './ServerSideAuthProvider'; diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/OidcKubernetesAuthProvider.ts b/plugins/kubernetes/src/kubernetes-auth-provider/OidcKubernetesAuthProvider.ts index b992c71391..9d9d34fe1d 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.getBearerToken(); const auth = { ...requestBody.auth }; if (auth.oidc) { auth.oidc[this.providerName] = authToken; diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/types.ts b/plugins/kubernetes/src/kubernetes-auth-provider/types.ts index de9f91c44e..395db3c1bd 100644 --- a/plugins/kubernetes/src/kubernetes-auth-provider/types.ts +++ b/plugins/kubernetes/src/kubernetes-auth-provider/types.ts @@ -14,10 +14,7 @@ * limitations under the License. */ -import { - KubernetesRequestAuth, - KubernetesRequestBody, -} from '@backstage/plugin-kubernetes-common'; +import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common'; import { createApiRef } from '@backstage/core-plugin-api'; export interface KubernetesAuthProvider { 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', { From c159ab64a609fed99d56eb4b52dcb871e9dc6892 Mon Sep 17 00:00:00 2001 From: Ruben Vallejo Date: Mon, 27 Mar 2023 11:30:13 -0400 Subject: [PATCH 3/4] add changeset and docs Signed-off-by: Ruben Vallejo --- .changeset/sweet-eels-hunt.md | 5 +++++ docs/features/kubernetes/proxy.md | 36 +++++-------------------------- 2 files changed, 10 insertions(+), 31 deletions(-) create mode 100644 .changeset/sweet-eels-hunt.md 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 From 6bea150df58a9262724e9d743ac2fe3250652752 Mon Sep 17 00:00:00 2001 From: Ruben Vallejo Date: Wed, 29 Mar 2023 17:08:41 -0400 Subject: [PATCH 4/4] fix:addressing PR comments Signed-off-by: Ruben Vallejo --- plugins/kubernetes/api-report.md | 16 +- .../src/api/KubernetesBackendClient.test.ts | 407 +++++++++++++++--- .../src/api/KubernetesBackendClient.ts | 31 +- .../GoogleKubernetesAuthProvider.ts | 12 +- .../KubernetesAuthProviders.ts | 4 +- .../OidcKubernetesAuthProvider.ts | 8 +- .../ServerSideAuthProvider.ts | 4 +- .../src/kubernetes-auth-provider/types.ts | 4 +- 8 files changed, 384 insertions(+), 102 deletions(-) diff --git a/plugins/kubernetes/api-report.md b/plugins/kubernetes/api-report.md index 7c150689b4..a5f72861da 100644 --- a/plugins/kubernetes/api-report.md +++ b/plugins/kubernetes/api-report.md @@ -185,7 +185,9 @@ export class GoogleKubernetesAuthProvider implements KubernetesAuthProvider { requestBody: KubernetesRequestBody, ): Promise; // (undocumented) - getBearerToken(): Promise; + 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) @@ -290,7 +292,9 @@ export class KubernetesAuthProviders implements KubernetesAuthProvidersApi { requestBody: KubernetesRequestBody, ): Promise; // (undocumented) - getBearerToken(authProvider: string): Promise; + 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) @@ -303,7 +307,9 @@ export interface KubernetesAuthProvidersApi { requestBody: KubernetesRequestBody, ): Promise; // (undocumented) - getBearerToken(authProvider: string): Promise; + 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) @@ -436,7 +442,9 @@ export class ServerSideKubernetesAuthProvider requestBody: KubernetesRequestBody, ): Promise; // (undocumented) - getBearerToken(): Promise; + 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/src/api/KubernetesBackendClient.test.ts b/plugins/kubernetes/src/api/KubernetesBackendClient.test.ts index ec18dd50bb..2c01f15b18 100644 --- a/plugins/kubernetes/src/api/KubernetesBackendClient.test.ts +++ b/plugins/kubernetes/src/api/KubernetesBackendClient.test.ts @@ -32,7 +32,7 @@ describe('KubernetesBackendClient', () => { let backendClient: KubernetesBackendClient; const kubernetesAuthProvidersApi: jest.Mocked = { decorateRequestBodyForAuth: jest.fn(), - getBearerToken: jest.fn(), + getCredentials: jest.fn(), }; let mockResponse: ObjectsByEntityResponse; const worker = setupServer(); @@ -100,6 +100,32 @@ describe('KubernetesBackendClient', () => { ]); }); + 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( @@ -133,7 +159,75 @@ describe('KubernetesBackendClient', () => { expect(customObject).toStrictEqual(mockResponse); }); - it('hits the /services/{entityName} api', async () => { + 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( @@ -158,6 +252,58 @@ describe('KubernetesBackendClient', () => { 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( @@ -184,83 +330,210 @@ describe('KubernetesBackendClient', () => { expect(response).toStrictEqual(mockResponse); }); - it('hits the /proxy API', async () => { - identityApi.getCredentials.mockResolvedValue({ token: 'idToken' }); - kubernetesAuthProvidersApi.getBearerToken.mockResolvedValue( - 'Bearer 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', - (_, res, ctx) => res(ctx.json(nsResponse)), - ), - rest.get('http://localhost:1234/api/kubernetes/clusters', (_, res, ctx) => - res(ctx.json({ items: [{ name: 'cluster-a', authProvider: 'aws' }] })), - ), - ); - - const request = { - clusterName: 'cluster-a', - path: '/api/v1/namespaces', - }; - - const response = await backendClient.proxy(request); - - expect(response).toStrictEqual(nsResponse); - }); - - it('/proxy API throws a ERROR_NOT_FOUND if the cluster in the request is not found', async () => { + it('/resources/workloads/query 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.json({ items: [{ name: 'cluster-b', authProvider: 'aws' }] })), + rest.post( + 'http://localhost:1234/api/kubernetes/resources/workloads/query', + (_, res, ctx) => res(ctx.status(404)), ), ); - const request = { - clusterName: 'cluster-a', - path: '/api/v1/namespaces', - }; - - await expect(backendClient.proxy(request)).rejects.toThrow(NotFoundError); - }); - - 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.getBearerToken.mockResolvedValue( - 'Bearer k8-token', - ); - const nsResponse = { - kind: 'Namespace', - apiVersion: 'v1', - metadata: { - name: 'new-ns', + 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.get( - 'http://localhost:1234/api/kubernetes/proxy/api/v1/namespaces', - (_, res, ctx) => res(ctx.status(200), ctx.json(nsResponse)), - ), - rest.get('http://localhost:1234/api/kubernetes/clusters', (_, res, ctx) => - res(ctx.json({ items: [{ name: 'cluster-a', authProvider: 'aws' }] })), + rest.post( + 'http://localhost:1234/api/kubernetes/resources/workloads/query', + (_, res, ctx) => res(ctx.status(500)), ), ); - const request = { - clusterName: 'cluster-a', - path: '/api/v1/namespaces', + const request: WorkloadsByEntityRequest = { + auth: {}, + entity: { + apiVersion: 'v1', + kind: 'pod', + metadata: { + name: 'test-name', + }, + }, }; - const response = await backendClient.proxy(request); - expect(response).toStrictEqual(nsResponse); + 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 dbeb1c3035..bc510589c8 100644 --- a/plugins/kubernetes/src/api/KubernetesBackendClient.ts +++ b/plugins/kubernetes/src/api/KubernetesBackendClient.ts @@ -87,8 +87,10 @@ export class KubernetesBackendClient implements KubernetesApi { return cluster; } - private async getBearerToken(authProvider: string): Promise { - return await this.kubernetesAuthProvidersApi.getBearerToken(authProvider); + private async getCredentials( + authProvider: string, + ): Promise<{ token: string }> { + return await this.kubernetesAuthProvidersApi.getCredentials(authProvider); } async getObjectsByEntity( @@ -138,25 +140,20 @@ export class KubernetesBackendClient implements KubernetesApi { init?: RequestInit; }): Promise { const { authProvider } = await this.getCluster(options.clusterName); - const k8sToken = await this.getBearerToken(authProvider); + 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 = identityResponse.token - ? { - ...options.init?.headers, - [`Backstage-Kubernetes-Cluster`]: options.clusterName, - [`Backstage-Kubernetes-Authorization`]: `Bearer ${k8sToken}`, - Authorization: `Bearer ${identityResponse.token}`, - } - : { - ...options.init?.headers, - [`Backstage-Kubernetes-Cluster`]: options.clusterName, - [`Backstage-Kubernetes-Authorization`]: `Bearer ${k8sToken}`, - }; - const response = await fetch(url, { ...options.init, headers }); + const headers = { + ...options.init?.headers, + [`Backstage-Kubernetes-Cluster`]: options.clusterName, + [`Backstage-Kubernetes-Authorization`]: `Bearer ${k8sToken}`, + ...(identityResponse.token && { + Authorization: `Bearer ${identityResponse.token}`, + }), + }; - return this.handleResponse(response); + return await fetch(url, { ...options.init, headers }); } } diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/GoogleKubernetesAuthProvider.ts b/plugins/kubernetes/src/kubernetes-auth-provider/GoogleKubernetesAuthProvider.ts index 4b46884b39..5652549b8a 100644 --- a/plugins/kubernetes/src/kubernetes-auth-provider/GoogleKubernetesAuthProvider.ts +++ b/plugins/kubernetes/src/kubernetes-auth-provider/GoogleKubernetesAuthProvider.ts @@ -28,7 +28,7 @@ export class GoogleKubernetesAuthProvider implements KubernetesAuthProvider { async decorateRequestBodyForAuth( requestBody: KubernetesRequestBody, ): Promise { - const googleAuthToken: string = await this.getBearerToken(); + const googleAuthToken: string = (await this.getCredentials()).token; if ('auth' in requestBody) { requestBody.auth!.google = googleAuthToken; } else { @@ -36,9 +36,11 @@ export class GoogleKubernetesAuthProvider implements KubernetesAuthProvider { } return requestBody; } - async getBearerToken(): Promise { - return await this.authProvider.getAccessToken( - 'https://www.googleapis.com/auth/cloud-platform', - ); + 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 45ba243d23..1873376487 100644 --- a/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts +++ b/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts @@ -92,12 +92,12 @@ export class KubernetesAuthProviders implements KubernetesAuthProvidersApi { ); } - async getBearerToken(authProvider: string): Promise { + async getCredentials(authProvider: string): Promise<{ token: string }> { const kubernetesAuthProvider: KubernetesAuthProvider | undefined = this.kubernetesAuthProviderMap.get(authProvider); if (kubernetesAuthProvider) { - return await kubernetesAuthProvider.getBearerToken(); + return await kubernetesAuthProvider.getCredentials(); } if (authProvider.startsWith('oidc.')) { diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/OidcKubernetesAuthProvider.ts b/plugins/kubernetes/src/kubernetes-auth-provider/OidcKubernetesAuthProvider.ts index 9d9d34fe1d..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.getBearerToken(); + const authToken: string = (await this.getCredentials()).token; const auth = { ...requestBody.auth }; if (auth.oidc) { auth.oidc[this.providerName] = authToken; @@ -41,7 +41,9 @@ export class OidcKubernetesAuthProvider implements KubernetesAuthProvider { return requestBody; } - async getBearerToken(): Promise { - return await this.authProvider.getIdToken(); + 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 9290a2eda3..8194f1e213 100644 --- a/plugins/kubernetes/src/kubernetes-auth-provider/ServerSideAuthProvider.ts +++ b/plugins/kubernetes/src/kubernetes-auth-provider/ServerSideAuthProvider.ts @@ -32,7 +32,7 @@ export class ServerSideKubernetesAuthProvider return requestBody; } - async getBearerToken(): Promise { - return ''; + 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 395db3c1bd..117c966d36 100644 --- a/plugins/kubernetes/src/kubernetes-auth-provider/types.ts +++ b/plugins/kubernetes/src/kubernetes-auth-provider/types.ts @@ -21,7 +21,7 @@ export interface KubernetesAuthProvider { decorateRequestBodyForAuth( requestBody: KubernetesRequestBody, ): Promise; - getBearerToken(): Promise; + getCredentials(): Promise<{ token: string }>; } export const kubernetesAuthProvidersApiRef = @@ -34,5 +34,5 @@ export interface KubernetesAuthProvidersApi { authProvider: string, requestBody: KubernetesRequestBody, ): Promise; - getBearerToken(authProvider: string): Promise; + getCredentials(authProvider: string): Promise<{ token: string }>; }