From cc8348ef2e18b0746f6c7ffe9d5b07788ba71ef7 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Thu, 12 Mar 2026 09:58:04 +0100 Subject: [PATCH] feat: add permissions integration to actions registry Signed-off-by: benjdlambert --- .changeset/actions-permissions-api.md | 5 + .changeset/actions-permissions-defaults.md | 5 + packages/backend-defaults/package.json | 1 + .../DefaultActionsRegistryService.ts | 67 ++++- .../actionsRegistryServiceFactory.test.ts | 267 ++++++++++++++++++ .../actionsRegistryServiceFactory.ts | 4 +- .../backend-plugin-api/report-alpha.api.md | 2 + .../src/alpha/ActionsRegistryService.ts | 2 + .../definitions/ActionsRegistryService.ts | 2 + yarn.lock | 1 + 10 files changed, 352 insertions(+), 4 deletions(-) create mode 100644 .changeset/actions-permissions-api.md create mode 100644 .changeset/actions-permissions-defaults.md diff --git a/.changeset/actions-permissions-api.md b/.changeset/actions-permissions-api.md new file mode 100644 index 0000000000..e18a2f2469 --- /dev/null +++ b/.changeset/actions-permissions-api.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-plugin-api': minor +--- + +Added optional `permission` field to `ActionsRegistryActionOptions`, allowing actions to declare a `BasicPermission` that controls visibility and access. diff --git a/.changeset/actions-permissions-defaults.md b/.changeset/actions-permissions-defaults.md new file mode 100644 index 0000000000..1f5c9a916e --- /dev/null +++ b/.changeset/actions-permissions-defaults.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-defaults': patch +--- + +Added permissions integration to the actions registry. Actions with a `permission` field are now checked against the permissions framework when listing and invoking. Denied actions are filtered from listings and return 404 on invocation. diff --git a/packages/backend-defaults/package.json b/packages/backend-defaults/package.json index bbc0e087ae..fb4e29a3ce 100644 --- a/packages/backend-defaults/package.json +++ b/packages/backend-defaults/package.json @@ -144,6 +144,7 @@ "@backstage/integration-aws-node": "workspace:^", "@backstage/plugin-auth-node": "workspace:^", "@backstage/plugin-events-node": "workspace:^", + "@backstage/plugin-permission-common": "workspace:^", "@backstage/plugin-permission-node": "workspace:^", "@backstage/types": "workspace:^", "@google-cloud/storage": "^7.0.0", diff --git a/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts b/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts index ffe74f81d9..a324389055 100644 --- a/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts +++ b/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts @@ -18,6 +18,7 @@ import { AuthService, HttpAuthService, LoggerService, + PermissionsService, PluginMetadataService, } from '@backstage/backend-plugin-api'; import PromiseRouter from 'express-promise-router'; @@ -29,6 +30,7 @@ import { ActionsRegistryService, } from '@backstage/backend-plugin-api/alpha'; import { InputError, NotAllowedError, NotFoundError } from '@backstage/errors'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; export class DefaultActionsRegistryService implements ActionsRegistryService { private actions: Map> = @@ -38,17 +40,20 @@ export class DefaultActionsRegistryService implements ActionsRegistryService { private readonly httpAuth: HttpAuthService; private readonly auth: AuthService; private readonly metadata: PluginMetadataService; + private readonly permissions: PermissionsService; private constructor( logger: LoggerService, httpAuth: HttpAuthService, auth: AuthService, metadata: PluginMetadataService, + permissions: PermissionsService, ) { this.logger = logger; this.httpAuth = httpAuth; this.auth = auth; this.metadata = metadata; + this.permissions = permissions; } static create({ @@ -56,22 +61,38 @@ export class DefaultActionsRegistryService implements ActionsRegistryService { logger, auth, metadata, + permissions, }: { httpAuth: HttpAuthService; logger: LoggerService; auth: AuthService; metadata: PluginMetadataService; + permissions: PermissionsService; }): DefaultActionsRegistryService { - return new DefaultActionsRegistryService(logger, httpAuth, auth, metadata); + return new DefaultActionsRegistryService( + logger, + httpAuth, + auth, + metadata, + permissions, + ); } createRouter(): Router { const router = PromiseRouter(); router.use(json()); - router.get('/.backstage/actions/v1/actions', (_, res) => { + router.get('/.backstage/actions/v1/actions', async (req, res) => { + const credentials = await this.httpAuth.credentials(req); + const entries = Array.from(this.actions.entries()); + + const allowedActions = await this.filterByPermissions( + entries, + credentials, + ); + return res.json({ - actions: Array.from(this.actions.entries()).map(([id, action]) => ({ + actions: allowedActions.map(([id, action]) => ({ id, ...action, attributes: { @@ -115,6 +136,18 @@ export class DefaultActionsRegistryService implements ActionsRegistryService { throw new NotFoundError(`Action "${req.params.actionId}" not found`); } + if (action.permission) { + const [decision] = await this.permissions.authorize( + [{ permission: action.permission }], + { credentials }, + ); + if (decision.result === AuthorizeResult.DENY) { + throw new NotFoundError( + `Action "${req.params.actionId}" not found`, + ); + } + } + const input = action.schema?.input ? action.schema.input(z).safeParse(req.body) : ({ success: true, data: undefined } as const); @@ -161,4 +194,32 @@ export class DefaultActionsRegistryService implements ActionsRegistryService { this.actions.set(id, options); } + + private async filterByPermissions( + entries: [string, ActionsRegistryActionOptions][], + credentials: Parameters[1]['credentials'], + ): Promise<[string, ActionsRegistryActionOptions][]> { + const permissionedEntries = entries.filter( + ([_, action]) => action.permission, + ); + + if (permissionedEntries.length === 0) { + return entries; + } + + const decisions = await this.permissions.authorize( + permissionedEntries.map(([_, action]) => ({ + permission: action.permission!, + })), + { credentials }, + ); + + const deniedIds = new Set( + permissionedEntries + .filter((_, index) => decisions[index].result === AuthorizeResult.DENY) + .map(([id]) => id), + ); + + return entries.filter(([id]) => !deniedIds.has(id)); + } } diff --git a/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts b/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts index 1e7c27960e..cbd1328d1b 100644 --- a/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts +++ b/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts @@ -24,6 +24,10 @@ import request from 'supertest'; import { actionsRegistryServiceFactory } from './actionsRegistryServiceFactory'; import { InputError, NotFoundError } from '@backstage/errors'; import { actionsRegistryServiceRef } from '@backstage/backend-plugin-api/alpha'; +import { + AuthorizeResult, + createPermission, +} from '@backstage/plugin-permission-common'; describe('actionsRegistryServiceFactory', () => { const defaultServices = [ @@ -558,4 +562,267 @@ describe('actionsRegistryServiceFactory', () => { }); }); }); + + describe('permissions', () => { + const testPermission = createPermission({ + name: 'test.action.use', + attributes: {}, + }); + + it('should filter out actions with denied permissions when listing', async () => { + const pluginSubject = createBackendPlugin({ + pluginId: 'my-plugin', + register(reg) { + reg.registerInit({ + deps: { + actionsRegistry: actionsRegistryServiceRef, + }, + async init({ actionsRegistry }) { + actionsRegistry.register({ + name: 'public-action', + title: 'Public Action', + description: 'No permission required', + schema: { + input: z => z.object({}), + output: z => z.object({}), + }, + action: async () => ({ output: {} }), + }); + actionsRegistry.register({ + name: 'protected-action', + title: 'Protected Action', + description: 'Permission required', + permission: testPermission, + schema: { + input: z => z.object({}), + output: z => z.object({}), + }, + action: async () => ({ output: {} }), + }); + }, + }); + }, + }); + + const { server } = await startTestBackend({ + features: [ + pluginSubject, + actionsRegistryServiceFactory, + httpRouterServiceFactory, + mockServices.httpAuth.factory({ + defaultCredentials: mockCredentials.service('user:default/mock'), + }), + mockServices.permissions.factory({ + result: AuthorizeResult.DENY, + }), + ], + }); + + const { body, status } = await request(server).get( + '/api/my-plugin/.backstage/actions/v1/actions', + ); + + expect(status).toBe(200); + expect(body.actions).toHaveLength(1); + expect(body.actions[0].name).toBe('public-action'); + }); + + it('should include actions with allowed permissions when listing', async () => { + const pluginSubject = createBackendPlugin({ + pluginId: 'my-plugin', + register(reg) { + reg.registerInit({ + deps: { + actionsRegistry: actionsRegistryServiceRef, + }, + async init({ actionsRegistry }) { + actionsRegistry.register({ + name: 'protected-action', + title: 'Protected Action', + description: 'Permission required', + permission: testPermission, + schema: { + input: z => z.object({}), + output: z => z.object({}), + }, + action: async () => ({ output: {} }), + }); + }, + }); + }, + }); + + const { server } = await startTestBackend({ + features: [ + pluginSubject, + actionsRegistryServiceFactory, + httpRouterServiceFactory, + mockServices.httpAuth.factory({ + defaultCredentials: mockCredentials.service('user:default/mock'), + }), + mockServices.permissions.factory({ + result: AuthorizeResult.ALLOW, + }), + ], + }); + + const { body, status } = await request(server).get( + '/api/my-plugin/.backstage/actions/v1/actions', + ); + + expect(status).toBe(200); + expect(body.actions).toHaveLength(1); + expect(body.actions[0].name).toBe('protected-action'); + }); + + it('should return 404 when invoking an action with denied permission', async () => { + const pluginSubject = createBackendPlugin({ + pluginId: 'my-plugin', + register(reg) { + reg.registerInit({ + deps: { + actionsRegistry: actionsRegistryServiceRef, + }, + async init({ actionsRegistry }) { + actionsRegistry.register({ + name: 'protected-action', + title: 'Protected Action', + description: 'Permission required', + permission: testPermission, + schema: { + input: z => z.object({}), + output: z => z.object({}), + }, + action: async () => ({ output: {} }), + }); + }, + }); + }, + }); + + const { server } = await startTestBackend({ + features: [ + pluginSubject, + actionsRegistryServiceFactory, + httpRouterServiceFactory, + mockServices.httpAuth.factory({ + defaultCredentials: mockCredentials.service('user:default/mock'), + }), + mockServices.permissions.factory({ + result: AuthorizeResult.DENY, + }), + ], + }); + + const { body, status } = await request(server).post( + '/api/my-plugin/.backstage/actions/v1/actions/my-plugin:protected-action/invoke', + ); + + expect(status).toBe(404); + expect(body).toMatchObject({ + error: { + message: 'Action "my-plugin:protected-action" not found', + }, + }); + }); + + it('should allow invoking an action when permission is granted', async () => { + const mockAction = jest.fn().mockResolvedValue({ output: { ok: true } }); + + const pluginSubject = createBackendPlugin({ + pluginId: 'my-plugin', + register(reg) { + reg.registerInit({ + deps: { + actionsRegistry: actionsRegistryServiceRef, + }, + async init({ actionsRegistry }) { + actionsRegistry.register({ + name: 'protected-action', + title: 'Protected Action', + description: 'Permission required', + permission: testPermission, + schema: { + input: z => z.object({}), + output: z => z.object({ ok: z.boolean() }), + }, + action: mockAction, + }); + }, + }); + }, + }); + + const { server } = await startTestBackend({ + features: [ + pluginSubject, + actionsRegistryServiceFactory, + httpRouterServiceFactory, + mockServices.httpAuth.factory({ + defaultCredentials: mockCredentials.service('user:default/mock'), + }), + mockServices.permissions.factory({ + result: AuthorizeResult.ALLOW, + }), + ], + }); + + const { body, status } = await request(server).post( + '/api/my-plugin/.backstage/actions/v1/actions/my-plugin:protected-action/invoke', + ); + + expect(status).toBe(200); + expect(body).toMatchObject({ output: { ok: true } }); + expect(mockAction).toHaveBeenCalled(); + }); + + it('should pass the correct permission to the authorize call', async () => { + const permissionsMock = mockServices.permissions.mock({ + authorize: async () => [{ result: AuthorizeResult.ALLOW }], + }); + + const pluginSubject = createBackendPlugin({ + pluginId: 'my-plugin', + register(reg) { + reg.registerInit({ + deps: { + actionsRegistry: actionsRegistryServiceRef, + }, + async init({ actionsRegistry }) { + actionsRegistry.register({ + name: 'protected-action', + title: 'Protected Action', + description: 'Permission required', + permission: testPermission, + schema: { + input: z => z.object({}), + output: z => z.object({}), + }, + action: async () => ({ output: {} }), + }); + }, + }); + }, + }); + + const { server } = await startTestBackend({ + features: [ + pluginSubject, + actionsRegistryServiceFactory, + httpRouterServiceFactory, + mockServices.httpAuth.factory({ + defaultCredentials: mockCredentials.service('user:default/mock'), + }), + permissionsMock.factory, + ], + }); + + await request(server).get('/api/my-plugin/.backstage/actions/v1/actions'); + + expect(permissionsMock.authorize).toHaveBeenCalledWith( + [{ permission: testPermission }], + expect.objectContaining({ credentials: expect.anything() }), + ); + }); + }); }); diff --git a/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/actionsRegistryServiceFactory.ts b/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/actionsRegistryServiceFactory.ts index 8c19b8148c..65d7aff5db 100644 --- a/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/actionsRegistryServiceFactory.ts +++ b/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/actionsRegistryServiceFactory.ts @@ -32,13 +32,15 @@ export const actionsRegistryServiceFactory = createServiceFactory({ httpAuth: coreServices.httpAuth, logger: coreServices.logger, auth: coreServices.auth, + permissions: coreServices.permissions, }, - factory: ({ metadata, httpRouter, httpAuth, logger, auth }) => { + factory: ({ metadata, httpRouter, httpAuth, logger, auth, permissions }) => { const actionsRegistryService = DefaultActionsRegistryService.create({ httpAuth, logger, auth, metadata, + permissions, }); httpRouter.use(actionsRegistryService.createRouter()); diff --git a/packages/backend-plugin-api/report-alpha.api.md b/packages/backend-plugin-api/report-alpha.api.md index 7d6786b97e..58acb5338b 100644 --- a/packages/backend-plugin-api/report-alpha.api.md +++ b/packages/backend-plugin-api/report-alpha.api.md @@ -5,6 +5,7 @@ ```ts import { AnyZodObject } from 'zod'; import { BackstageCredentials } from '@backstage/backend-plugin-api'; +import { BasicPermission } from '@backstage/plugin-permission-common'; import { JsonObject } from '@backstage/types'; import { JSONSchema7 } from 'json-schema'; import { JsonValue } from '@backstage/types'; @@ -31,6 +32,7 @@ export type ActionsRegistryActionOptions< input: (zod: typeof z) => TInputSchema; output: (zod: typeof z) => TOutputSchema; }; + permission?: BasicPermission; attributes?: { destructive?: boolean; idempotent?: boolean; diff --git a/packages/backend-plugin-api/src/alpha/ActionsRegistryService.ts b/packages/backend-plugin-api/src/alpha/ActionsRegistryService.ts index 9a7820e354..cd8a271322 100644 --- a/packages/backend-plugin-api/src/alpha/ActionsRegistryService.ts +++ b/packages/backend-plugin-api/src/alpha/ActionsRegistryService.ts @@ -14,6 +14,7 @@ * limitations under the License. */ import { z, AnyZodObject } from 'zod'; +import { BasicPermission } from '@backstage/plugin-permission-common'; import { LoggerService, BackstageCredentials, @@ -42,6 +43,7 @@ export type ActionsRegistryActionOptions< input: (zod: typeof z) => TInputSchema; output: (zod: typeof z) => TOutputSchema; }; + permission?: BasicPermission; attributes?: { destructive?: boolean; idempotent?: boolean; diff --git a/packages/backend-plugin-api/src/services/definitions/ActionsRegistryService.ts b/packages/backend-plugin-api/src/services/definitions/ActionsRegistryService.ts index 6c0936f85c..dc81bb3c28 100644 --- a/packages/backend-plugin-api/src/services/definitions/ActionsRegistryService.ts +++ b/packages/backend-plugin-api/src/services/definitions/ActionsRegistryService.ts @@ -14,6 +14,7 @@ * limitations under the License. */ import { z, AnyZodObject } from 'zod'; +import { BasicPermission } from '@backstage/plugin-permission-common'; import { LoggerService } from './LoggerService'; import { BackstageCredentials } from './AuthService'; @@ -40,6 +41,7 @@ export type ActionsRegistryActionOptions< input: (zod: typeof z) => TInputSchema; output: (zod: typeof z) => TOutputSchema; }; + permission?: BasicPermission; attributes?: { destructive?: boolean; idempotent?: boolean; diff --git a/yarn.lock b/yarn.lock index 35dd067cc4..c0931d9fa5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2524,6 +2524,7 @@ __metadata: "@backstage/integration-aws-node": "workspace:^" "@backstage/plugin-auth-node": "workspace:^" "@backstage/plugin-events-node": "workspace:^" + "@backstage/plugin-permission-common": "workspace:^" "@backstage/plugin-permission-node": "workspace:^" "@backstage/types": "workspace:^" "@google-cloud/cloud-sql-connector": "npm:^1.4.0"