diff --git a/.changeset/silver-falcons-nail.md b/.changeset/silver-falcons-nail.md new file mode 100644 index 0000000000..970bbd7fee --- /dev/null +++ b/.changeset/silver-falcons-nail.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Add apply-conditions endpoint for evaluating conditional permissions in catalog backend. diff --git a/plugins/catalog-backend/api-report.md b/plugins/catalog-backend/api-report.md index 17c7ad1d85..8536dafe76 100644 --- a/plugins/catalog-backend/api-report.md +++ b/plugins/catalog-backend/api-report.md @@ -1309,6 +1309,7 @@ export class NextCatalogBuilder { addEntityPolicy(...policies: EntityPolicy[]): NextCatalogBuilder; // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen addEntityProvider(...providers: EntityProvider[]): NextCatalogBuilder; + addPermissionRules(...permissionRules: CatalogPermissionRule[]): void; // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen addProcessor(...processors: CatalogProcessor[]): NextCatalogBuilder; build(): Promise<{ @@ -1356,6 +1357,8 @@ export interface NextRouterOptions { // (undocumented) logger: Logger_2; // (undocumented) + permissionRules?: CatalogPermissionRule[]; + // (undocumented) refreshService?: RefreshService; } diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json index fe27ddb4c7..8472c33369 100644 --- a/plugins/catalog-backend/package.json +++ b/plugins/catalog-backend/package.json @@ -32,6 +32,7 @@ "dependencies": { "@backstage/backend-common": "^0.10.1", "@backstage/catalog-client": "^0.5.3", + "@backstage/plugin-catalog-common": "^0.1.0", "@backstage/catalog-model": "^0.9.8", "@backstage/config": "^0.1.11", "@backstage/errors": "^0.1.5", @@ -65,6 +66,7 @@ "devDependencies": { "@backstage/backend-test-utils": "^0.1.12", "@backstage/cli": "^0.10.4", + "@backstage/plugin-permission-common": "^0.3.0", "@backstage/test-utils": "^0.2.1", "@types/core-js": "^2.5.4", "@types/git-url-parse": "^9.0.0", diff --git a/plugins/catalog-backend/src/service/NextCatalogBuilder.ts b/plugins/catalog-backend/src/service/NextCatalogBuilder.ts index 1618ef8d7b..d0c01c3e71 100644 --- a/plugins/catalog-backend/src/service/NextCatalogBuilder.ts +++ b/plugins/catalog-backend/src/service/NextCatalogBuilder.ts @@ -82,6 +82,8 @@ import { Config } from '@backstage/config'; import { Logger } from 'winston'; import { LocationService } from './types'; import { connectEntityProviders } from '../processing/connectEntityProviders'; +import { CatalogPermissionRule } from '../permissions/types'; +import { permissionRules as catalogPermissionRules } from '../permissions/rules'; export type CatalogEnvironment = { logger: Logger; @@ -125,6 +127,7 @@ export class NextCatalogBuilder { maxSeconds: 150, }); private locationAnalyzer: LocationAnalyzer | undefined = undefined; + private permissionRules: CatalogPermissionRule[]; constructor(env: CatalogEnvironment) { this.env = env; @@ -136,6 +139,7 @@ export class NextCatalogBuilder { this.processors = []; this.processorsReplace = false; this.parser = undefined; + this.permissionRules = Object.values(catalogPermissionRules); } /** @@ -317,6 +321,17 @@ export class NextCatalogBuilder { return this; } + /** + * Adds additional permission rules. Permission rules are used to evaluate + * catalog resources against queries. See + * {@link @backstage/plugin-permission-node#PermissionRule}. + * + * @param permissionRules - Additional permission rules + */ + addPermissionRules(...permissionRules: CatalogPermissionRule[]) { + this.permissionRules.push(...permissionRules); + } + /** * Wires up and returns all of the component parts of the catalog */ @@ -393,6 +408,7 @@ export class NextCatalogBuilder { refreshService, logger, config, + permissionRules: this.permissionRules, }); await connectEntityProviders(processingDatabase, entityProviders); diff --git a/plugins/catalog-backend/src/service/NextRouter.test.ts b/plugins/catalog-backend/src/service/NextRouter.test.ts index 6bc4481425..c31de07718 100644 --- a/plugins/catalog-backend/src/service/NextRouter.test.ts +++ b/plugins/catalog-backend/src/service/NextRouter.test.ts @@ -24,6 +24,7 @@ import { EntitiesCatalog } from '../catalog'; import { LocationService, RefreshService } from './types'; import { basicEntityFilter } from './request'; import { createNextRouter } from './NextRouter'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; describe('createNextRouter readonly disabled', () => { let entitiesCatalog: jest.Mocked; @@ -51,6 +52,7 @@ describe('createNextRouter readonly disabled', () => { logger: getVoidLogger(), refreshService, config: new ConfigReader(undefined), + permissionRules: [], }); app = express().use(router); }); @@ -336,6 +338,7 @@ describe('createNextRouter readonly enabled', () => { readonly: true, }, }), + permissionRules: [], }); app = express().use(router); }); @@ -429,3 +432,72 @@ describe('createNextRouter readonly enabled', () => { }); }); }); + +describe('NextRouter permissioning', () => { + let entitiesCatalog: jest.Mocked; + let locationService: jest.Mocked; + let app: express.Express; + let refreshService: RefreshService; + + const fakeRule = { + name: 'FAKE_RULE', + description: 'fake rule', + apply: () => true, + toQuery: () => ({ key: '', values: [] }), + }; + + beforeAll(async () => { + entitiesCatalog = { + entities: jest.fn(), + removeEntityByUid: jest.fn(), + batchAddOrUpdateEntities: jest.fn(), + entityAncestry: jest.fn(), + }; + locationService = { + getLocation: jest.fn(), + createLocation: jest.fn(), + listLocations: jest.fn(), + deleteLocation: jest.fn(), + }; + refreshService = { refresh: jest.fn() }; + const router = await createNextRouter({ + entitiesCatalog, + locationService, + logger: getVoidLogger(), + refreshService, + config: new ConfigReader(undefined), + permissionRules: [fakeRule], + }); + app = express().use(router); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('accepts and evaluates conditions at the apply-conditions endpoint', async () => { + const spideySense: Entity = { + apiVersion: 'a', + kind: 'b', + metadata: { + name: 'spidey-sense', + }, + }; + entitiesCatalog.entities.mockResolvedValueOnce({ + entities: [spideySense], + pageInfo: { hasNextPage: false }, + }); + + const requestBody = { + resourceType: 'catalog-entity', + resourceRef: 'component:default/spidey-sense', + conditions: { rule: 'FAKE_RULE', params: ['user:default/spiderman'] }, + }; + const response = await request(app) + .post('/.well-known/backstage/permissions/apply-conditions') + .send(requestBody); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ result: AuthorizeResult.ALLOW }); + }); +}); diff --git a/plugins/catalog-backend/src/service/NextRouter.ts b/plugins/catalog-backend/src/service/NextRouter.ts index 6352f96c59..059f937dd7 100644 --- a/plugins/catalog-backend/src/service/NextRouter.ts +++ b/plugins/catalog-backend/src/service/NextRouter.ts @@ -17,17 +17,22 @@ import { errorHandler } from '@backstage/backend-common'; import { analyzeLocationSchema, + Entity, locationSpecSchema, + parseEntityRef, stringifyEntityRef, } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import { NotFoundError } from '@backstage/errors'; +import { RESOURCE_TYPE_CATALOG_ENTITY } from '@backstage/plugin-catalog-common'; +import { createPermissionIntegrationRouter } from '@backstage/plugin-permission-node'; import express from 'express'; import Router from 'express-promise-router'; import { Logger } from 'winston'; import yn from 'yn'; import { EntitiesCatalog } from '../catalog'; import { LocationAnalyzer } from '../ingestion/types'; +import { CatalogPermissionRule } from '../permissions/types'; import { basicEntityFilter, parseEntityFilterParams, @@ -44,6 +49,7 @@ export interface NextRouterOptions { refreshService?: RefreshService; logger: Logger; config: Config; + permissionRules?: CatalogPermissionRule[]; } export async function createNextRouter( @@ -56,6 +62,7 @@ export async function createNextRouter( refreshService, config, logger, + permissionRules, } = options; const router = Router(); @@ -77,6 +84,14 @@ export async function createNextRouter( if (entitiesCatalog) { router + .use( + createPermissionIntegrationRouter({ + resourceType: RESOURCE_TYPE_CATALOG_ENTITY, + getResource: resourceRef => + getEntityResource(resourceRef, entitiesCatalog), + rules: permissionRules ?? [], + }), + ) .get('/entities', async (req, res) => { const { entities, pageInfo } = await entitiesCatalog.entities({ filter: parseEntityFilterParams(req.query), @@ -182,3 +197,20 @@ export async function createNextRouter( router.use(errorHandler()); return router; } + +async function getEntityResource( + resourceRef: string, + entitiesCatalog: EntitiesCatalog, +): Promise { + const parsed = parseEntityRef(resourceRef); + + const { entities } = await entitiesCatalog.entities({ + filter: basicEntityFilter({ + kind: parsed.kind, + 'metadata.namespace': parsed.namespace, + 'metadata.name': parsed.name, + }), + }); + + return entities[0]; +}