diff --git a/.changeset/afraid-kids-jog.md b/.changeset/afraid-kids-jog.md new file mode 100644 index 0000000000..39e4e5c5a1 --- /dev/null +++ b/.changeset/afraid-kids-jog.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-permission-node': patch +--- + +The returned router from `createPermissionIntegrationRouter` is now mutable, allowing for permissions and resources to be added after creation of the router. diff --git a/plugins/permission-node/report.api.md b/plugins/permission-node/report.api.md index 83730c6050..748ed58f3b 100644 --- a/plugins/permission-node/report.api.md +++ b/plugins/permission-node/report.api.md @@ -124,7 +124,7 @@ export function createPermissionIntegrationRouter< TResourceType3 extends string, TResource3, >( - options: + options?: | { permissions: Array; } @@ -140,7 +140,15 @@ export function createPermissionIntegrationRouter< TResourceType3, TResource3 >, -): express.Router; +): express.Router & { + addPermissions(permissions: Permission[]): void; + addResourceType( + resource: CreatePermissionIntegrationRouterResourceOptions< + TResourceType, + TResource + >, + ): void; +}; // @public export type CreatePermissionIntegrationRouterResourceOptions< diff --git a/plugins/permission-node/src/integration/createPermissionIntegrationRouter.test.ts b/plugins/permission-node/src/integration/createPermissionIntegrationRouter.test.ts index 0062ef03b6..0bce007c89 100644 --- a/plugins/permission-node/src/integration/createPermissionIntegrationRouter.test.ts +++ b/plugins/permission-node/src/integration/createPermissionIntegrationRouter.test.ts @@ -981,6 +981,94 @@ describe('createPermissionIntegrationRouter', () => { ], }); }); + + it('returns a list of basic permissions together with permissions and rules from multiple resource types with mutation', async () => { + const aPermission = createPermission({ + name: 'a.permission', + attributes: {}, + }); + + const router = createPermissionIntegrationRouter(); + + const responseBefore = await request(express().use(router)).get( + '/.well-known/backstage/permissions/metadata', + ); + + expect(responseBefore.status).toEqual(200); + expect(responseBefore.body).toEqual({ + permissions: [], + rules: [], + }); + + router.addPermissions([aPermission, testPermission]); + + router.addResourceType({ + resourceType: 'test-resource', + permissions: [testPermission], + getResources: defaultMockedGetResources1, + rules: [testRule1, testRule2], + }); + + router.addResourceType({ + resourceType: 'test-resource-2', + permissions: [testPermission2], + getResources: defaultMockedGetResources2, + rules: [testRule3], + }); + + const responseAfter = await request(express().use(router)).get( + '/.well-known/backstage/permissions/metadata', + ); + + expect(responseAfter.status).toEqual(200); + expect(responseAfter.body).toEqual({ + permissions: [aPermission, testPermission, testPermission2], + rules: [ + { + name: testRule1.name, + description: testRule1.description, + resourceType: testRule1.resourceType, + paramsSchema: { + $schema: 'http://json-schema.org/draft-07/schema#', + additionalProperties: false, + properties: { + foo: { + type: 'string', + }, + bar: { + description: 'bar', + type: 'number', + }, + }, + required: ['foo', 'bar'], + type: 'object', + }, + }, + { + name: testRule2.name, + description: testRule2.description, + resourceType: testRule2.resourceType, + paramsSchema: { + $schema: 'http://json-schema.org/draft-07/schema#', + additionalProperties: false, + properties: {}, + type: 'object', + }, + }, + { + name: testRule3.name, + description: testRule3.description, + resourceType: testRule3.resourceType, + paramsSchema: { + $schema: 'http://json-schema.org/draft-07/schema#', + additionalProperties: false, + properties: {}, + type: 'object', + }, + }, + ], + }); + }); }); describe('createConditionAuthorizer', () => { diff --git a/plugins/permission-node/src/integration/createPermissionIntegrationRouter.ts b/plugins/permission-node/src/integration/createPermissionIntegrationRouter.ts index de1e32169a..9ef5b564d8 100644 --- a/plugins/permission-node/src/integration/createPermissionIntegrationRouter.ts +++ b/plugins/permission-node/src/integration/createPermissionIntegrationRouter.ts @@ -250,6 +250,102 @@ export type PermissionIntegrationRouterOptions< >; }; +class PermissionIntegrationMetadataStore { + readonly #rulesByTypeByName = new Map< + string, + Map> + >(); + readonly #permissionsByName = new Map(); + readonly #resourcesByType = new Map< + string, + CreatePermissionIntegrationRouterResourceOptions + >(); + readonly #serializedRules = new Array(); + + getSerializedMetadata(): MetadataResponse { + return { + permissions: Array.from(this.#permissionsByName.values()), + rules: this.#serializedRules, + }; + } + + hasResourceType(type: string): boolean { + return this.#resourcesByType.has(type); + } + + async getResources( + resourceType: string, + refs: string[], + ): Promise> { + const resource = this.#resourcesByType.get(resourceType); + if (!resource?.getResources) { + throw new NotImplementedError( + `This plugin does not expose any permission rule or can't evaluate the conditions request for ${resourceType}`, + ); + } + + const uniqueRefs = Array.from(new Set(refs)); + const resources = await resource.getResources(uniqueRefs); + return Object.fromEntries( + uniqueRefs.map((ref, index) => [ref, resources[index]]), + ); + } + + getRuleMapper(resourceType: string) { + return (name: string): PermissionRule => { + const rule = this.#rulesByTypeByName.get(resourceType)?.get(name); + if (!rule) { + throw new Error(`Unexpected permission rule: ${name}`); + } + return rule; + }; + } + + addPermissions(permissions: Permission[]) { + for (const permission of permissions) { + // Permission naming conflicts are silently ignored + this.#permissionsByName.set(permission.name, permission); + } + } + + addResourceType( + resource: CreatePermissionIntegrationRouterResourceOptions, + ) { + const { resourceType } = resource; + + if (this.#resourcesByType.has(resourceType)) { + throw new Error( + `Refused to add permission resource with type '${resourceType}' because it already exists`, + ); + } + this.#resourcesByType.set(resourceType, resource); + + for (const rule of resource.rules) { + const rulesByName = + this.#rulesByTypeByName.get(resourceType) ?? new Map(); + this.#rulesByTypeByName.set(resourceType, rulesByName); + + if (rulesByName.has(rule.name)) { + throw new Error( + `Refused to add permission rule for type '${resourceType}' with name '${rule.name}' because it already exists`, + ); + } + rulesByName.set(rule.name, rule); + + this.#serializedRules.push({ + name: rule.name, + description: rule.description, + resourceType: rule.resourceType, + paramsSchema: zodToJsonSchema(rule.paramsSchema ?? z.object({})), + }); + } + + if (resource.permissions) { + this.addPermissions(resource.permissions); + } + } +} + /** * Create an express Router which provides an authorization route to allow * integration between the permission backend and other Backstage backend @@ -299,7 +395,7 @@ export function createPermissionIntegrationRouter< TResourceType3 extends string, TResource3, >( - options: + options?: | { permissions: Array } | CreatePermissionIntegrationRouterResourceOptions< TResourceType1, @@ -313,167 +409,80 @@ export function createPermissionIntegrationRouter< TResourceType3, TResource3 >, -): express.Router { - const optionsWithResources = options as PermissionIntegrationRouterOptions; - const allOptions = [ - optionsWithResources.resources ? optionsWithResources.resources : options, - ].flat(); - const allRules = allOptions.flatMap( - option => - ( - option as CreatePermissionIntegrationRouterResourceOptions< - TResourceType1, - TResource1 - > - ).rules || [], - ); +): express.Router & { + addPermissions(permissions: Permission[]): void; + addResourceType( + resource: CreatePermissionIntegrationRouterResourceOptions< + TResourceType, + TResource + >, + ): void; +} { + const store = new PermissionIntegrationMetadataStore(); - const allPermissions = Array.from( - new Map( - [ - ...((options as { permissions: Permission[] }).permissions || []), - ...(optionsWithResources.resources?.flatMap(o => o.permissions || []) || - []), - ].map(i => [i.name, i]), - ).values(), - ); + if (options) { + if ('resources' in options) { + // Not technically allowed by types, but it's historically been covered by tests + if ('permissions' in options) { + store.addPermissions(options.permissions as Permission[]); + } - const allResourceTypes = allOptions.reduce((acc, option) => { - if ( - isCreatePermissionIntegrationRouterResourceOptions( - option as - | { permissions: Array } - | CreatePermissionIntegrationRouterResourceOptions< - TResourceType1, - TResource1 - >, - ) - ) { - acc.push( - ( - option as CreatePermissionIntegrationRouterResourceOptions< - TResourceType1, - TResource1 - > - ).resourceType, - ); + for (const resource of options.resources) { + store.addResourceType(resource); + } + } else if ('resourceType' in options) { + store.addResourceType(options); + } else { + store.addPermissions(options.permissions); } - return acc; - }, [] as string[]); + } const router = Router(); - router.use(express.json()); + + router.use('/.well-known/backstage/permissions/', express.json()); router.get('/.well-known/backstage/permissions/metadata', (_, res) => { - const serializedRules: MetadataResponseSerializedRule[] = allRules.map( - rule => ({ - name: rule.name, - description: rule.description, - resourceType: rule.resourceType, - paramsSchema: zodToJsonSchema(rule.paramsSchema ?? z.object({})), - }), - ); - - const responseJson: MetadataResponse = { - permissions: allPermissions, - rules: serializedRules, - }; - - return res.json(responseJson); + res.json(store.getSerializedMetadata()); }); router.post( '/.well-known/backstage/permissions/apply-conditions', async (req, res: Response) => { - const ruleMapByResourceType: Record< - string, - ReturnType - > = {}; - const getResourcesByResourceType: Record< - string, - CreatePermissionIntegrationRouterResourceOptions< - TResourceType1, - TResource1 - >['getResources'] - > = {}; - - for (let option of allOptions) { - option = option as - | { permissions: Array } - | CreatePermissionIntegrationRouterResourceOptions< - TResourceType1, - TResource1 - >; - if (isCreatePermissionIntegrationRouterResourceOptions(option)) { - ruleMapByResourceType[option.resourceType] = createGetRule( - option.rules, - ); - - getResourcesByResourceType[option.resourceType] = option.getResources; - } - } - - const assertValidResourceTypes = ( - requests: ApplyConditionsRequestEntry[], - ) => { - const invalidResourceTypes = requests - .filter(request => !allResourceTypes.includes(request.resourceType)) - .map(request => request.resourceType); - - if (invalidResourceTypes.length) { - throw new InputError( - `Unexpected resource types: ${invalidResourceTypes.join(', ')}.`, - ); - } - }; - const parseResult = applyConditionsRequestSchema.safeParse(req.body); - if (!parseResult.success) { throw new InputError(parseResult.error.toString()); } - const body = parseResult.data; + const { items: requests } = parseResult.data; - assertValidResourceTypes(body.items); - - const resourceRefsByResourceType = body.items.reduce< - Record> - >((acc, item) => { - if (!acc[item.resourceType]) { - acc[item.resourceType] = new Set(); - } - acc[item.resourceType].add(item.resourceRef); - return acc; - }, {}); - - const resourcesByResourceType: Record> = {}; - for (const resourceType of Object.keys(resourceRefsByResourceType)) { - const getResources = getResourcesByResourceType[resourceType]; - if (!getResources) { - throw new NotImplementedError( - `This plugin does not expose any permission rule or can't evaluate the conditions request for ${resourceType}`, - ); - } - const resourceRefs = Array.from( - resourceRefsByResourceType[resourceType], + const invalidResourceTypes = requests.filter( + i => !store.hasResourceType(i.resourceType), + ); + if (invalidResourceTypes.length) { + throw new InputError( + `Unexpected resource types: ${invalidResourceTypes + .map(i => i.resourceType) + .join(', ')}.`, ); - const resources = await getResources(resourceRefs); - resourceRefs.forEach((resourceRef, index) => { - if (!resourcesByResourceType[resourceType]) { - resourcesByResourceType[resourceType] = {}; - } - resourcesByResourceType[resourceType][resourceRef] = resources[index]; - }); } - return res.json({ - items: body.items.map(request => ({ + const resourcesByType: Record> = {}; + for (const requestedType of new Set(requests.map(i => i.resourceType))) { + resourcesByType[requestedType] = await store.getResources( + requestedType, + requests + .filter(r => r.resourceType === requestedType) + .map(i => i.resourceRef), + ); + } + + res.json({ + items: requests.map(request => ({ id: request.id, result: applyConditions( request.conditions, - resourcesByResourceType[request.resourceType][request.resourceRef], - ruleMapByResourceType[request.resourceType], + resourcesByType[request.resourceType][request.resourceRef], + store.getRuleMapper(request.resourceType), ) ? AuthorizeResult.ALLOW : AuthorizeResult.DENY, @@ -482,29 +491,17 @@ export function createPermissionIntegrationRouter< }, ); - return router; -} - -function isCreatePermissionIntegrationRouterResourceOptions< - TResourceType extends string, - TResource, ->( - options: - | { permissions: Array } - | CreatePermissionIntegrationRouterResourceOptions< + return Object.assign(router, { + addPermissions(permissions: Permission[]) { + store.addPermissions(permissions); + }, + addResourceType( + resource: CreatePermissionIntegrationRouterResourceOptions< TResourceType, TResource >, -): options is CreatePermissionIntegrationRouterResourceOptions< - TResourceType, - TResource -> { - return ( - ( - options as CreatePermissionIntegrationRouterResourceOptions< - TResourceType, - TResource - > - ).resourceType !== undefined - ); + ) { + store.addResourceType(resource); + }, + }); }