Merge commit from fork

Signed-off-by: Benjamin Lambert <benjdlambert@gmail.com>
Signed-off-by: benjdlambert <ben@blam.sh>
This commit is contained in:
Ben Lambert
2026-04-29 15:51:19 +02:00
committed by GitHub
parent 151eb3f8ff
commit fa06df607e
9 changed files with 334 additions and 3 deletions
@@ -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.
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-unprocessed-entities-common': patch
---
Added `unprocessedEntitiesReadPermission` for authorizing read access to unprocessed entity endpoints.
@@ -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"
}
}
@@ -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<PermissionEvaluator>;
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);
});
});
});
@@ -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',
@@ -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,
]);
@@ -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)
@@ -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,
};
+52
View File
@@ -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"