From 1fef8423cef28bcab6179196d8e6744af39ee802 Mon Sep 17 00:00:00 2001 From: Ruben Vallejo Date: Tue, 7 Feb 2023 12:44:08 -0500 Subject: [PATCH 1/6] Adding permission framework integration to kubernetes proxy endpoint Signed-off-by: Ruben Vallejo --- packages/backend/src/plugins/kubernetes.ts | 1 + .../src/service/KubernetesBuilder.test.ts | 91 +++++++++++++++---- .../src/service/KubernetesBuilder.ts | 7 +- .../src/service/KubernetesProxy.ts | 49 +++++++++- .../kubernetes-backend/src/service/router.ts | 2 + .../src/service/standaloneApplication.ts | 8 +- plugins/kubernetes-common/package.json | 1 + plugins/kubernetes-common/src/index.ts | 5 + plugins/kubernetes-common/src/permissions.ts | 39 ++++++++ yarn.lock | 28 ++++++ 10 files changed, 211 insertions(+), 20 deletions(-) create mode 100644 plugins/kubernetes-common/src/permissions.ts diff --git a/packages/backend/src/plugins/kubernetes.ts b/packages/backend/src/plugins/kubernetes.ts index 3bc7a5dcbe..3bc6648862 100644 --- a/packages/backend/src/plugins/kubernetes.ts +++ b/packages/backend/src/plugins/kubernetes.ts @@ -27,6 +27,7 @@ export default async function createPlugin( logger: env.logger, config: env.config, catalogApi, + permissions: env.permissions, }).build(); return router; } diff --git a/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts b/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts index cc2dea26c0..695649b9f3 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts @@ -31,16 +31,24 @@ import { import { KubernetesBuilder } from './KubernetesBuilder'; import { KubernetesFanOutHandler } from './KubernetesFanOutHandler'; import { CatalogApi } from '@backstage/catalog-client'; -import { HEADER_KUBERNETES_CLUSTER } from './KubernetesProxy'; +import { + HEADER_KUBERNETES_CLUSTER, + HEADER_KUBERNETES_AUTH, +} from './KubernetesProxy'; import { setupServer } from 'msw/node'; import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; import { rest } from 'msw'; +import { + AuthorizeResult, + PermissionEvaluator, +} from '@backstage/plugin-permission-common'; describe('KubernetesBuilder', () => { let app: express.Express; let kubernetesFanOutHandler: jest.Mocked; let config: Config; let catalogApi: CatalogApi; + let permissions: jest.Mocked; beforeAll(async () => { const logger = getVoidLogger(); @@ -74,10 +82,16 @@ describe('KubernetesBuilder', () => { getKubernetesObjectsByEntity: jest.fn(), } as any; + permissions = { + authorize: jest.fn(), + authorizeConditional: jest.fn(), + }; + const { router } = await KubernetesBuilder.createBuilder({ config, logger, catalogApi, + permissions, }) .setObjectsProvider(kubernetesFanOutHandler) .setClusterSupplier(clusterSupplier) @@ -250,6 +264,7 @@ describe('KubernetesBuilder', () => { logger, config, catalogApi, + permissions, }) .setClusterSupplier(clusterSupplier) .setServiceLocator(serviceLocator) @@ -278,20 +293,26 @@ describe('KubernetesBuilder', () => { beforeEach(() => { worker.use( - rest.post('https://localhost:1234/api/v1/namespaces', (req, res, ctx) => - req - .arrayBuffer() - .then(body => - res( - ctx.set('content-type', `${req.headers.get('content-type')}`), - ctx.body(body), - ), - ), + rest.post( + 'https://localhost:1234/api/v1/namespaces', + (req, res, ctx) => { + if (!req.headers.get('Authorization')) { + return res(ctx.status(401)); + } + return req + .arrayBuffer() + .then(body => + res( + ctx.set('content-type', `${req.headers.get('content-type')}`), + ctx.body(body), + ), + ); + }, ), ); }); - it('returns the given request body', async () => { + it('returns the given request body with permission set to allow', async () => { const requestBody = { kind: 'Namespace', apiVersion: 'v1', @@ -300,9 +321,14 @@ describe('KubernetesBuilder', () => { }, }; + permissions.authorize.mockReturnValue( + Promise.resolve([{ result: AuthorizeResult.ALLOW }]), + ); + const proxyEndpointRequest = request(app) .post('/proxy/api/v1/namespaces') .set(HEADER_KUBERNETES_CLUSTER, 'some-cluster') + .set(HEADER_KUBERNETES_AUTH, 'randomtoken') .send(requestBody); worker.use(rest.all(proxyEndpointRequest.url, req => req.passthrough())); @@ -312,17 +338,22 @@ describe('KubernetesBuilder', () => { expect(response.body).toStrictEqual(requestBody); }); - it('supports yaml content type', async () => { + it('supports yaml content type with permission set to allow', async () => { const requestBody = `--- -kind: Namespace -apiVersion: v1 -metadata: - name: new-ns -`; + kind: Namespace + apiVersion: v1 + metadata: + name: new-ns + `; + + permissions.authorize.mockReturnValue( + Promise.resolve([{ result: AuthorizeResult.ALLOW }]), + ); const proxyEndpointRequest = request(app) .post('/proxy/api/v1/namespaces') .set(HEADER_KUBERNETES_CLUSTER, 'some-cluster') + .set(HEADER_KUBERNETES_AUTH, 'randomtoken') .set('content-type', 'application/yaml') .send(requestBody); @@ -331,5 +362,31 @@ metadata: const response = await proxyEndpointRequest; expect(response.text).toEqual(requestBody); }); + + it('returns a 403 response if Permission Policy is in place that blocks endpoint', async () => { + const requestBody = { + kind: 'Namespace', + apiVersion: 'v1', + metadata: { + name: 'new-ns', + }, + }; + + permissions.authorize.mockReturnValue( + Promise.resolve([{ result: AuthorizeResult.DENY }]), + ); + + const proxyEndpointRequest = request(app) + .post('/proxy/api/v1/namespaces') + .set(HEADER_KUBERNETES_CLUSTER, 'some-cluster') + .set(HEADER_KUBERNETES_AUTH, 'randomtoken') + .send(requestBody); + + worker.use(rest.all(proxyEndpointRequest.url, req => req.passthrough())); + + const response = await proxyEndpointRequest; + + expect(response.status).toEqual(403); + }); }); }); diff --git a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts index 74ec9eada2..ea098ab596 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts @@ -15,6 +15,7 @@ */ import { CatalogApi } from '@backstage/catalog-client'; import { Config } from '@backstage/config'; +import { PermissionEvaluator } from '@backstage/plugin-permission-common'; import express from 'express'; import Router from 'express-promise-router'; import { Duration } from 'luxon'; @@ -49,6 +50,7 @@ export interface KubernetesEnvironment { logger: Logger; config: Config; catalogApi: CatalogApi; + permissions: PermissionEvaluator; } /** @@ -89,6 +91,7 @@ export class KubernetesBuilder { public async build(): KubernetesBuilderReturn { const logger = this.env.logger; const config = this.env.config; + const permissions = this.env.permissions; logger.info('Initializing Kubernetes backend'); @@ -126,6 +129,7 @@ export class KubernetesBuilder { clusterSupplier, this.env.catalogApi, proxy, + permissions, ); return { @@ -262,10 +266,11 @@ export class KubernetesBuilder { clusterSupplier: KubernetesClustersSupplier, catalogApi: CatalogApi, proxy: KubernetesProxy, + permissionApi: PermissionEvaluator, ): express.Router { const logger = this.env.logger; const router = Router(); - router.use('/proxy', proxy.createRequestHandler()); + router.use('/proxy', proxy.createRequestHandler(permissionApi)); router.use(express.json()); // @deprecated diff --git a/plugins/kubernetes-backend/src/service/KubernetesProxy.ts b/plugins/kubernetes-backend/src/service/KubernetesProxy.ts index 178105ef79..8e9f27d994 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesProxy.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesProxy.ts @@ -18,9 +18,19 @@ import { ErrorResponseBody, ForwardedError, InputError, + NotAllowedError, NotFoundError, serializeError, } from '@backstage/errors'; +import { getBearerTokenFromAuthorizationHeader } from '@backstage/plugin-auth-node'; +import { + kubernetesProxyReadPermission, + kubernetesProxyCreatePermission, +} from '@backstage/plugin-kubernetes-common'; +import { + PermissionEvaluator, + AuthorizeResult, +} from '@backstage/plugin-permission-common'; import { bufferFromFileOrString } from '@kubernetes/client-node'; import type { Request, RequestHandler } from 'express'; import { createProxyMiddleware } from 'http-proxy-middleware'; @@ -36,6 +46,13 @@ export const APPLICATION_JSON: string = 'application/json'; */ export const HEADER_KUBERNETES_CLUSTER: string = 'X-Kubernetes-Cluster'; +/** + * The header that is used to specify the Authentication Authorities token. + * e.x if using the google auth provider as your authentication authority then this field would be the google provided bearer token. + * @alpha + */ +export const HEADER_KUBERNETES_AUTH: string = 'X-Kubernetes-Authorization'; + /** * A proxy that routes requests to the Kubernetes API. * @@ -49,8 +66,33 @@ export class KubernetesProxy { private readonly clusterSupplier: KubernetesClustersSupplier, ) {} - public createRequestHandler(): RequestHandler { + public createRequestHandler( + permissionApi: PermissionEvaluator, + ): RequestHandler { return async (req, res, next) => { + const token = getBearerTokenFromAuthorizationHeader( + req.header('authorization'), + ); + + const authorizeResponse = ( + await permissionApi.authorize( + [ + { permission: kubernetesProxyReadPermission }, + { permission: kubernetesProxyCreatePermission }, + ], + { + token, + }, + ) + )[0]; + + if (authorizeResponse.result === AuthorizeResult.DENY) { + res + .status(403) + .json({ error: new NotAllowedError('Unauthorized').message }); + return; + } + const middleware = await this.getMiddleware(req); middleware(req, res, next); }; @@ -104,6 +146,11 @@ export class KubernetesProxy { res.status(500).json(body); }, + onProxyReq: (proxyReq, req) => { + // the kubernetes proxy endpoint expects a header field labeled `X-Kubernetes-Authorization` that will be used to authenticate with the Kubernetes Api. The token provided as a value should be an Authentication Providers bearer token. + const token = req.header('X-Kubernetes-Authorization') ?? ''; + proxyReq.setHeader('Authorization', token); + }, }); this.middlewareForClusterName.set(originalCluster.name, middleware); diff --git a/plugins/kubernetes-backend/src/service/router.ts b/plugins/kubernetes-backend/src/service/router.ts index 341956553e..f7e58f535c 100644 --- a/plugins/kubernetes-backend/src/service/router.ts +++ b/plugins/kubernetes-backend/src/service/router.ts @@ -21,6 +21,7 @@ import express from 'express'; import { KubernetesBuilder } from './KubernetesBuilder'; import { PluginEndpointDiscovery } from '@backstage/backend-common'; import { CatalogApi } from '@backstage/catalog-client'; +import { PermissionEvaluator } from '@backstage/plugin-permission-common'; /** * @@ -32,6 +33,7 @@ export interface RouterOptions { catalogApi: CatalogApi; clusterSupplier?: KubernetesClustersSupplier; discovery: PluginEndpointDiscovery; + permissions: PermissionEvaluator; } /** diff --git a/plugins/kubernetes-backend/src/service/standaloneApplication.ts b/plugins/kubernetes-backend/src/service/standaloneApplication.ts index 8234f89d48..e3afb5a385 100644 --- a/plugins/kubernetes-backend/src/service/standaloneApplication.ts +++ b/plugins/kubernetes-backend/src/service/standaloneApplication.ts @@ -28,6 +28,7 @@ import { Logger } from 'winston'; import { createRouter } from './router'; import { ConfigReader } from '@backstage/config'; import { CatalogClient } from '@backstage/catalog-client'; +import { PermissionEvaluator } from '@backstage/plugin-permission-common'; export interface ApplicationOptions { enableCors: boolean; @@ -47,6 +48,8 @@ export async function createStandaloneApplication( discoveryApi: SingleHostDiscovery.fromConfig(config), }); + const permissions = {} as PermissionEvaluator; + app.use(helmet()); if (enableCors) { app.use(cors()); @@ -54,7 +57,10 @@ export async function createStandaloneApplication( app.use(compression()); app.use(express.json()); app.use(requestLoggingHandler()); - app.use('/', await createRouter({ logger, config, discovery, catalogApi })); + app.use( + '/', + await createRouter({ logger, config, discovery, catalogApi, permissions }), + ); app.use(notFoundHandler()); app.use(errorHandler()); diff --git a/plugins/kubernetes-common/package.json b/plugins/kubernetes-common/package.json index 2d83db84d4..50c7571e72 100644 --- a/plugins/kubernetes-common/package.json +++ b/plugins/kubernetes-common/package.json @@ -39,6 +39,7 @@ }, "dependencies": { "@backstage/catalog-model": "workspace:^", + "@backstage/plugin-permission-common": "workspace:^", "@kubernetes/client-node": "0.18.1" }, "devDependencies": { diff --git a/plugins/kubernetes-common/src/index.ts b/plugins/kubernetes-common/src/index.ts index ade73e4f79..6aab437cd1 100644 --- a/plugins/kubernetes-common/src/index.ts +++ b/plugins/kubernetes-common/src/index.ts @@ -22,3 +22,8 @@ export * from './types'; export * from './catalog-entity-constants'; +export { + kubernetesProxyReadPermission, + kubernetesProxyCreatePermission, + kubernetesClusterPermissions, +} from './permissions'; diff --git a/plugins/kubernetes-common/src/permissions.ts b/plugins/kubernetes-common/src/permissions.ts new file mode 100644 index 0000000000..ef9d8580df --- /dev/null +++ b/plugins/kubernetes-common/src/permissions.ts @@ -0,0 +1,39 @@ +/* + * 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 { createPermission } from '@backstage/plugin-permission-common'; + +/** This permission is used to authorize actions that involve using the kubernetes Proxy Endpoint /proxy + * @alpha + */ +export const kubernetesProxyReadPermission = createPermission({ + name: 'kubernetes.proxy.read', + attributes: { action: 'read' }, +}); + +export const kubernetesProxyCreatePermission = createPermission({ + name: 'kubernetes.proxy.create', + attributes: { action: 'create' }, +}); + +/** + * List of all cluster permissions. + * @alpha + */ +export const kubernetesClusterPermissions = [ + kubernetesProxyReadPermission, + kubernetesProxyCreatePermission, +]; diff --git a/yarn.lock b/yarn.lock index 50f067a32e..7c8b674c0f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6843,6 +6843,7 @@ __metadata: dependencies: "@backstage/catalog-model": "workspace:^" "@backstage/cli": "workspace:^" + "@backstage/plugin-permission-common": "workspace:^" "@kubernetes/client-node": 0.18.1 languageName: unknown linkType: soft @@ -7462,6 +7463,32 @@ __metadata: languageName: unknown linkType: soft +"@backstage/plugin-proxy-restrict@^0.0.0, @backstage/plugin-proxy-restrict@workspace:plugins/proxy-restrict": + version: 0.0.0-use.local + resolution: "@backstage/plugin-proxy-restrict@workspace:plugins/proxy-restrict" + dependencies: + "@backstage/cli": "workspace:^" + "@backstage/core-app-api": "workspace:^" + "@backstage/core-components": "workspace:^" + "@backstage/core-plugin-api": "workspace:^" + "@backstage/dev-utils": "workspace:^" + "@backstage/test-utils": "workspace:^" + "@backstage/theme": "workspace:^" + "@material-ui/core": ^4.12.2 + "@material-ui/icons": ^4.9.1 + "@material-ui/lab": ^4.0.0-alpha.57 + "@testing-library/jest-dom": ^5.10.1 + "@testing-library/react": ^12.1.3 + "@testing-library/user-event": ^14.0.0 + "@types/node": "*" + cross-fetch: ^3.1.5 + msw: ^0.49.0 + react-use: ^17.2.4 + peerDependencies: + react: ^16.13.1 || ^17.0.0 + languageName: unknown + linkType: soft + "@backstage/plugin-rollbar-backend@workspace:^, @backstage/plugin-rollbar-backend@workspace:plugins/rollbar-backend": version: 0.0.0-use.local resolution: "@backstage/plugin-rollbar-backend@workspace:plugins/rollbar-backend" @@ -22469,6 +22496,7 @@ __metadata: "@backstage/plugin-pagerduty": "workspace:^" "@backstage/plugin-permission-react": "workspace:^" "@backstage/plugin-playlist": "workspace:^" + "@backstage/plugin-proxy-restrict": ^0.0.0 "@backstage/plugin-rollbar": "workspace:^" "@backstage/plugin-scaffolder": "workspace:^" "@backstage/plugin-scaffolder-react": "workspace:^" From 804f6d16b0cdc7814822a117e2b1d354396fa6c7 Mon Sep 17 00:00:00 2001 From: Ruben Vallejo Date: Tue, 7 Feb 2023 15:38:33 -0500 Subject: [PATCH 2/6] adding changeset,api-report,addressing ci check Signed-off-by: Ruben Vallejo --- .changeset/eleven-bats-tease.md | 5 ++++ .changeset/wise-garlics-camp.md | 5 ++++ plugins/kubernetes-backend/api-report.md | 8 +++++- .../src/service/KubernetesProxy.test.ts | 26 +++++++++++++++--- plugins/kubernetes-common/api-report.md | 10 +++++++ plugins/kubernetes-common/src/permissions.ts | 5 +++- yarn.lock | 27 ------------------- 7 files changed, 53 insertions(+), 33 deletions(-) create mode 100644 .changeset/eleven-bats-tease.md create mode 100644 .changeset/wise-garlics-camp.md diff --git a/.changeset/eleven-bats-tease.md b/.changeset/eleven-bats-tease.md new file mode 100644 index 0000000000..38c591ea3c --- /dev/null +++ b/.changeset/eleven-bats-tease.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-kubernetes-backend': minor +--- + +PermissionApi of type PermissionEvaluator must now be passed as a parameter when calling proxy.createRequestHandler() for the KubernetesProxyEndpoint. Kubernetes `/proxy` endpoint now requires two tokens in its header per request.The field `X-Kubernetes-Authentication` needs the value of a authentication authorities' bearer token.The field `Authorization` should contain a backstage identity token. diff --git a/.changeset/wise-garlics-camp.md b/.changeset/wise-garlics-camp.md new file mode 100644 index 0000000000..e7639ad71c --- /dev/null +++ b/.changeset/wise-garlics-camp.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-kubernetes-common': patch +--- + +Introduced proxy permission types to be used with the kubernetes proxy endpoint's permission framework integration. diff --git a/plugins/kubernetes-backend/api-report.md b/plugins/kubernetes-backend/api-report.md index a6a4bcfc58..431ddce244 100644 --- a/plugins/kubernetes-backend/api-report.md +++ b/plugins/kubernetes-backend/api-report.md @@ -17,6 +17,7 @@ import { KubernetesRequestAuth } from '@backstage/plugin-kubernetes-common'; import type { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common'; import { Logger } from 'winston'; import type { ObjectsByEntityResponse } from '@backstage/plugin-kubernetes-common'; +import { PermissionEvaluator } from '@backstage/plugin-permission-common'; import { PluginEndpointDiscovery } from '@backstage/backend-common'; import type { RequestHandler } from 'express'; import { TokenCredential } from '@azure/identity'; @@ -200,6 +201,7 @@ export class KubernetesBuilder { clusterSupplier: KubernetesClustersSupplier, catalogApi: CatalogApi, proxy: KubernetesProxy, + permissionApi: PermissionEvaluator, ): express.Router; // (undocumented) protected buildServiceLocator( @@ -271,6 +273,8 @@ export interface KubernetesEnvironment { config: Config; // (undocumented) logger: Logger; + // (undocumented) + permissions: PermissionEvaluator; } // @public @@ -340,7 +344,7 @@ export type KubernetesObjectTypes = export class KubernetesProxy { constructor(logger: Logger, clusterSupplier: KubernetesClustersSupplier); // (undocumented) - createRequestHandler(): RequestHandler; + createRequestHandler(permissionApi: PermissionEvaluator): RequestHandler; } // @public @@ -418,6 +422,8 @@ export interface RouterOptions { discovery: PluginEndpointDiscovery; // (undocumented) logger: Logger; + // (undocumented) + permissions: PermissionEvaluator; } // @public (undocumented) diff --git a/plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts b/plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts index 4254bb33da..a033e7d52d 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts @@ -30,6 +30,10 @@ import { HEADER_KUBERNETES_CLUSTER, KubernetesProxy, } from './KubernetesProxy'; +import { + AuthorizeResult, + PermissionEvaluator, +} from '@backstage/plugin-permission-common'; describe('KubernetesProxy', () => { let proxy: KubernetesProxy; @@ -64,6 +68,11 @@ describe('KubernetesProxy', () => { getClusters: jest.fn(), }; + const permissions: jest.Mocked = { + authorize: jest.fn(), + authorizeConditional: jest.fn(), + }; + beforeEach(() => { jest.resetAllMocks(); proxy = new KubernetesProxy(getVoidLogger(), clusterSupplier); @@ -71,13 +80,16 @@ describe('KubernetesProxy', () => { it('should return a ERROR_NOT_FOUND if no clusters are found', async () => { clusterSupplier.getClusters.mockResolvedValue([]); + permissions.authorize.mockReturnValue( + Promise.resolve([{ result: AuthorizeResult.ALLOW }]), + ); const req = buildMockRequest('test', 'api'); const { res, next } = getMockRes(); - await expect(proxy.createRequestHandler()(req, res, next)).rejects.toThrow( - NotFoundError, - ); + await expect( + proxy.createRequestHandler(permissions)(req, res, next), + ).rejects.toThrow(NotFoundError); }); it('should pass the exact response from Kubernetes', async () => { @@ -100,7 +112,13 @@ describe('KubernetesProxy', () => { authProvider: 'serviceAccount', }, ] as ClusterDetails[]); - const app = express().use('/mountpath', proxy.createRequestHandler()); + const app = express().use( + '/mountpath', + proxy.createRequestHandler(permissions), + ); + permissions.authorize.mockReturnValue( + Promise.resolve([{ result: AuthorizeResult.ALLOW }]), + ); const requestPromise = request(app) .get('/mountpath/api') .set(HEADER_KUBERNETES_CLUSTER, 'cluster1'); diff --git a/plugins/kubernetes-common/api-report.md b/plugins/kubernetes-common/api-report.md index fb41895f1a..a06780a61a 100644 --- a/plugins/kubernetes-common/api-report.md +++ b/plugins/kubernetes-common/api-report.md @@ -3,6 +3,7 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import { BasicPermission } from '@backstage/plugin-permission-common'; import { Entity } from '@backstage/catalog-model'; import type { JsonObject } from '@backstage/types'; import { PodStatus } from '@kubernetes/client-node'; @@ -206,6 +207,9 @@ export interface JobsFetchResponse { type: 'jobs'; } +// @alpha +export const kubernetesClusterPermissions: BasicPermission[]; + // @public (undocumented) export type KubernetesErrorTypes = | 'BAD_REQUEST' @@ -217,6 +221,12 @@ export type KubernetesErrorTypes = // @public (undocumented) export type KubernetesFetchError = StatusError | RawFetchError; +// @alpha +export const kubernetesProxyCreatePermission: BasicPermission; + +// @alpha +export const kubernetesProxyReadPermission: BasicPermission; + // @public (undocumented) export interface KubernetesRequestAuth { // (undocumented) diff --git a/plugins/kubernetes-common/src/permissions.ts b/plugins/kubernetes-common/src/permissions.ts index ef9d8580df..854a188506 100644 --- a/plugins/kubernetes-common/src/permissions.ts +++ b/plugins/kubernetes-common/src/permissions.ts @@ -16,7 +16,7 @@ import { createPermission } from '@backstage/plugin-permission-common'; -/** This permission is used to authorize actions that involve using the kubernetes Proxy Endpoint /proxy +/** This permission is used to authorize `read` actions that involve using the kubernetes Proxy Endpoint /proxy * @alpha */ export const kubernetesProxyReadPermission = createPermission({ @@ -24,6 +24,9 @@ export const kubernetesProxyReadPermission = createPermission({ attributes: { action: 'read' }, }); +/** This permission is used to authorize `create` actions that involve using the kubernetes Proxy Endpoint /proxy + * @alpha + */ export const kubernetesProxyCreatePermission = createPermission({ name: 'kubernetes.proxy.create', attributes: { action: 'create' }, diff --git a/yarn.lock b/yarn.lock index 7c8b674c0f..075384ae64 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7463,32 +7463,6 @@ __metadata: languageName: unknown linkType: soft -"@backstage/plugin-proxy-restrict@^0.0.0, @backstage/plugin-proxy-restrict@workspace:plugins/proxy-restrict": - version: 0.0.0-use.local - resolution: "@backstage/plugin-proxy-restrict@workspace:plugins/proxy-restrict" - dependencies: - "@backstage/cli": "workspace:^" - "@backstage/core-app-api": "workspace:^" - "@backstage/core-components": "workspace:^" - "@backstage/core-plugin-api": "workspace:^" - "@backstage/dev-utils": "workspace:^" - "@backstage/test-utils": "workspace:^" - "@backstage/theme": "workspace:^" - "@material-ui/core": ^4.12.2 - "@material-ui/icons": ^4.9.1 - "@material-ui/lab": ^4.0.0-alpha.57 - "@testing-library/jest-dom": ^5.10.1 - "@testing-library/react": ^12.1.3 - "@testing-library/user-event": ^14.0.0 - "@types/node": "*" - cross-fetch: ^3.1.5 - msw: ^0.49.0 - react-use: ^17.2.4 - peerDependencies: - react: ^16.13.1 || ^17.0.0 - languageName: unknown - linkType: soft - "@backstage/plugin-rollbar-backend@workspace:^, @backstage/plugin-rollbar-backend@workspace:plugins/rollbar-backend": version: 0.0.0-use.local resolution: "@backstage/plugin-rollbar-backend@workspace:plugins/rollbar-backend" @@ -22496,7 +22470,6 @@ __metadata: "@backstage/plugin-pagerduty": "workspace:^" "@backstage/plugin-permission-react": "workspace:^" "@backstage/plugin-playlist": "workspace:^" - "@backstage/plugin-proxy-restrict": ^0.0.0 "@backstage/plugin-rollbar": "workspace:^" "@backstage/plugin-scaffolder": "workspace:^" "@backstage/plugin-scaffolder-react": "workspace:^" From a711ad54da54e45e9633e6caa0464a5f4027c686 Mon Sep 17 00:00:00 2001 From: Ruben Vallejo Date: Thu, 9 Feb 2023 12:12:07 -0500 Subject: [PATCH 3/6] Revert kubernetes create and read permissions to singular proxy permission, reword changeset, add permission parameter to install doc Signed-off-by: Ruben Vallejo --- .changeset/eleven-bats-tease.md | 2 +- docs/features/kubernetes/installation.md | 1 + .../src/service/KubernetesProxy.ts | 10 ++------- plugins/kubernetes-common/src/index.ts | 3 +-- plugins/kubernetes-common/src/permissions.ts | 21 +++++-------------- 5 files changed, 10 insertions(+), 27 deletions(-) diff --git a/.changeset/eleven-bats-tease.md b/.changeset/eleven-bats-tease.md index 38c591ea3c..400a0fb722 100644 --- a/.changeset/eleven-bats-tease.md +++ b/.changeset/eleven-bats-tease.md @@ -2,4 +2,4 @@ '@backstage/plugin-kubernetes-backend': minor --- -PermissionApi of type PermissionEvaluator must now be passed as a parameter when calling proxy.createRequestHandler() for the KubernetesProxyEndpoint. Kubernetes `/proxy` endpoint now requires two tokens in its header per request.The field `X-Kubernetes-Authentication` needs the value of a authentication authorities' bearer token.The field `Authorization` should contain a backstage identity token. +`KubernetesBuilder.create` now requires a `permissions` field of type `PermissionEvaluator`. The kubernetes `/proxy` endpoint now requires two tokens: the `X-Kubernetes-Authorization` header should contain a bearer token for the target cluster, and the `Authorization` header should contain a backstage identity token. diff --git a/docs/features/kubernetes/installation.md b/docs/features/kubernetes/installation.md index ae7826a804..17028ef90f 100644 --- a/docs/features/kubernetes/installation.md +++ b/docs/features/kubernetes/installation.md @@ -75,6 +75,7 @@ export default async function createPlugin( logger: env.logger, config: env.config, catalogApi, + permissions: env.permissions, }).build(); return router; } diff --git a/plugins/kubernetes-backend/src/service/KubernetesProxy.ts b/plugins/kubernetes-backend/src/service/KubernetesProxy.ts index 8e9f27d994..59d209d93a 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesProxy.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesProxy.ts @@ -23,10 +23,7 @@ import { serializeError, } from '@backstage/errors'; import { getBearerTokenFromAuthorizationHeader } from '@backstage/plugin-auth-node'; -import { - kubernetesProxyReadPermission, - kubernetesProxyCreatePermission, -} from '@backstage/plugin-kubernetes-common'; +import { kubernetesProxyPermission } from '@backstage/plugin-kubernetes-common'; import { PermissionEvaluator, AuthorizeResult, @@ -76,10 +73,7 @@ export class KubernetesProxy { const authorizeResponse = ( await permissionApi.authorize( - [ - { permission: kubernetesProxyReadPermission }, - { permission: kubernetesProxyCreatePermission }, - ], + [{ permission: kubernetesProxyPermission }], { token, }, diff --git a/plugins/kubernetes-common/src/index.ts b/plugins/kubernetes-common/src/index.ts index 6aab437cd1..88b600f17b 100644 --- a/plugins/kubernetes-common/src/index.ts +++ b/plugins/kubernetes-common/src/index.ts @@ -23,7 +23,6 @@ export * from './types'; export * from './catalog-entity-constants'; export { - kubernetesProxyReadPermission, - kubernetesProxyCreatePermission, + kubernetesProxyPermission, kubernetesClusterPermissions, } from './permissions'; diff --git a/plugins/kubernetes-common/src/permissions.ts b/plugins/kubernetes-common/src/permissions.ts index 854a188506..58eb47fbf3 100644 --- a/plugins/kubernetes-common/src/permissions.ts +++ b/plugins/kubernetes-common/src/permissions.ts @@ -16,27 +16,16 @@ import { createPermission } from '@backstage/plugin-permission-common'; -/** This permission is used to authorize `read` actions that involve using the kubernetes Proxy Endpoint /proxy +/** This permission is used to check access to the proxy endpoint * @alpha */ -export const kubernetesProxyReadPermission = createPermission({ - name: 'kubernetes.proxy.read', - attributes: { action: 'read' }, -}); - -/** This permission is used to authorize `create` actions that involve using the kubernetes Proxy Endpoint /proxy - * @alpha - */ -export const kubernetesProxyCreatePermission = createPermission({ - name: 'kubernetes.proxy.create', - attributes: { action: 'create' }, +export const kubernetesProxyPermission = createPermission({ + name: 'kubernetes.proxy', + attributes: {}, }); /** * List of all cluster permissions. * @alpha */ -export const kubernetesClusterPermissions = [ - kubernetesProxyReadPermission, - kubernetesProxyCreatePermission, -]; +export const kubernetesClusterPermissions = [kubernetesProxyPermission]; From e2ff409a3b6780849b1ccafd4b7d6f4c24fc70e3 Mon Sep 17 00:00:00 2001 From: Ruben Vallejo Date: Thu, 9 Feb 2023 15:22:18 -0500 Subject: [PATCH 4/6] Add permission integration router which allows us to list permissions available to the plugin Signed-off-by: Ruben Vallejo --- plugins/kubernetes-backend/package.json | 2 ++ .../src/service/KubernetesBuilder.test.ts | 30 +++++++++++++++---- .../src/service/KubernetesBuilder.ts | 13 +++++++- plugins/kubernetes-common/api-report.md | 10 +++---- plugins/kubernetes-common/src/index.ts | 3 +- plugins/kubernetes-common/src/permissions.ts | 10 +++++-- yarn.lock | 2 ++ 7 files changed, 55 insertions(+), 15 deletions(-) diff --git a/plugins/kubernetes-backend/package.json b/plugins/kubernetes-backend/package.json index 850f3c67f4..e052fb4f58 100644 --- a/plugins/kubernetes-backend/package.json +++ b/plugins/kubernetes-backend/package.json @@ -42,6 +42,8 @@ "@backstage/errors": "workspace:^", "@backstage/plugin-auth-node": "workspace:^", "@backstage/plugin-kubernetes-common": "workspace:^", + "@backstage/plugin-permission-common": "workspace:^", + "@backstage/plugin-permission-node": "workspace:^", "@google-cloud/container": "^4.0.0", "@jest-mock/express": "^2.0.1", "@kubernetes/client-node": "0.18.1", diff --git a/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts b/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts index 695649b9f3..8eb7a066df 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts @@ -286,7 +286,6 @@ describe('KubernetesBuilder', () => { expect(response.status).toEqual(200); }); }); - describe('post /proxy', () => { const worker = setupServer(); setupRequestMockHandlers(worker); @@ -340,11 +339,11 @@ describe('KubernetesBuilder', () => { it('supports yaml content type with permission set to allow', async () => { const requestBody = `--- - kind: Namespace - apiVersion: v1 - metadata: - name: new-ns - `; +kind: Namespace +apiVersion: v1 +metadata: + name: new-ns +`; permissions.authorize.mockReturnValue( Promise.resolve([{ result: AuthorizeResult.ALLOW }]), @@ -389,4 +388,23 @@ describe('KubernetesBuilder', () => { expect(response.status).toEqual(403); }); }); + describe('get /.well-known/backstage/permissions/metadata', () => { + it('lists permissions supported by the kubernetes plugin', async () => { + const response = await request(app).get( + '/.well-known/backstage/permissions/metadata', + ); + + expect(response.status).toEqual(200); + expect(response.body).toMatchObject({ + permissions: [ + { + type: 'basic', + name: 'kubernetes.proxy', + attributes: {}, + }, + ], + rules: [], + }); + }); + }); }); diff --git a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts index ea098ab596..c84f87b36f 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts @@ -15,7 +15,12 @@ */ import { CatalogApi } from '@backstage/catalog-client'; import { Config } from '@backstage/config'; +import { + kubernetesPermissions, + RESOURCE_TYPE_KUBERNETES_RESOURCE, +} from '@backstage/plugin-kubernetes-common'; import { PermissionEvaluator } from '@backstage/plugin-permission-common'; +import { createPermissionIntegrationRouter } from '@backstage/plugin-permission-node'; import express from 'express'; import Router from 'express-promise-router'; import { Duration } from 'luxon'; @@ -272,7 +277,13 @@ export class KubernetesBuilder { const router = Router(); router.use('/proxy', proxy.createRequestHandler(permissionApi)); router.use(express.json()); - + router.use( + createPermissionIntegrationRouter({ + resourceType: RESOURCE_TYPE_KUBERNETES_RESOURCE, + permissions: kubernetesPermissions, + rules: [], + }), + ); // @deprecated router.post('/services/:serviceId', async (req, res) => { const serviceId = req.params.serviceId; diff --git a/plugins/kubernetes-common/api-report.md b/plugins/kubernetes-common/api-report.md index a06780a61a..5fdd40d152 100644 --- a/plugins/kubernetes-common/api-report.md +++ b/plugins/kubernetes-common/api-report.md @@ -207,9 +207,6 @@ export interface JobsFetchResponse { type: 'jobs'; } -// @alpha -export const kubernetesClusterPermissions: BasicPermission[]; - // @public (undocumented) export type KubernetesErrorTypes = | 'BAD_REQUEST' @@ -222,10 +219,10 @@ export type KubernetesErrorTypes = export type KubernetesFetchError = StatusError | RawFetchError; // @alpha -export const kubernetesProxyCreatePermission: BasicPermission; +export const kubernetesPermissions: BasicPermission[]; // @alpha -export const kubernetesProxyReadPermission: BasicPermission; +export const kubernetesProxyPermission: BasicPermission; // @public (undocumented) export interface KubernetesRequestAuth { @@ -291,6 +288,9 @@ export interface ReplicaSetsFetchResponse { type: 'replicasets'; } +// @alpha +export const RESOURCE_TYPE_KUBERNETES_RESOURCE = 'kubernetes-resource'; + // @public (undocumented) export interface ServiceFetchResponse { // (undocumented) diff --git a/plugins/kubernetes-common/src/index.ts b/plugins/kubernetes-common/src/index.ts index 88b600f17b..e9fb2b35d3 100644 --- a/plugins/kubernetes-common/src/index.ts +++ b/plugins/kubernetes-common/src/index.ts @@ -23,6 +23,7 @@ export * from './types'; export * from './catalog-entity-constants'; export { + RESOURCE_TYPE_KUBERNETES_RESOURCE, kubernetesProxyPermission, - kubernetesClusterPermissions, + kubernetesPermissions, } from './permissions'; diff --git a/plugins/kubernetes-common/src/permissions.ts b/plugins/kubernetes-common/src/permissions.ts index 58eb47fbf3..3123796aa3 100644 --- a/plugins/kubernetes-common/src/permissions.ts +++ b/plugins/kubernetes-common/src/permissions.ts @@ -16,6 +16,12 @@ import { createPermission } from '@backstage/plugin-permission-common'; +/** + * Permission resource type which corresponds to cluster entities. + * @alpha + */ +export const RESOURCE_TYPE_KUBERNETES_RESOURCE = 'kubernetes-resource'; + /** This permission is used to check access to the proxy endpoint * @alpha */ @@ -25,7 +31,7 @@ export const kubernetesProxyPermission = createPermission({ }); /** - * List of all cluster permissions. + * List of all Kubernetes permissions. * @alpha */ -export const kubernetesClusterPermissions = [kubernetesProxyPermission]; +export const kubernetesPermissions = [kubernetesProxyPermission]; diff --git a/yarn.lock b/yarn.lock index 075384ae64..71b43756fa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6807,6 +6807,8 @@ __metadata: "@backstage/errors": "workspace:^" "@backstage/plugin-auth-node": "workspace:^" "@backstage/plugin-kubernetes-common": "workspace:^" + "@backstage/plugin-permission-common": "workspace:^" + "@backstage/plugin-permission-node": "workspace:^" "@google-cloud/container": ^4.0.0 "@jest-mock/express": ^2.0.1 "@kubernetes/client-node": 0.18.1 From c04498db278bf3751964207c69d24a37706dea27 Mon Sep 17 00:00:00 2001 From: Ruben Vallejo Date: Mon, 13 Feb 2023 16:02:52 -0500 Subject: [PATCH 5/6] Refactor header names, add options object to requestHandler and add new changes to api-reports and changeset along with docs Signed-off-by: Ruben Vallejo --- .changeset/eleven-bats-tease.md | 2 +- docs/features/kubernetes/proxy.md | 15 ++++++++--- plugins/kubernetes-backend/api-report.md | 12 ++++++++- .../src/service/KubernetesBuilder.ts | 9 ++----- .../src/service/KubernetesProxy.test.ts | 10 +++---- .../src/service/KubernetesProxy.ts | 27 ++++++++++++------- .../kubernetes-backend/src/service/index.ts | 7 ++++- plugins/kubernetes-common/api-report.md | 7 ++--- plugins/kubernetes-common/src/index.ts | 1 - plugins/kubernetes-common/src/permissions.ts | 10 ++----- 10 files changed, 59 insertions(+), 41 deletions(-) diff --git a/.changeset/eleven-bats-tease.md b/.changeset/eleven-bats-tease.md index 400a0fb722..732055ca52 100644 --- a/.changeset/eleven-bats-tease.md +++ b/.changeset/eleven-bats-tease.md @@ -2,4 +2,4 @@ '@backstage/plugin-kubernetes-backend': minor --- -`KubernetesBuilder.create` now requires a `permissions` field of type `PermissionEvaluator`. The kubernetes `/proxy` endpoint now requires two tokens: the `X-Kubernetes-Authorization` header should contain a bearer token for the target cluster, and the `Authorization` header should contain a backstage identity token. +**BREAKING**: `KubernetesBuilder.create` now requires a `permissions` field of type `PermissionEvaluator`. The kubernetes `/proxy` endpoint now requires two tokens: the `Backstage-Kubernetes-Authorization` header should contain a bearer token for the target cluster, and the `Authorization` header should contain a backstage identity token. The kubernetes `/proxy` endpoint now requires a `Backstage-Kubernetes-Cluster` header replacing the previously required `X-Kubernetes-Cluster` header. diff --git a/docs/features/kubernetes/proxy.md b/docs/features/kubernetes/proxy.md index 5848d62e3e..08d80d3e30 100644 --- a/docs/features/kubernetes/proxy.md +++ b/docs/features/kubernetes/proxy.md @@ -23,6 +23,7 @@ import { googleAuthApiRef, useApi, } from '@backstage/core-plugin-api'; +import { getBearerTokenFromAuthorizationHeader } from '@backstage/plugin-auth-node'; const CLUSTER_NAME = ''; // use a known cluster name @@ -32,6 +33,11 @@ const token = await googleAuthApi.getAccessToken( 'https://www.googleapis.com/auth/cloud-platform', ); +// get a bearer token from backstage `/auth` +const userToken = getBearerTokenFromAuthorizationHeader( + req.header('authorization'), +); + const discoveryApi = useApi(discoveryApiRef); const kubernetesBaseUrl = await discoveryApi.getBaseUrl('kubernetes'); const kubernetesProxyEndpoint = `${kubernetesBaseUrl}/proxy`; @@ -40,8 +46,9 @@ const kubernetesProxyEndpoint = `${kubernetesBaseUrl}/proxy`; await fetch(`${kubernetesProxyEndpoint}/api/v1/namespaces`, { method: 'GET', headers: { - 'X-Kubernetes-Cluster': CLUSTER_NAME, - Authorization: `Bearer ${token}`, + 'Backstage-Kubernetes-Cluster': CLUSTER_NAME, + 'Backstage-Kubernetes-Authorization': `Bearer ${token}`, + Authorization: `Bearer ${userToken}`, }, }); ``` @@ -49,7 +56,7 @@ await fetch(`${kubernetesProxyEndpoint}/api/v1/namespaces`, { ## How it works The proxy will interpret the -[`X-Kubernetes-Cluster` +[`Backstage-Kubernetes-Cluster` header](https://backstage.io/docs/reference/plugin-kubernetes-backend.header_kubernetes_cluster) as the name of the cluster to target. This name will be compared to each cluster returned by all the configured [cluster @@ -60,6 +67,8 @@ the value in the header will be targeted. Then the request will be forwarded verbatim (but with the endpoint's base URL prefix stripped) to the cluster. +The proxy will also interpret the `Backstage-Kubernetes-Authorization` header as the `Authorization` header to use when forwarding a request to a target cluster. + ## Authentication Until some security and permission decisions are made (see [this diff --git a/plugins/kubernetes-backend/api-report.md b/plugins/kubernetes-backend/api-report.md index 431ddce244..449131a26e 100644 --- a/plugins/kubernetes-backend/api-report.md +++ b/plugins/kubernetes-backend/api-report.md @@ -142,6 +142,9 @@ export class GoogleServiceAccountAuthTranslator ): Promise; } +// @public +export const HEADER_KUBERNETES_AUTH: string; + // @public export const HEADER_KUBERNETES_CLUSTER: string; @@ -344,9 +347,16 @@ export type KubernetesObjectTypes = export class KubernetesProxy { constructor(logger: Logger, clusterSupplier: KubernetesClustersSupplier); // (undocumented) - createRequestHandler(permissionApi: PermissionEvaluator): RequestHandler; + createRequestHandler( + options: KubernetesProxyCreateRequestHandlerOptions, + ): RequestHandler; } +// @public +export type KubernetesProxyCreateRequestHandlerOptions = { + permissionApi: PermissionEvaluator; +}; + // @public export interface KubernetesServiceLocator { // (undocumented) diff --git a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts index c84f87b36f..367e5edef3 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts @@ -15,10 +15,7 @@ */ import { CatalogApi } from '@backstage/catalog-client'; import { Config } from '@backstage/config'; -import { - kubernetesPermissions, - RESOURCE_TYPE_KUBERNETES_RESOURCE, -} from '@backstage/plugin-kubernetes-common'; +import { kubernetesPermissions } from '@backstage/plugin-kubernetes-common'; import { PermissionEvaluator } from '@backstage/plugin-permission-common'; import { createPermissionIntegrationRouter } from '@backstage/plugin-permission-node'; import express from 'express'; @@ -275,13 +272,11 @@ export class KubernetesBuilder { ): express.Router { const logger = this.env.logger; const router = Router(); - router.use('/proxy', proxy.createRequestHandler(permissionApi)); + router.use('/proxy', proxy.createRequestHandler({ permissionApi })); router.use(express.json()); router.use( createPermissionIntegrationRouter({ - resourceType: RESOURCE_TYPE_KUBERNETES_RESOURCE, permissions: kubernetesPermissions, - rules: [], }), ); // @deprecated diff --git a/plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts b/plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts index a033e7d52d..d2404af839 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts @@ -68,7 +68,7 @@ describe('KubernetesProxy', () => { getClusters: jest.fn(), }; - const permissions: jest.Mocked = { + const permissionApi: jest.Mocked = { authorize: jest.fn(), authorizeConditional: jest.fn(), }; @@ -80,7 +80,7 @@ describe('KubernetesProxy', () => { it('should return a ERROR_NOT_FOUND if no clusters are found', async () => { clusterSupplier.getClusters.mockResolvedValue([]); - permissions.authorize.mockReturnValue( + permissionApi.authorize.mockReturnValue( Promise.resolve([{ result: AuthorizeResult.ALLOW }]), ); @@ -88,7 +88,7 @@ describe('KubernetesProxy', () => { const { res, next } = getMockRes(); await expect( - proxy.createRequestHandler(permissions)(req, res, next), + proxy.createRequestHandler({ permissionApi })(req, res, next), ).rejects.toThrow(NotFoundError); }); @@ -114,9 +114,9 @@ describe('KubernetesProxy', () => { ] as ClusterDetails[]); const app = express().use( '/mountpath', - proxy.createRequestHandler(permissions), + proxy.createRequestHandler({ permissionApi }), ); - permissions.authorize.mockReturnValue( + permissionApi.authorize.mockReturnValue( Promise.resolve([{ result: AuthorizeResult.ALLOW }]), ); const requestPromise = request(app) diff --git a/plugins/kubernetes-backend/src/service/KubernetesProxy.ts b/plugins/kubernetes-backend/src/service/KubernetesProxy.ts index 59d209d93a..49a381b75a 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesProxy.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesProxy.ts @@ -41,14 +41,24 @@ export const APPLICATION_JSON: string = 'application/json'; * * @public */ -export const HEADER_KUBERNETES_CLUSTER: string = 'X-Kubernetes-Cluster'; +export const HEADER_KUBERNETES_CLUSTER: string = 'Backstage-Kubernetes-Cluster'; /** * The header that is used to specify the Authentication Authorities token. * e.x if using the google auth provider as your authentication authority then this field would be the google provided bearer token. - * @alpha + * @public */ -export const HEADER_KUBERNETES_AUTH: string = 'X-Kubernetes-Authorization'; +export const HEADER_KUBERNETES_AUTH: string = + 'Backstage-Kubernetes-Authorization'; + +/** + * The options object expected to be passed as a parameter to KubernetesProxy.createRequestHandler(). + * + * @public + */ +export type KubernetesProxyCreateRequestHandlerOptions = { + permissionApi: PermissionEvaluator; +}; /** * A proxy that routes requests to the Kubernetes API. @@ -64,8 +74,9 @@ export class KubernetesProxy { ) {} public createRequestHandler( - permissionApi: PermissionEvaluator, + options: KubernetesProxyCreateRequestHandlerOptions, ): RequestHandler { + const { permissionApi } = options; return async (req, res, next) => { const token = getBearerTokenFromAuthorizationHeader( req.header('authorization'), @@ -81,9 +92,7 @@ export class KubernetesProxy { )[0]; if (authorizeResponse.result === AuthorizeResult.DENY) { - res - .status(403) - .json({ error: new NotAllowedError('Unauthorized').message }); + res.status(403).json({ error: new NotAllowedError('Unauthorized') }); return; } @@ -141,8 +150,8 @@ export class KubernetesProxy { res.status(500).json(body); }, onProxyReq: (proxyReq, req) => { - // the kubernetes proxy endpoint expects a header field labeled `X-Kubernetes-Authorization` that will be used to authenticate with the Kubernetes Api. The token provided as a value should be an Authentication Providers bearer token. - const token = req.header('X-Kubernetes-Authorization') ?? ''; + // the kubernetes proxy endpoint expects a header field labeled `Backstage-Kubernetes-Authorization` that will be used to authenticate with the Kubernetes Api. The token provided as a value should be an bearer token for the target cluster. + const token = req.header(HEADER_KUBERNETES_AUTH) ?? ''; proxyReq.setHeader('Authorization', token); }, }); diff --git a/plugins/kubernetes-backend/src/service/index.ts b/plugins/kubernetes-backend/src/service/index.ts index 620f788ac2..2f3e907879 100644 --- a/plugins/kubernetes-backend/src/service/index.ts +++ b/plugins/kubernetes-backend/src/service/index.ts @@ -16,5 +16,10 @@ export * from './KubernetesBuilder'; export { DEFAULT_OBJECTS } from './KubernetesFanOutHandler'; -export { HEADER_KUBERNETES_CLUSTER, KubernetesProxy } from './KubernetesProxy'; +export { + HEADER_KUBERNETES_CLUSTER, + KubernetesProxy, + HEADER_KUBERNETES_AUTH, +} from './KubernetesProxy'; +export type { KubernetesProxyCreateRequestHandlerOptions } from './KubernetesProxy'; export * from './router'; diff --git a/plugins/kubernetes-common/api-report.md b/plugins/kubernetes-common/api-report.md index 5fdd40d152..6c88074e50 100644 --- a/plugins/kubernetes-common/api-report.md +++ b/plugins/kubernetes-common/api-report.md @@ -218,10 +218,10 @@ export type KubernetesErrorTypes = // @public (undocumented) export type KubernetesFetchError = StatusError | RawFetchError; -// @alpha +// @public export const kubernetesPermissions: BasicPermission[]; -// @alpha +// @public export const kubernetesProxyPermission: BasicPermission; // @public (undocumented) @@ -288,9 +288,6 @@ export interface ReplicaSetsFetchResponse { type: 'replicasets'; } -// @alpha -export const RESOURCE_TYPE_KUBERNETES_RESOURCE = 'kubernetes-resource'; - // @public (undocumented) export interface ServiceFetchResponse { // (undocumented) diff --git a/plugins/kubernetes-common/src/index.ts b/plugins/kubernetes-common/src/index.ts index e9fb2b35d3..d5cf8dcf61 100644 --- a/plugins/kubernetes-common/src/index.ts +++ b/plugins/kubernetes-common/src/index.ts @@ -23,7 +23,6 @@ export * from './types'; export * from './catalog-entity-constants'; export { - RESOURCE_TYPE_KUBERNETES_RESOURCE, kubernetesProxyPermission, kubernetesPermissions, } from './permissions'; diff --git a/plugins/kubernetes-common/src/permissions.ts b/plugins/kubernetes-common/src/permissions.ts index 3123796aa3..d3ccbe8171 100644 --- a/plugins/kubernetes-common/src/permissions.ts +++ b/plugins/kubernetes-common/src/permissions.ts @@ -16,14 +16,8 @@ import { createPermission } from '@backstage/plugin-permission-common'; -/** - * Permission resource type which corresponds to cluster entities. - * @alpha - */ -export const RESOURCE_TYPE_KUBERNETES_RESOURCE = 'kubernetes-resource'; - /** This permission is used to check access to the proxy endpoint - * @alpha + * @public */ export const kubernetesProxyPermission = createPermission({ name: 'kubernetes.proxy', @@ -32,6 +26,6 @@ export const kubernetesProxyPermission = createPermission({ /** * List of all Kubernetes permissions. - * @alpha + * @public */ export const kubernetesPermissions = [kubernetesProxyPermission]; From 7c4cf78bd8eac5a5542c816cc54454198b79664a Mon Sep 17 00:00:00 2001 From: Ruben Vallejo Date: Mon, 13 Feb 2023 17:32:11 -0500 Subject: [PATCH 6/6] Add disable proxy endpoint section to docs Signed-off-by: Ruben Vallejo --- docs/features/kubernetes/proxy.md | 78 +++++++++++++++++++++++++------ 1 file changed, 64 insertions(+), 14 deletions(-) diff --git a/docs/features/kubernetes/proxy.md b/docs/features/kubernetes/proxy.md index 08d80d3e30..b53dfeb29d 100644 --- a/docs/features/kubernetes/proxy.md +++ b/docs/features/kubernetes/proxy.md @@ -22,8 +22,8 @@ import { discoveryApiRef, googleAuthApiRef, useApi, + identityApiRef, } from '@backstage/core-plugin-api'; -import { getBearerTokenFromAuthorizationHeader } from '@backstage/plugin-auth-node'; const CLUSTER_NAME = ''; // use a known cluster name @@ -33,10 +33,9 @@ const token = await googleAuthApi.getAccessToken( 'https://www.googleapis.com/auth/cloud-platform', ); -// get a bearer token from backstage `/auth` -const userToken = getBearerTokenFromAuthorizationHeader( - req.header('authorization'), -); +// 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'); @@ -56,18 +55,19 @@ await fetch(`${kubernetesProxyEndpoint}/api/v1/namespaces`, { ## How it works The proxy will interpret the -[`Backstage-Kubernetes-Cluster` -header](https://backstage.io/docs/reference/plugin-kubernetes-backend.header_kubernetes_cluster) -as the name of the cluster to target. This name will be compared to each cluster +[`Backstage-Kubernetes-Cluster`](https://backstage.io/docs/reference/plugin-kubernetes-backend.header_kubernetes_cluster) +header as the name of the cluster to target. This name will be compared to each cluster returned by all the configured [cluster locators](https://backstage.io/docs/features/kubernetes/configuration#clusterlocatormethods) -- the first cluster whose [`name` field](https://backstage.io/docs/features/kubernetes/configuration#clustersname) matches the value in the header will be targeted. -Then the request will be forwarded verbatim (but with the endpoint's base URL -prefix stripped) to the cluster. +Then the request will be forwarded to the cluster. -The proxy will also interpret the `Backstage-Kubernetes-Authorization` header as the `Authorization` header to use when forwarding a request to a target cluster. +Overall, the only changes to each request are: + +- the endpoint's base URL prefix is stripped. +- the `Backstage-Kubernetes-Authorization` header becomes the `Authorization` header that is used when forwarding the request. ## Authentication @@ -82,9 +82,59 @@ providers](https://backstage.io/docs/features/kubernetes/authentication#client-s The proxy has no provisions for mTLS, so it cannot be used to connect to clusters using the [x509 Client Certs](https://kubernetes.io/docs/reference/access-authn-authz/authentication/#x509-client-certs) -authentication strategy. [Bearer -tokens](https://kubernetes.io/docs/reference/access-authn-authz/authentication/#putting-a-bearer-token-in-a-request) -will be forwarded as-is. +authentication strategy.\ +The current `/proxy` Implementation expects a +[Bearer +token](https://kubernetes.io/docs/reference/access-authn-authz/authentication/#putting-a-bearer-token-in-a-request) +to be provided as a `Backstage-Kubernetes-Authorization` header for a target cluster. This token will be used as the `Authorization` header when forwarding a request to a target cluster. + +## How to disable the proxy endpoint via PermissionPolicy + +The kubernetes plugin can disable the use of the `proxy` endpoint by leveraging the permission framework. This integration allows admins to use well defined PermissionPolicies to restrict the use of the endpoint all together. The `proxy` endpoint can return 403 errors even if it has a valid ID token attached that a cluster would authorize thus allowing integrators the confidence that Backstage is not accessing kubernetes clusters on behalf of undesired parties. + +This feature assumes your backstage instance has enabled the [permissions framework](https://backstage.io/docs/permissions/getting-started) + +A sample policy like: + +[packages/backend/src/plugins/permissions.ts](https://github.com/backstage/backstage/blob/master/packages/backend/src/plugins/permission.ts) + +```typescript +import { BackstageIdentityResponse } from '@backstage/plugin-auth-node'; +import { + AuthorizeResult, + PolicyDecision, +} from '@backstage/plugin-permission-common'; +import { + PermissionPolicy, + PolicyQuery, +} from '@backstage/plugin-permission-node'; + +class KubernetesDenyAllProxyEndpointPolicy implements PermissionPolicy { + async handle( + request: PolicyQuery, + user?: BackstageIdentityResponse, + ): Promise { + if (request.permission.name === 'kubernetes.proxy') { + return { + result: AuthorizeResult.DENY, + }; + } + return { result: AuthorizeResult.ALLOW }; + } +} +``` + +would leverage the permission framework to return the following response: + +```json +{ + "error": { + "name": "NotAllowedError" + } +} +``` + +even if a valid ID token was attached that a cluster would authorize. ## Other known limitations