From fa06df607e162e037839f73a506273ae51fdb5c7 Mon Sep 17 00:00:00 2001 From: Ben Lambert Date: Wed, 29 Apr 2026 15:51:19 +0200 Subject: [PATCH] Merge commit from fork Signed-off-by: Benjamin Lambert Signed-off-by: benjdlambert --- .../unprocessed-entities-read-auth-check.md | 5 + .../unprocessed-entities-read-permission.md | 5 + .../package.json | 8 +- .../src/UnprocessedEntitiesModule.test.ts | 225 ++++++++++++++++++ .../src/UnprocessedEntitiesModule.ts | 21 +- .../src/module.ts | 6 +- .../report.api.md | 4 + .../src/permissions.ts | 11 + yarn.lock | 52 ++++ 9 files changed, 334 insertions(+), 3 deletions(-) create mode 100644 .changeset/unprocessed-entities-read-auth-check.md create mode 100644 .changeset/unprocessed-entities-read-permission.md create mode 100644 plugins/catalog-backend-module-unprocessed/src/UnprocessedEntitiesModule.test.ts diff --git a/.changeset/unprocessed-entities-read-auth-check.md b/.changeset/unprocessed-entities-read-auth-check.md new file mode 100644 index 0000000000..1ca21079f2 --- /dev/null +++ b/.changeset/unprocessed-entities-read-auth-check.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend-module-unprocessed': patch +--- + +Added permission authorization checks to the unprocessed entities read endpoints for pending and failed entities. diff --git a/.changeset/unprocessed-entities-read-permission.md b/.changeset/unprocessed-entities-read-permission.md new file mode 100644 index 0000000000..d1470586ef --- /dev/null +++ b/.changeset/unprocessed-entities-read-permission.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-unprocessed-entities-common': patch +--- + +Added `unprocessedEntitiesReadPermission` for authorizing read access to unprocessed entity endpoints. diff --git a/plugins/catalog-backend-module-unprocessed/package.json b/plugins/catalog-backend-module-unprocessed/package.json index 9aca307ffb..26b15151da 100644 --- a/plugins/catalog-backend-module-unprocessed/package.json +++ b/plugins/catalog-backend-module-unprocessed/package.json @@ -40,11 +40,17 @@ "@backstage/plugin-catalog-node": "workspace:^", "@backstage/plugin-catalog-unprocessed-entities-common": "workspace:^", "@backstage/plugin-permission-common": "workspace:^", + "@types/express": "^4.17.6", "express-promise-router": "^4.1.1", "knex": "^3.0.0" }, "devDependencies": { + "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", - "@types/express": "^4.17.6" + "@types/express": "^4.17.6", + "@types/supertest": "^6.0.0", + "better-sqlite3": "^11.0.0", + "express": "^4.22.0", + "supertest": "^7.0.0" } } diff --git a/plugins/catalog-backend-module-unprocessed/src/UnprocessedEntitiesModule.test.ts b/plugins/catalog-backend-module-unprocessed/src/UnprocessedEntitiesModule.test.ts new file mode 100644 index 0000000000..5cfd2a8761 --- /dev/null +++ b/plugins/catalog-backend-module-unprocessed/src/UnprocessedEntitiesModule.test.ts @@ -0,0 +1,225 @@ +/* + * 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 express from 'express'; +import request from 'supertest'; +import knex, { Knex } from 'knex'; +import { UnprocessedEntitiesModule } from './UnprocessedEntitiesModule'; +import { + AuthorizeResult, + type PermissionEvaluator, +} from '@backstage/plugin-permission-common'; +import { mockCredentials, mockServices } from '@backstage/backend-test-utils'; + +describe('UnprocessedEntitiesModule', () => { + let db: Knex; + let app: express.Express; + let mockPermissions: jest.Mocked; + + beforeEach(async () => { + db = knex({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + + await db.schema.createTable('refresh_state', table => { + table.string('entity_id').primary(); + table.string('entity_ref'); + table.text('unprocessed_entity'); + table.text('unprocessed_hash').nullable(); + table.text('processed_entity').nullable(); + table.text('result_hash').nullable(); + table.text('cache').nullable(); + table.text('errors').nullable(); + table.text('location_key').nullable(); + table.string('next_update_at'); + table.string('last_discovery_at'); + }); + + await db.schema.createTable('final_entities', table => { + table.string('entity_id').primary(); + table.text('final_entity').nullable(); + }); + + const now = new Date().toISOString(); + + await db('refresh_state').insert({ + entity_id: 'pending-entity', + entity_ref: 'component:default/pending', + unprocessed_entity: JSON.stringify({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { name: 'pending' }, + spec: { owner: 'group:default/team-a', type: 'service' }, + }), + next_update_at: now, + last_discovery_at: now, + }); + + await db('refresh_state').insert({ + entity_id: 'failed-entity', + entity_ref: 'component:default/failed', + unprocessed_entity: JSON.stringify({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { name: 'failed' }, + spec: { owner: 'group:default/team-a', type: 'service' }, + }), + errors: JSON.stringify([{ message: 'something broke' }]), + next_update_at: now, + last_discovery_at: now, + }); + + await db('final_entities').insert({ + entity_id: 'failed-entity', + final_entity: null, + }); + + mockPermissions = { + authorize: jest.fn(), + authorizeConditional: jest.fn(), + }; + + app = express(); + const router = express.Router(); + app.use(router); + + const module = UnprocessedEntitiesModule.create({ + database: db, + router: { use: handler => router.use(handler) }, + permissions: mockPermissions, + httpAuth: mockServices.httpAuth(), + }); + module.registerRoutes(); + + app.use( + ( + err: Error & { statusCode?: number }, + _req: express.Request, + res: express.Response, + _next: express.NextFunction, + ) => { + const status = + err.statusCode ?? (err.name === 'NotAllowedError' ? 403 : 500); + res.status(status).json({ error: { name: err.name } }); + }, + ); + }); + + afterEach(async () => { + await db.destroy(); + }); + + describe('GET /entities/unprocessed/pending', () => { + it('returns pending entities when authorized', async () => { + mockPermissions.authorize.mockResolvedValue([ + { result: AuthorizeResult.ALLOW }, + ]); + + const response = await request(app) + .get('/entities/unprocessed/pending') + .auth(mockCredentials.user.token(), { type: 'bearer' }); + + expect(response.status).toBe(200); + expect(response.body.type).toBe('pending'); + expect(response.body.entities).toHaveLength(1); + expect(response.body.entities[0].entity_ref).toBe( + 'component:default/pending', + ); + expect(mockPermissions.authorize).toHaveBeenCalledTimes(1); + }); + + it('returns 403 when not authorized', async () => { + mockPermissions.authorize.mockResolvedValue([ + { result: AuthorizeResult.DENY }, + ]); + + const response = await request(app) + .get('/entities/unprocessed/pending') + .auth(mockCredentials.user.token(), { type: 'bearer' }); + + expect(response.status).toBe(403); + expect(mockPermissions.authorize).toHaveBeenCalledTimes(1); + }); + }); + + describe('GET /entities/unprocessed/failed', () => { + it('returns failed entities when authorized', async () => { + mockPermissions.authorize.mockResolvedValue([ + { result: AuthorizeResult.ALLOW }, + ]); + + const response = await request(app) + .get('/entities/unprocessed/failed') + .auth(mockCredentials.user.token(), { type: 'bearer' }); + + expect(response.status).toBe(200); + expect(response.body.type).toBe('failed'); + expect(response.body.entities).toHaveLength(1); + expect(response.body.entities[0].entity_ref).toBe( + 'component:default/failed', + ); + expect(mockPermissions.authorize).toHaveBeenCalledTimes(1); + }); + + it('returns 403 when not authorized', async () => { + mockPermissions.authorize.mockResolvedValue([ + { result: AuthorizeResult.DENY }, + ]); + + const response = await request(app) + .get('/entities/unprocessed/failed') + .auth(mockCredentials.user.token(), { type: 'bearer' }); + + expect(response.status).toBe(403); + expect(mockPermissions.authorize).toHaveBeenCalledTimes(1); + }); + }); + + describe('DELETE /entities/unprocessed/delete/:entity_id', () => { + it('deletes entity when authorized', async () => { + mockPermissions.authorize.mockResolvedValue([ + { result: AuthorizeResult.ALLOW }, + ]); + + const response = await request(app) + .delete('/entities/unprocessed/delete/failed-entity') + .auth(mockCredentials.user.token(), { type: 'bearer' }); + + expect(response.status).toBe(204); + expect(mockPermissions.authorize).toHaveBeenCalledTimes(1); + + const remaining = await db('refresh_state') + .where({ entity_id: 'failed-entity' }) + .select(); + expect(remaining).toHaveLength(0); + }); + + it('returns 403 when not authorized', async () => { + mockPermissions.authorize.mockResolvedValue([ + { result: AuthorizeResult.DENY }, + ]); + + const response = await request(app) + .delete('/entities/unprocessed/delete/failed-entity') + .auth(mockCredentials.user.token(), { type: 'bearer' }); + + expect(response.status).toBe(403); + expect(mockPermissions.authorize).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/plugins/catalog-backend-module-unprocessed/src/UnprocessedEntitiesModule.ts b/plugins/catalog-backend-module-unprocessed/src/UnprocessedEntitiesModule.ts index 91ecd45bf5..62310aa14e 100644 --- a/plugins/catalog-backend-module-unprocessed/src/UnprocessedEntitiesModule.ts +++ b/plugins/catalog-backend-module-unprocessed/src/UnprocessedEntitiesModule.ts @@ -33,7 +33,10 @@ import { AuthorizeResult, BasicPermission, } from '@backstage/plugin-permission-common'; -import { unprocessedEntitiesDeletePermission } from '@backstage/plugin-catalog-unprocessed-entities-common'; +import { + unprocessedEntitiesDeletePermission, + unprocessedEntitiesReadPermission, +} from '@backstage/plugin-catalog-unprocessed-entities-common'; import { NotAllowedError } from '@backstage/errors'; /** @@ -158,6 +161,14 @@ export class UnprocessedEntitiesModule { this.moduleRouter .get('/entities/unprocessed/failed', async (req, res) => { + const authorized = await isRequestAuthorized( + req, + unprocessedEntitiesReadPermission, + ); + if (!authorized) { + throw new NotAllowedError('Unauthorized'); + } + return res.json( await this.unprocessed({ reason: 'failed', @@ -167,6 +178,14 @@ export class UnprocessedEntitiesModule { ); }) .get('/entities/unprocessed/pending', async (req, res) => { + const authorized = await isRequestAuthorized( + req, + unprocessedEntitiesReadPermission, + ); + if (!authorized) { + throw new NotAllowedError('Unauthorized'); + } + return res.json( await this.unprocessed({ reason: 'pending', diff --git a/plugins/catalog-backend-module-unprocessed/src/module.ts b/plugins/catalog-backend-module-unprocessed/src/module.ts index 1b46cb4448..1d5697f529 100644 --- a/plugins/catalog-backend-module-unprocessed/src/module.ts +++ b/plugins/catalog-backend-module-unprocessed/src/module.ts @@ -19,7 +19,10 @@ import { createBackendModule, } from '@backstage/backend-plugin-api'; import { UnprocessedEntitiesModule } from './UnprocessedEntitiesModule'; -import { unprocessedEntitiesDeletePermission } from '@backstage/plugin-catalog-unprocessed-entities-common'; +import { + unprocessedEntitiesDeletePermission, + unprocessedEntitiesReadPermission, +} from '@backstage/plugin-catalog-unprocessed-entities-common'; /** * Catalog Module for Unprocessed Entities @@ -55,6 +58,7 @@ export const catalogModuleUnprocessedEntities = createBackendModule({ }); permissionsRegistry.addPermissions([ + unprocessedEntitiesReadPermission, unprocessedEntitiesDeletePermission, ]); diff --git a/plugins/catalog-unprocessed-entities-common/report.api.md b/plugins/catalog-unprocessed-entities-common/report.api.md index e373a3f45b..580e402931 100644 --- a/plugins/catalog-unprocessed-entities-common/report.api.md +++ b/plugins/catalog-unprocessed-entities-common/report.api.md @@ -57,9 +57,13 @@ export const unprocessedEntitiesDeletePermission: BasicPermission; // @public export const unprocessedEntitiesPermissions: { + unprocessedEntitiesReadPermission: BasicPermission; unprocessedEntitiesDeletePermission: BasicPermission; }; +// @public +export const unprocessedEntitiesReadPermission: BasicPermission; + // @public export interface UnprocessedEntitiesRequestOptions { // (undocumented) diff --git a/plugins/catalog-unprocessed-entities-common/src/permissions.ts b/plugins/catalog-unprocessed-entities-common/src/permissions.ts index d34cc0d1dc..4c139e9284 100644 --- a/plugins/catalog-unprocessed-entities-common/src/permissions.ts +++ b/plugins/catalog-unprocessed-entities-common/src/permissions.ts @@ -15,6 +15,16 @@ */ import { createPermission } from '@backstage/plugin-permission-common'; +/** + * This permission is used to designate actions that involve reading + * unprocessed entity records from the refresh_state table. + * @public + */ +export const unprocessedEntitiesReadPermission = createPermission({ + name: 'catalog.entities.unprocessed.read', + attributes: { action: 'read' }, +}); + /** * This permission is used to designate actions that involve removing an * unprocessed entity record from the refresh_state table. @@ -30,5 +40,6 @@ export const unprocessedEntitiesDeletePermission = createPermission({ * @public */ export const unprocessedEntitiesPermissions = { + unprocessedEntitiesReadPermission, unprocessedEntitiesDeletePermission, }; diff --git a/yarn.lock b/yarn.lock index 3624e30c09..a6a6fdc273 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5132,6 +5132,7 @@ __metadata: resolution: "@backstage/plugin-catalog-backend-module-unprocessed@workspace:plugins/catalog-backend-module-unprocessed" dependencies: "@backstage/backend-plugin-api": "workspace:^" + "@backstage/backend-test-utils": "workspace:^" "@backstage/catalog-model": "workspace:^" "@backstage/cli": "workspace:^" "@backstage/errors": "workspace:^" @@ -5140,8 +5141,12 @@ __metadata: "@backstage/plugin-catalog-unprocessed-entities-common": "workspace:^" "@backstage/plugin-permission-common": "workspace:^" "@types/express": "npm:^4.17.6" + "@types/supertest": "npm:^6.0.0" + better-sqlite3: "npm:^11.0.0" + express: "npm:^4.22.0" express-promise-router: "npm:^4.1.1" knex: "npm:^3.0.0" + supertest: "npm:^7.0.0" languageName: unknown linkType: soft @@ -19288,6 +19293,13 @@ __metadata: languageName: node linkType: hard +"@types/cookiejar@npm:^2.1.5": + version: 2.1.5 + resolution: "@types/cookiejar@npm:2.1.5" + checksum: 10/04d5990e87b6387532d15a87d9ec9b2eb783039291193863751dcfd7fc723a3b3aa30ce4c06b03975cba58632e933772f1ff031af23eaa3ac7f94e71afa6e073 + languageName: node + linkType: hard + "@types/core-js@npm:^2.5.4": version: 2.5.8 resolution: "@types/core-js@npm:2.5.8" @@ -19919,6 +19931,13 @@ __metadata: languageName: node linkType: hard +"@types/methods@npm:^1.1.4": + version: 1.1.4 + resolution: "@types/methods@npm:1.1.4" + checksum: 10/ad2a7178486f2fd167750f3eb920ab032a947ff2e26f55c86670a6038632d790b46f52e5b6ead5823f1e53fc68028f1e9ddd15cfead7903e04517c88debd72b1 + languageName: node + linkType: hard + "@types/mime-types@npm:^2.1.0": version: 2.1.4 resolution: "@types/mime-types@npm:2.1.4" @@ -20641,6 +20660,18 @@ __metadata: languageName: node linkType: hard +"@types/superagent@npm:^8.1.0": + version: 8.1.9 + resolution: "@types/superagent@npm:8.1.9" + dependencies: + "@types/cookiejar": "npm:^2.1.5" + "@types/methods": "npm:^1.1.4" + "@types/node": "npm:*" + form-data: "npm:^4.0.0" + checksum: 10/6d9687b0bc3d693b900ef76000b02437a70879c3219b28606879c086d786bb1e48429813e72e32dd0aafc94c053a78a2aa8be67c45bc8e6b968ca62d6d5cc554 + languageName: node + linkType: hard + "@types/supertest@npm:^2.0.8": version: 2.0.16 resolution: "@types/supertest@npm:2.0.16" @@ -20650,6 +20681,16 @@ __metadata: languageName: node linkType: hard +"@types/supertest@npm:^6.0.0": + version: 6.0.3 + resolution: "@types/supertest@npm:6.0.3" + dependencies: + "@types/methods": "npm:^1.1.4" + "@types/superagent": "npm:^8.1.0" + checksum: 10/6ec05eb591c97bc856b0e78c12f5bec10545f3a749688f34232d189797a506d971bc95931718eb57b378d8513f6d2d12462383e6d68455fa72df35c19de6e89e + languageName: node + linkType: hard + "@types/swagger-ui-react@npm:^5.0.0": version: 5.18.0 resolution: "@types/swagger-ui-react@npm:5.18.0" @@ -23880,6 +23921,17 @@ __metadata: languageName: node linkType: hard +"better-sqlite3@npm:^11.0.0": + version: 11.10.0 + resolution: "better-sqlite3@npm:11.10.0" + dependencies: + bindings: "npm:^1.5.0" + node-gyp: "npm:latest" + prebuild-install: "npm:^7.1.1" + checksum: 10/5e4c7437c4fe6033335a79c82974d7ab29f33c51c36f48b73e87e087d21578468575de1c56a7badd4f76f17255e25abefddaeacf018e5eeb9e0cb8d6e3e4a5e1 + languageName: node + linkType: hard + "better-sqlite3@npm:^12.0.0": version: 12.8.0 resolution: "better-sqlite3@npm:12.8.0"