diff --git a/plugins/kubernetes-backend/api-report.md b/plugins/kubernetes-backend/api-report.md index 5aa124394a..603f15adeb 100644 --- a/plugins/kubernetes-backend/api-report.md +++ b/plugins/kubernetes-backend/api-report.md @@ -166,6 +166,8 @@ export const HEADER_KUBERNETES_CLUSTER: string; export class KubernetesBuilder { constructor(env: KubernetesEnvironment); // (undocumented) + addAuthStrategy(key: string, strategy: AuthenticationStrategy): this; + // (undocumented) build(): KubernetesBuilderReturn; // (undocumented) protected buildAuthStrategyMap(): { diff --git a/plugins/kubernetes-backend/src/auth/AksStrategy.ts b/plugins/kubernetes-backend/src/auth/AksStrategy.ts index db4d423060..999dd9fa11 100644 --- a/plugins/kubernetes-backend/src/auth/AksStrategy.ts +++ b/plugins/kubernetes-backend/src/auth/AksStrategy.ts @@ -27,7 +27,9 @@ export class AksStrategy implements AuthenticationStrategy { requestAuth: KubernetesRequestAuth, ): Promise { const token = requestAuth.aks; - return token ? { type: 'bearer token', token } : { type: 'anonymous' }; + return token + ? { type: 'bearer token', token: token as string } + : { type: 'anonymous' }; } public validate(_: AuthMetadata) {} } diff --git a/plugins/kubernetes-backend/src/auth/GoogleStrategy.ts b/plugins/kubernetes-backend/src/auth/GoogleStrategy.ts index ff8d02cb14..850cd24ba8 100644 --- a/plugins/kubernetes-backend/src/auth/GoogleStrategy.ts +++ b/plugins/kubernetes-backend/src/auth/GoogleStrategy.ts @@ -33,7 +33,7 @@ export class GoogleStrategy implements AuthenticationStrategy { 'Google token not found under auth.google in request body', ); } - return { type: 'bearer token', token }; + return { type: 'bearer token', token: token as string }; } public validate(_: AuthMetadata) {} } diff --git a/plugins/kubernetes-backend/src/auth/OidcStrategy.ts b/plugins/kubernetes-backend/src/auth/OidcStrategy.ts index f5ab1cbd8d..2d9a4808e5 100644 --- a/plugins/kubernetes-backend/src/auth/OidcStrategy.ts +++ b/plugins/kubernetes-backend/src/auth/OidcStrategy.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { JsonObject } from '@backstage/types'; import { ANNOTATION_KUBERNETES_OIDC_TOKEN_PROVIDER, KubernetesRequestAuth, @@ -38,14 +39,14 @@ export class OidcStrategy implements AuthenticationStrategy { ); } - const token = authConfig.oidc?.[oidcTokenProvider]; + const token = (authConfig.oidc as JsonObject | null)?.[oidcTokenProvider]; if (!token) { throw new Error( `Auth token not found under oidc.${oidcTokenProvider} in request body`, ); } - return { type: 'bearer token', token }; + return { type: 'bearer token', token: token as string }; } public validate(authMetadata: AuthMetadata) { diff --git a/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts b/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts index dbcb0659b7..e7e1e07549 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts @@ -21,6 +21,7 @@ import { ANNOTATION_KUBERNETES_AUTH_PROVIDER, ANNOTATION_KUBERNETES_OIDC_TOKEN_PROVIDER, ObjectsByEntityResponse, + KubernetesRequestAuth, } from '@backstage/plugin-kubernetes-common'; import express from 'express'; import request from 'supertest'; @@ -55,7 +56,8 @@ describe('KubernetesBuilder', () => { let catalogApi: CatalogApi; let permissions: jest.Mocked; - beforeAll(async () => { + beforeEach(async () => { + jest.resetAllMocks(); const logger = getVoidLogger(); config = new ConfigReader({ kubernetes: { @@ -109,10 +111,6 @@ describe('KubernetesBuilder', () => { app = express().use(router); }); - beforeEach(() => { - jest.resetAllMocks(); - }); - describe('get /clusters', () => { it('happy path: lists clusters', async () => { const response = await request(app).get('/clusters'); @@ -298,8 +296,77 @@ describe('KubernetesBuilder', () => { expect(response.body).toEqual(result); expect(response.status).toEqual(200); }); + + it('reads auth data for custom strategy', async () => { + permissions.authorize.mockResolvedValue([ + { result: AuthorizeResult.ALLOW }, + ]); + const mockFetcher = { + fetchPodMetricsByNamespaces: jest + .fn() + .mockResolvedValue({ errors: [], responses: [] }), + fetchObjectsForService: jest.fn().mockResolvedValue({ + errors: [], + responses: [ + { type: 'pods', resources: [{ metadata: { name: 'pod1' } }] }, + ], + }), + }; + const { router } = await KubernetesBuilder.createBuilder({ + logger: getVoidLogger(), + config, + catalogApi, + permissions, + }) + .addAuthStrategy('custom', { + getCredential: jest + .fn< + Promise, + [ClusterDetails, KubernetesRequestAuth] + >() + .mockImplementation(async (_, requestAuth) => ({ + type: 'bearer token', + token: requestAuth.custom as string, + })), + validate: jest.fn(), + }) + .setClusterSupplier({ + getClusters: jest + .fn, []>() + .mockResolvedValue([ + { + name: 'custom-cluster', + url: 'http://my.cluster.url', + authMetadata: { + [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'custom', + }, + }, + ]), + }) + .setFetcher(mockFetcher) + .build(); + app = express().use(router); + + await request(app) + .post('/services/test-service') + .send({ + entity: { + metadata: { + name: 'thing', + }, + }, + auth: { custom: 'custom-token' }, + }); + + expect(mockFetcher.fetchObjectsForService).toHaveBeenCalledWith( + expect.objectContaining({ + credential: { type: 'bearer token', token: 'custom-token' }, + }), + ); + }); }); - describe('post /proxy', () => { + + describe('/proxy', () => { const worker = setupServer(); setupRequestMockHandlers(worker); @@ -400,6 +467,59 @@ metadata: expect(response.status).toEqual(403); }); + + it('permits custom client-side auth strategy', async () => { + worker.use( + rest.get('http://my.cluster.url/api/v1/namespaces', (req, res, ctx) => { + if (req.headers.get('Authorization') !== 'custom-token') { + return res(ctx.status(401)); + } + return res(ctx.json({ items: [] })); + }), + ); + permissions.authorize.mockResolvedValue([ + { result: AuthorizeResult.ALLOW }, + ]); + const { router } = await KubernetesBuilder.createBuilder({ + logger: getVoidLogger(), + config, + catalogApi, + permissions, + }) + .addAuthStrategy('custom', { + getCredential: jest + .fn< + Promise, + [ClusterDetails, KubernetesRequestAuth] + >() + .mockResolvedValue({ type: 'anonymous' }), + validate: jest.fn(), + }) + .setClusterSupplier({ + getClusters: jest + .fn, []>() + .mockResolvedValue([ + { + name: 'custom-cluster', + url: 'http://my.cluster.url', + authMetadata: { + [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'custom', + }, + }, + ]), + }) + .build(); + app = express().use(router); + + const proxyEndpointRequest = request(app) + .get('/proxy/api/v1/namespaces') + .set(HEADER_KUBERNETES_CLUSTER, 'custom-cluster') + .set(HEADER_KUBERNETES_AUTH, 'custom-token'); + worker.use(rest.all(proxyEndpointRequest.url, req => req.passthrough())); + const response = await proxyEndpointRequest; + + expect(response.body).toStrictEqual({ items: [] }); + }); }); describe('get /.well-known/backstage/permissions/metadata', () => { it('lists permissions supported by the kubernetes plugin', async () => { diff --git a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts index f3e71e8dbc..f3766d8513 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts @@ -204,6 +204,11 @@ export class KubernetesBuilder { this.authStrategyMap = authStrategyMap; } + public addAuthStrategy(key: string, strategy: AuthenticationStrategy) { + this.getAuthStrategyMap()[key] = strategy; + return this; + } + protected buildCustomResources() { const customResources: CustomResource[] = ( this.env.config.getOptionalConfigArray('kubernetes.customResources') ?? [] diff --git a/plugins/kubernetes-common/api-report.md b/plugins/kubernetes-common/api-report.md index aae72d6f1e..051293f37f 100644 --- a/plugins/kubernetes-common/api-report.md +++ b/plugins/kubernetes-common/api-report.md @@ -233,16 +233,7 @@ export const kubernetesPermissions: BasicPermission[]; export const kubernetesProxyPermission: BasicPermission; // @public (undocumented) -export interface KubernetesRequestAuth { - // (undocumented) - aks?: string; - // (undocumented) - google?: string; - // (undocumented) - oidc?: { - [key: string]: string; - }; -} +export type KubernetesRequestAuth = JsonObject; // @public (undocumented) export interface KubernetesRequestBody { diff --git a/plugins/kubernetes-common/src/types.ts b/plugins/kubernetes-common/src/types.ts index 2b2ab05b51..feb213d520 100644 --- a/plugins/kubernetes-common/src/types.ts +++ b/plugins/kubernetes-common/src/types.ts @@ -33,13 +33,7 @@ import { import { Entity } from '@backstage/catalog-model'; /** @public */ -export interface KubernetesRequestAuth { - google?: string; - aks?: string; - oidc?: { - [key: string]: string; - }; -} +export type KubernetesRequestAuth = JsonObject; /** @public */ export interface CustomResourceMatcher { diff --git a/plugins/kubernetes/package.json b/plugins/kubernetes/package.json index 7a0c2bf929..4bd55d65a3 100644 --- a/plugins/kubernetes/package.json +++ b/plugins/kubernetes/package.json @@ -42,6 +42,7 @@ "@backstage/plugin-catalog-react": "workspace:^", "@backstage/plugin-kubernetes-common": "workspace:^", "@backstage/theme": "workspace:^", + "@backstage/types": "workspace:^", "@kubernetes-models/apimachinery": "^1.1.0", "@kubernetes-models/base": "^4.0.1", "@kubernetes/client-node": "0.18.1", diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.test.ts b/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.test.ts index 14e574022a..5d03dcdae3 100644 --- a/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.test.ts +++ b/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.test.ts @@ -74,7 +74,7 @@ describe('KubernetesAuthProviders tests', () => { requestBody, ); - expect(details.auth?.oidc?.okta).toBe('oktaToken'); + expect(details).toMatchObject({ auth: { oidc: { okta: 'oktaToken' } } }); }); it('returns error for unknown authProvider', async () => { diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/OidcKubernetesAuthProvider.ts b/plugins/kubernetes/src/kubernetes-auth-provider/OidcKubernetesAuthProvider.ts index bd5760bde2..b2e6c5cbc2 100644 --- a/plugins/kubernetes/src/kubernetes-auth-provider/OidcKubernetesAuthProvider.ts +++ b/plugins/kubernetes/src/kubernetes-auth-provider/OidcKubernetesAuthProvider.ts @@ -14,9 +14,10 @@ * limitations under the License. */ -import { KubernetesAuthProvider } from './types'; +import { JsonObject } from '@backstage/types'; import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common'; import { OpenIdConnectApi } from '@backstage/core-plugin-api'; +import { KubernetesAuthProvider } from './types'; export class OidcKubernetesAuthProvider implements KubernetesAuthProvider { providerName: string; @@ -31,9 +32,9 @@ export class OidcKubernetesAuthProvider implements KubernetesAuthProvider { requestBody: KubernetesRequestBody, ): Promise { const authToken: string = (await this.getCredentials()).token; - const auth = { ...requestBody.auth }; + const auth = { ...(requestBody.auth as JsonObject) }; if (auth.oidc) { - auth.oidc[this.providerName] = authToken; + (auth.oidc as JsonObject)[this.providerName] = authToken; } else { auth.oidc = { [this.providerName]: authToken }; } diff --git a/yarn.lock b/yarn.lock index 78d55e945d..982538afb4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7693,6 +7693,7 @@ __metadata: "@backstage/plugin-kubernetes-common": "workspace:^" "@backstage/test-utils": "workspace:^" "@backstage/theme": "workspace:^" + "@backstage/types": "workspace:^" "@kubernetes-models/apimachinery": ^1.1.0 "@kubernetes-models/base": ^4.0.1 "@kubernetes/client-node": 0.18.1