Merge commit from fork
Signed-off-by: Benjamin Lambert <benjdlambert@gmail.com> Signed-off-by: benjdlambert <ben@blam.sh>
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user