diff --git a/.changeset/real-foxes-film.md b/.changeset/real-foxes-film.md new file mode 100644 index 0000000000..757146d624 --- /dev/null +++ b/.changeset/real-foxes-film.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-kubernetes-backend': minor +'@backstage/plugin-kubernetes-common': minor +--- + +Introduced resource permission type to be used with the kubernetes endpoint's permission framework integration for all endpoints except the proxy endpoints. diff --git a/plugins/kubernetes-backend/src/auth/requirePermission.ts b/plugins/kubernetes-backend/src/auth/requirePermission.ts new file mode 100644 index 0000000000..c95cd12c72 --- /dev/null +++ b/plugins/kubernetes-backend/src/auth/requirePermission.ts @@ -0,0 +1,49 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { + HttpAuthService, + PermissionsService, +} from '@backstage/backend-plugin-api'; +import { NotAllowedError } from '@backstage/errors'; +import { kubernetesResourcePermission } from '@backstage/plugin-kubernetes-common'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; + +import express from 'express'; + +export async function requirePermission( + permissionApi: PermissionsService, + httpAuth: HttpAuthService, + req: express.Request, +) { + const decision = ( + await permissionApi.authorize( + [ + { + permission: kubernetesResourcePermission, + }, + ], + { + credentials: await httpAuth.credentials(req), + }, + ) + )[0]; + + if (decision.result === AuthorizeResult.DENY) { + const err = new NotAllowedError('Unauthorized'); + throw err; + } +} diff --git a/plugins/kubernetes-backend/src/routes/resourceRoutes.test.ts b/plugins/kubernetes-backend/src/routes/resourceRoutes.test.ts index 7ace17e805..462312a404 100644 --- a/plugins/kubernetes-backend/src/routes/resourceRoutes.test.ts +++ b/plugins/kubernetes-backend/src/routes/resourceRoutes.test.ts @@ -18,17 +18,48 @@ import request from 'supertest'; import { mockCredentials, mockServices, + type ServiceMock, startTestBackend, } from '@backstage/backend-test-utils'; import { kubernetesObjectsProviderExtensionPoint } from '@backstage/plugin-kubernetes-node'; -import { createBackendModule } from '@backstage/backend-plugin-api'; +import { + createBackendModule, + type PermissionsService, +} from '@backstage/backend-plugin-api'; import { Entity } from '@backstage/catalog-model'; import { ExtendedHttpServer } from '@backstage/backend-defaults/rootHttpRouter'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; describe('resourcesRoutes', () => { let app: ExtendedHttpServer; + const permissionsMock: ServiceMock = + mockServices.permissions.mock({ + authorize: jest.fn(), + authorizeConditional: jest.fn(), + }); - beforeAll(async () => { + const startPermissionDeniedTestServer = async () => { + permissionsMock.authorize.mockResolvedValue([ + { result: AuthorizeResult.DENY }, + ]); + const { server } = await startTestBackend({ + features: [ + mockServices.rootConfig.factory({ + data: { + kubernetes: { + serviceLocatorMethod: { type: 'multiTenant' }, + clusterLocatorMethods: [], + }, + }, + }), + permissionsMock.factory, + import('@backstage/plugin-kubernetes-backend'), + ], + }); + return server; + }; + + beforeEach(async () => { const objectsProviderMock = { getKubernetesObjectsByEntity: jest.fn().mockImplementation(args => { if (args.entity.metadata.name === 'inject500') { @@ -109,6 +140,8 @@ describe('resourcesRoutes', () => { }, }), import('@backstage/plugin-kubernetes-backend'), + import('@backstage/plugin-permission-backend'), + import('@backstage/plugin-permission-backend-module-allow-all-policy'), createBackendModule({ pluginId: 'kubernetes', moduleId: 'test-objects-provider', @@ -127,6 +160,10 @@ describe('resourcesRoutes', () => { app = server; }); + afterEach(() => { + app.stop(); + }); + describe('POST /resources/workloads/query', () => { // eslint-disable-next-line jest/expect-expect it('200 happy path', async () => { @@ -269,6 +306,13 @@ describe('resourcesRoutes', () => { response: { statusCode: 401 }, }); }); + it('403 when permission blocks endpoint', async () => { + app = await startPermissionDeniedTestServer(); + const response = await request(app).post( + '/api/kubernetes/resources/workloads/query', + ); + expect(response.status).toEqual(403); + }); // eslint-disable-next-line jest/expect-expect it('500 handle gracefully', async () => { await request(app) @@ -548,6 +592,13 @@ describe('resourcesRoutes', () => { response: { statusCode: 401 }, }); }); + it('403 when permission blocks endpoint', async () => { + app = await startPermissionDeniedTestServer(); + const response = await request(app).post( + '/api/kubernetes/resources/custom/query', + ); + expect(response.status).toEqual(403); + }); // eslint-disable-next-line jest/expect-expect it('500 handle gracefully', async () => { await request(app) diff --git a/plugins/kubernetes-backend/src/routes/resourcesRoutes.ts b/plugins/kubernetes-backend/src/routes/resourcesRoutes.ts index 838e039d27..091080bdcc 100644 --- a/plugins/kubernetes-backend/src/routes/resourcesRoutes.ts +++ b/plugins/kubernetes-backend/src/routes/resourcesRoutes.ts @@ -23,6 +23,8 @@ import { InputError } from '@backstage/errors'; import express, { Request } from 'express'; import { KubernetesObjectsProvider } from '@backstage/plugin-kubernetes-node'; import { AuthService, HttpAuthService } from '@backstage/backend-plugin-api'; +import { PermissionEvaluator } from '@backstage/plugin-permission-common'; +import { requirePermission } from '../auth/requirePermission'; export const addResourceRoutesToRouter = ( router: express.Router, @@ -30,6 +32,7 @@ export const addResourceRoutesToRouter = ( objectsProvider: KubernetesObjectsProvider, auth: AuthService, httpAuth: HttpAuthService, + permissionApi: PermissionEvaluator, ) => { const getEntityByReq = async (req: Request) => { const rawEntityRef = req.body.entityRef; @@ -62,6 +65,7 @@ export const addResourceRoutesToRouter = ( }; router.post('/resources/workloads/query', async (req, res) => { + await requirePermission(permissionApi, httpAuth, req); const entity = await getEntityByReq(req); const response = await objectsProvider.getKubernetesObjectsByEntity( { @@ -74,6 +78,7 @@ export const addResourceRoutesToRouter = ( }); router.post('/resources/custom/query', async (req, res) => { + await requirePermission(permissionApi, httpAuth, req); const entity = await getEntityByReq(req); if (!req.body.customResources) { diff --git a/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts b/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts index b38bf9121a..8669299d2e 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts @@ -92,6 +92,19 @@ describe('API integration tests', () => { }); }, }); + const startPermissionDeniedTestServer = async () => { + permissionsMock.authorize.mockResolvedValue([ + { result: AuthorizeResult.DENY }, + ]); + const { server } = await startTestBackend({ + features: [ + minimalValidConfigService, + permissionsMock.factory, + import('@backstage/plugin-kubernetes-backend'), + ], + }); + return server; + }; beforeEach(async () => { jest.resetAllMocks(); @@ -305,6 +318,12 @@ describe('API integration tests', () => { items: [expect.objectContaining({ title: 'cluster-title' })], }); }); + + it('returns 403 response when permission blocks endpoint', async () => { + app = await startPermissionDeniedTestServer(); + const response = await request(app).get('/api/kubernetes/clusters'); + expect(response.status).toEqual(403); + }); }); describe('post /services/:serviceId', () => { @@ -504,6 +523,14 @@ describe('API integration tests', () => { }), ); }); + + it('returns 403 response when permission blocks endpoint', async () => { + app = await startPermissionDeniedTestServer(); + const response = await request(app).post( + '/api/kubernetes/services/test-service', + ); + expect(response.status).toEqual(403); + }); }); describe('/proxy', () => { @@ -571,18 +598,7 @@ metadata: }); it('returns 403 response when permission blocks endpoint', async () => { - permissionsMock.authorize.mockResolvedValue([ - { result: AuthorizeResult.DENY }, - ]); - - const { server } = await startTestBackend({ - features: [ - minimalValidConfigService, - permissionsMock.factory, - import('@backstage/plugin-kubernetes-backend'), - ], - }); - app = server; + app = await startPermissionDeniedTestServer(); const proxyEndpointRequest = request(app) .post('/api/kubernetes/proxy/api/v1/namespaces') @@ -779,6 +795,7 @@ metadata: expect(response.body).toMatchObject({ permissions: [ { type: 'basic', name: 'kubernetes.proxy', attributes: {} }, + { type: 'basic', name: 'kubernetes.resource', attributes: {} }, ], rules: [], }); diff --git a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts index 6a4e9cc296..ea5c725d8f 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts @@ -73,6 +73,7 @@ import { } from './KubernetesFanOutHandler'; import { KubernetesClientBasedFetcher } from './KubernetesFetcher'; import { KubernetesProxy } from './KubernetesProxy'; +import { requirePermission } from '../auth/requirePermission'; /** * @deprecated Please migrate to the new backend system as this will be removed in the future. @@ -393,6 +394,7 @@ export class KubernetesBuilder { ); // @deprecated router.post('/services/:serviceId', async (req, res) => { + await requirePermission(permissionApi, httpAuth, req); const serviceId = req.params.serviceId; const requestBody: ObjectsByEntityRequest = req.body; try { @@ -413,6 +415,7 @@ export class KubernetesBuilder { }); router.get('/clusters', async (req, res) => { + await requirePermission(permissionApi, httpAuth, req); const credentials = await httpAuth.credentials(req); const clusterDetails = await this.fetchClusterDetails(clusterSupplier, { credentials, @@ -447,6 +450,7 @@ export class KubernetesBuilder { objectsProvider, authService, httpAuth, + permissionApi, ); return router; diff --git a/plugins/kubernetes-common/report.api.md b/plugins/kubernetes-common/report.api.md index 8cdac7ed45..b40ae89c82 100644 --- a/plugins/kubernetes-common/report.api.md +++ b/plugins/kubernetes-common/report.api.md @@ -347,6 +347,9 @@ export interface KubernetesRequestBody { entity: Entity; } +// @public +export const kubernetesResourcePermission: BasicPermission; + // @public (undocumented) export interface LimitRangeFetchResponse { // (undocumented) diff --git a/plugins/kubernetes-common/src/index.ts b/plugins/kubernetes-common/src/index.ts index faaaaab92c..76a4c0b7e7 100644 --- a/plugins/kubernetes-common/src/index.ts +++ b/plugins/kubernetes-common/src/index.ts @@ -25,6 +25,7 @@ export * from './catalog-entity-constants'; export * from './certificate-authority-constants'; export { kubernetesProxyPermission, + kubernetesResourcePermission, kubernetesPermissions, } from './permissions'; export * from './error-detection'; diff --git a/plugins/kubernetes-common/src/permissions.ts b/plugins/kubernetes-common/src/permissions.ts index d3ccbe8171..928b5c7d06 100644 --- a/plugins/kubernetes-common/src/permissions.ts +++ b/plugins/kubernetes-common/src/permissions.ts @@ -24,8 +24,19 @@ export const kubernetesProxyPermission = createPermission({ attributes: {}, }); +/** This permission is used to check access to the resources endpoints + * @public + */ +export const kubernetesResourcePermission = createPermission({ + name: 'kubernetes.resource', + attributes: {}, +}); + /** * List of all Kubernetes permissions. * @public */ -export const kubernetesPermissions = [kubernetesProxyPermission]; +export const kubernetesPermissions = [ + kubernetesProxyPermission, + kubernetesResourcePermission, +];