From cc8348ef2e18b0746f6c7ffe9d5b07788ba71ef7 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Thu, 12 Mar 2026 09:58:04 +0100 Subject: [PATCH 1/6] 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" From d504478c1e81cbba96d66bd7cbb2f24ee5b5de43 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Thu, 12 Mar 2026 10:01:42 +0100 Subject: [PATCH 2/6] docs: add permissions documentation for actions registry Signed-off-by: benjdlambert --- .changeset/actions-permissions-api.md | 25 +++++++++++++ .changeset/actions-permissions-defaults.md | 2 +- .../core-services/actions-registry.md | 36 +++++++++++++++++++ docs/backend-system/core-services/actions.md | 4 +++ .../DefaultActionsRegistryService.ts | 9 +++-- 5 files changed, 72 insertions(+), 4 deletions(-) diff --git a/.changeset/actions-permissions-api.md b/.changeset/actions-permissions-api.md index e18a2f2469..193e84b7a7 100644 --- a/.changeset/actions-permissions-api.md +++ b/.changeset/actions-permissions-api.md @@ -3,3 +3,28 @@ --- Added optional `permission` field to `ActionsRegistryActionOptions`, allowing actions to declare a `BasicPermission` that controls visibility and access. + +```typescript +import { createPermission } from '@backstage/plugin-permission-common'; + +const myPermission = createPermission({ + name: 'myPlugin.myAction.use', + attributes: {}, +}); + +actionsRegistry.register({ + name: 'my-action', + title: 'My Action', + description: 'An action that requires permission', + permission: myPermission, + schema: { + input: z => z.object({ name: z.string() }), + output: z => z.object({ ok: z.boolean() }), + }, + action: async ({ input }) => { + return { output: { ok: true } }; + }, +}); +``` + +Actions without a `permission` field continue to work as before. diff --git a/.changeset/actions-permissions-defaults.md b/.changeset/actions-permissions-defaults.md index 1f5c9a916e..3c471398c0 100644 --- a/.changeset/actions-permissions-defaults.md +++ b/.changeset/actions-permissions-defaults.md @@ -2,4 +2,4 @@ '@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. +Added permissions integration to the actions registry. Actions registered with a `permission` field are now checked against the permissions framework when listing and invoking. Denied actions are filtered from list results, and invoking a denied action returns a `404 Not Found` as if the action does not exist. diff --git a/docs/backend-system/core-services/actions-registry.md b/docs/backend-system/core-services/actions-registry.md index 9e493e6bb2..97db476707 100644 --- a/docs/backend-system/core-services/actions-registry.md +++ b/docs/backend-system/core-services/actions-registry.md @@ -25,6 +25,7 @@ Each action registered with the service must conform to the `ActionsRegistryActi ### Optional Properties +- **`permission`:** A `BasicPermission` that controls visibility and access to the action through the permissions framework. See [Permissions](#permissions) below. - **`attributes`:** Object containing behavioral flags: - **`destructive`:** Boolean indicating if the action modifies or deletes data - **`idempotent`:** Boolean indicating if running the action multiple times produces the same result @@ -157,6 +158,41 @@ export const myPlugin = createBackendPlugin({ }); ``` +## Permissions + +Actions can optionally declare a `permission` to control visibility and access through the Backstage permissions framework. When a permission is set, the action is only visible in listings and accessible by users who are authorized. + +Actions that are denied by the permission policy are filtered from `list()` results and return a `404 Not Found` on `invoke()`, as if they don't exist. + +### Adding a Permission to an Action + +```typescript +import { createPermission } from '@backstage/plugin-permission-common'; + +// Define a permission for your action +const deleteEntityPermission = createPermission({ + name: 'catalog.entity.delete', + attributes: { action: 'delete' }, +}); + +actionsRegistry.register({ + name: 'delete-entity', + title: 'Delete Entity', + description: 'Removes an entity from the catalog', + permission: deleteEntityPermission, + schema: { + input: z => z.object({ entityRef: z.string() }), + output: z => z.object({ deleted: z.boolean() }), + }, + action: async ({ input }) => { + // action logic + return { output: { deleted: true } }; + }, +}); +``` + +Actions without a `permission` field remain visible and accessible by all callers, preserving backwards compatibility. + ## Best Practices ### Naming Conventions diff --git a/docs/backend-system/core-services/actions.md b/docs/backend-system/core-services/actions.md index 02f8b65591..d1680b2380 100644 --- a/docs/backend-system/core-services/actions.md +++ b/docs/backend-system/core-services/actions.md @@ -65,6 +65,10 @@ backend: - 'scaffolder.internal.*' ``` +### Permissions + +Actions registered with a `permission` field are automatically checked against the permissions framework. When listing actions, any actions denied by the active permission policy are filtered out of the results. When invoking a denied action, a `404 Not Found` error is returned. See the [Actions Registry Permissions](./actions-registry.md#permissions) documentation for how to configure permissions on actions. + ## Using the Service ### Listing Available Actions diff --git a/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts b/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts index a324389055..33e59afbe4 100644 --- a/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts +++ b/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts @@ -16,6 +16,7 @@ import { AuthService, + BackstageCredentials, HttpAuthService, LoggerService, PermissionsService, @@ -32,6 +33,8 @@ import { import { InputError, NotAllowedError, NotFoundError } from '@backstage/errors'; import { AuthorizeResult } from '@backstage/plugin-permission-common'; +type ActionEntry = [string, ActionsRegistryActionOptions]; + export class DefaultActionsRegistryService implements ActionsRegistryService { private actions: Map> = new Map(); @@ -196,9 +199,9 @@ export class DefaultActionsRegistryService implements ActionsRegistryService { } private async filterByPermissions( - entries: [string, ActionsRegistryActionOptions][], - credentials: Parameters[1]['credentials'], - ): Promise<[string, ActionsRegistryActionOptions][]> { + entries: ActionEntry[], + credentials: BackstageCredentials, + ): Promise { const permissionedEntries = entries.filter( ([_, action]) => action.permission, ); From 5f739e13120f8708535fc2eabd828d5b556f974b Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Thu, 12 Mar 2026 15:00:36 +0100 Subject: [PATCH 3/6] fix: address PR feedback for actions permissions Signed-off-by: benjdlambert --- .changeset/actions-permissions-defaults.md | 2 +- .../core-services/actions-registry.md | 10 +++-- .../DefaultActionsRegistryService.ts | 15 ++++++- .../actionsRegistryServiceFactory.test.ts | 44 +++++++++++++++++++ .../actionsRegistryServiceFactory.ts | 12 ++++- 5 files changed, 76 insertions(+), 7 deletions(-) diff --git a/.changeset/actions-permissions-defaults.md b/.changeset/actions-permissions-defaults.md index 3c471398c0..f5fdf0ade0 100644 --- a/.changeset/actions-permissions-defaults.md +++ b/.changeset/actions-permissions-defaults.md @@ -2,4 +2,4 @@ '@backstage/backend-defaults': patch --- -Added permissions integration to the actions registry. Actions registered with a `permission` field are now checked against the permissions framework when listing and invoking. Denied actions are filtered from list results, and invoking a denied action returns a `404 Not Found` as if the action does not exist. +Added permissions integration to the actions registry. Actions registered with a `permission` field are now checked against the permissions framework when listing and invoking. Denied actions are filtered from list results, and invoking a denied action returns a `404 Not Found` as if the action does not exist. Permissions are automatically registered with the `PermissionsRegistryService` so they appear in the permission policy system. diff --git a/docs/backend-system/core-services/actions-registry.md b/docs/backend-system/core-services/actions-registry.md index 97db476707..4f817855c1 100644 --- a/docs/backend-system/core-services/actions-registry.md +++ b/docs/backend-system/core-services/actions-registry.md @@ -162,7 +162,9 @@ export const myPlugin = createBackendPlugin({ Actions can optionally declare a `permission` to control visibility and access through the Backstage permissions framework. When a permission is set, the action is only visible in listings and accessible by users who are authorized. -Actions that are denied by the permission policy are filtered from `list()` results and return a `404 Not Found` on `invoke()`, as if they don't exist. +When accessed via the Actions Service or the `/.backstage/actions/v1/...` HTTP endpoints, actions that are denied by the permission policy are filtered from list results and return a `404 Not Found` on invocation, as if they don't exist. + +Permissions declared on actions are automatically registered with the `PermissionsRegistryService` so they appear in the permission policy system. ### Adding a Permission to an Action @@ -170,8 +172,8 @@ Actions that are denied by the permission policy are filtered from `list()` resu import { createPermission } from '@backstage/plugin-permission-common'; // Define a permission for your action -const deleteEntityPermission = createPermission({ - name: 'catalog.entity.delete', +const myDeletePermission = createPermission({ + name: 'my-plugin.actions.deleteEntity', attributes: { action: 'delete' }, }); @@ -179,7 +181,7 @@ actionsRegistry.register({ name: 'delete-entity', title: 'Delete Entity', description: 'Removes an entity from the catalog', - permission: deleteEntityPermission, + permission: myDeletePermission, schema: { input: z => z.object({ entityRef: z.string() }), output: z => z.object({ deleted: z.boolean() }), diff --git a/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts b/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts index 33e59afbe4..974576dd81 100644 --- a/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts +++ b/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts @@ -19,6 +19,7 @@ import { BackstageCredentials, HttpAuthService, LoggerService, + PermissionsRegistryService, PermissionsService, PluginMetadataService, } from '@backstage/backend-plugin-api'; @@ -44,6 +45,7 @@ export class DefaultActionsRegistryService implements ActionsRegistryService { private readonly auth: AuthService; private readonly metadata: PluginMetadataService; private readonly permissions: PermissionsService; + private readonly permissionsRegistry: PermissionsRegistryService; private constructor( logger: LoggerService, @@ -51,12 +53,14 @@ export class DefaultActionsRegistryService implements ActionsRegistryService { auth: AuthService, metadata: PluginMetadataService, permissions: PermissionsService, + permissionsRegistry: PermissionsRegistryService, ) { this.logger = logger; this.httpAuth = httpAuth; this.auth = auth; this.metadata = metadata; this.permissions = permissions; + this.permissionsRegistry = permissionsRegistry; } static create({ @@ -65,12 +69,14 @@ export class DefaultActionsRegistryService implements ActionsRegistryService { auth, metadata, permissions, + permissionsRegistry, }: { httpAuth: HttpAuthService; logger: LoggerService; auth: AuthService; metadata: PluginMetadataService; permissions: PermissionsService; + permissionsRegistry: PermissionsRegistryService; }): DefaultActionsRegistryService { return new DefaultActionsRegistryService( logger, @@ -78,6 +84,7 @@ export class DefaultActionsRegistryService implements ActionsRegistryService { auth, metadata, permissions, + permissionsRegistry, ); } @@ -97,7 +104,9 @@ export class DefaultActionsRegistryService implements ActionsRegistryService { return res.json({ actions: allowedActions.map(([id, action]) => ({ id, - ...action, + name: action.name, + title: action.title, + description: action.description, attributes: { // Inspired by the @modelcontextprotocol/sdk defaults for the hints. // https://github.com/modelcontextprotocol/typescript-sdk/blob/dd69efa1de8646bb6b195ff8d5f52e13739f4550/src/types.ts#L777-L812 @@ -195,6 +204,10 @@ export class DefaultActionsRegistryService implements ActionsRegistryService { throw new Error(`Action with id "${id}" is already registered`); } + if (options.permission) { + this.permissionsRegistry.addPermissions([options.permission]); + } + this.actions.set(id, options); } 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 cbd1328d1b..21123cdca5 100644 --- a/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts +++ b/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts @@ -824,5 +824,49 @@ describe('actionsRegistryServiceFactory', () => { expect.objectContaining({ credentials: expect.anything() }), ); }); + + it('should register the permission with the permissions registry', async () => { + const permissionsRegistryMock = mockServices.permissionsRegistry.mock(); + + 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: {} }), + }); + }, + }); + }, + }); + + await startTestBackend({ + features: [ + pluginSubject, + actionsRegistryServiceFactory, + httpRouterServiceFactory, + mockServices.httpAuth.factory({ + defaultCredentials: mockCredentials.service('user:default/mock'), + }), + permissionsRegistryMock.factory, + ], + }); + + expect(permissionsRegistryMock.addPermissions).toHaveBeenCalledWith([ + testPermission, + ]); + }); }); }); diff --git a/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/actionsRegistryServiceFactory.ts b/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/actionsRegistryServiceFactory.ts index 65d7aff5db..4ac6b2c782 100644 --- a/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/actionsRegistryServiceFactory.ts +++ b/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/actionsRegistryServiceFactory.ts @@ -33,14 +33,24 @@ export const actionsRegistryServiceFactory = createServiceFactory({ logger: coreServices.logger, auth: coreServices.auth, permissions: coreServices.permissions, + permissionsRegistry: coreServices.permissionsRegistry, }, - factory: ({ metadata, httpRouter, httpAuth, logger, auth, permissions }) => { + factory: ({ + metadata, + httpRouter, + httpAuth, + logger, + auth, + permissions, + permissionsRegistry, + }) => { const actionsRegistryService = DefaultActionsRegistryService.create({ httpAuth, logger, auth, metadata, permissions, + permissionsRegistry, }); httpRouter.use(actionsRegistryService.createRouter()); From 97c917ea1c4646df97fcac07f3dbde5e5e09e663 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Thu, 12 Mar 2026 15:07:32 +0100 Subject: [PATCH 4/6] refactor: rename permission to visibilityPermission Signed-off-by: benjdlambert --- .changeset/actions-permissions-api.md | 6 +++--- .changeset/actions-permissions-defaults.md | 2 +- .../backend-system/core-services/actions-registry.md | 8 ++++---- docs/backend-system/core-services/actions.md | 2 +- .../actionsRegistry/DefaultActionsRegistryService.ts | 12 ++++++------ .../actionsRegistryServiceFactory.test.ts | 12 ++++++------ packages/backend-plugin-api/report-alpha.api.md | 2 +- .../src/alpha/ActionsRegistryService.ts | 2 +- .../services/definitions/ActionsRegistryService.ts | 2 +- 9 files changed, 24 insertions(+), 24 deletions(-) diff --git a/.changeset/actions-permissions-api.md b/.changeset/actions-permissions-api.md index 193e84b7a7..c38099a8a9 100644 --- a/.changeset/actions-permissions-api.md +++ b/.changeset/actions-permissions-api.md @@ -2,7 +2,7 @@ '@backstage/backend-plugin-api': minor --- -Added optional `permission` field to `ActionsRegistryActionOptions`, allowing actions to declare a `BasicPermission` that controls visibility and access. +Added optional `visibilityPermission` field to `ActionsRegistryActionOptions`, allowing actions to declare a `BasicPermission` that controls visibility and access. ```typescript import { createPermission } from '@backstage/plugin-permission-common'; @@ -16,7 +16,7 @@ actionsRegistry.register({ name: 'my-action', title: 'My Action', description: 'An action that requires permission', - permission: myPermission, + visibilityPermission: myPermission, schema: { input: z => z.object({ name: z.string() }), output: z => z.object({ ok: z.boolean() }), @@ -27,4 +27,4 @@ actionsRegistry.register({ }); ``` -Actions without a `permission` field continue to work as before. +Actions without a `visibilityPermission` field continue to work as before. diff --git a/.changeset/actions-permissions-defaults.md b/.changeset/actions-permissions-defaults.md index f5fdf0ade0..66d931fa6d 100644 --- a/.changeset/actions-permissions-defaults.md +++ b/.changeset/actions-permissions-defaults.md @@ -2,4 +2,4 @@ '@backstage/backend-defaults': patch --- -Added permissions integration to the actions registry. Actions registered with a `permission` field are now checked against the permissions framework when listing and invoking. Denied actions are filtered from list results, and invoking a denied action returns a `404 Not Found` as if the action does not exist. Permissions are automatically registered with the `PermissionsRegistryService` so they appear in the permission policy system. +Added permissions integration to the actions registry. Actions registered with a `visibilityPermission` field are now checked against the permissions framework when listing and invoking. Denied actions are filtered from list results, and invoking a denied action returns a `404 Not Found` as if the action does not exist. Permissions are automatically registered with the `PermissionsRegistryService` so they appear in the permission policy system. diff --git a/docs/backend-system/core-services/actions-registry.md b/docs/backend-system/core-services/actions-registry.md index 4f817855c1..23e8c2bff8 100644 --- a/docs/backend-system/core-services/actions-registry.md +++ b/docs/backend-system/core-services/actions-registry.md @@ -25,7 +25,7 @@ Each action registered with the service must conform to the `ActionsRegistryActi ### Optional Properties -- **`permission`:** A `BasicPermission` that controls visibility and access to the action through the permissions framework. See [Permissions](#permissions) below. +- **`visibilityPermission`:** A `BasicPermission` that controls visibility and access to the action through the permissions framework. See [Permissions](#permissions) below. - **`attributes`:** Object containing behavioral flags: - **`destructive`:** Boolean indicating if the action modifies or deletes data - **`idempotent`:** Boolean indicating if running the action multiple times produces the same result @@ -160,7 +160,7 @@ export const myPlugin = createBackendPlugin({ ## Permissions -Actions can optionally declare a `permission` to control visibility and access through the Backstage permissions framework. When a permission is set, the action is only visible in listings and accessible by users who are authorized. +Actions can optionally declare a `visibilityPermission` to control visibility and access through the Backstage permissions framework. When a permission is set, the action is only visible in listings and accessible by users who are authorized. When accessed via the Actions Service or the `/.backstage/actions/v1/...` HTTP endpoints, actions that are denied by the permission policy are filtered from list results and return a `404 Not Found` on invocation, as if they don't exist. @@ -181,7 +181,7 @@ actionsRegistry.register({ name: 'delete-entity', title: 'Delete Entity', description: 'Removes an entity from the catalog', - permission: myDeletePermission, + visibilityPermission: myDeletePermission, schema: { input: z => z.object({ entityRef: z.string() }), output: z => z.object({ deleted: z.boolean() }), @@ -193,7 +193,7 @@ actionsRegistry.register({ }); ``` -Actions without a `permission` field remain visible and accessible by all callers, preserving backwards compatibility. +Actions without a `visibilityPermission` field remain visible and accessible by all callers, preserving backwards compatibility. ## Best Practices diff --git a/docs/backend-system/core-services/actions.md b/docs/backend-system/core-services/actions.md index d1680b2380..fc2eabc0f3 100644 --- a/docs/backend-system/core-services/actions.md +++ b/docs/backend-system/core-services/actions.md @@ -67,7 +67,7 @@ backend: ### Permissions -Actions registered with a `permission` field are automatically checked against the permissions framework. When listing actions, any actions denied by the active permission policy are filtered out of the results. When invoking a denied action, a `404 Not Found` error is returned. See the [Actions Registry Permissions](./actions-registry.md#permissions) documentation for how to configure permissions on actions. +Actions registered with a `visibilityPermission` field are automatically checked against the permissions framework. When listing actions, any actions denied by the active permission policy are filtered out of the results. When invoking a denied action, a `404 Not Found` error is returned. See the [Actions Registry Permissions](./actions-registry.md#permissions) documentation for how to configure permissions on actions. ## Using the Service diff --git a/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts b/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts index 974576dd81..8cda4c675f 100644 --- a/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts +++ b/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts @@ -148,9 +148,9 @@ export class DefaultActionsRegistryService implements ActionsRegistryService { throw new NotFoundError(`Action "${req.params.actionId}" not found`); } - if (action.permission) { + if (action.visibilityPermission) { const [decision] = await this.permissions.authorize( - [{ permission: action.permission }], + [{ permission: action.visibilityPermission }], { credentials }, ); if (decision.result === AuthorizeResult.DENY) { @@ -204,8 +204,8 @@ export class DefaultActionsRegistryService implements ActionsRegistryService { throw new Error(`Action with id "${id}" is already registered`); } - if (options.permission) { - this.permissionsRegistry.addPermissions([options.permission]); + if (options.visibilityPermission) { + this.permissionsRegistry.addPermissions([options.visibilityPermission]); } this.actions.set(id, options); @@ -216,7 +216,7 @@ export class DefaultActionsRegistryService implements ActionsRegistryService { credentials: BackstageCredentials, ): Promise { const permissionedEntries = entries.filter( - ([_, action]) => action.permission, + ([_, action]) => action.visibilityPermission, ); if (permissionedEntries.length === 0) { @@ -225,7 +225,7 @@ export class DefaultActionsRegistryService implements ActionsRegistryService { const decisions = await this.permissions.authorize( permissionedEntries.map(([_, action]) => ({ - permission: action.permission!, + permission: action.visibilityPermission!, })), { credentials }, ); 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 21123cdca5..56327031ec 100644 --- a/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts +++ b/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts @@ -592,7 +592,7 @@ describe('actionsRegistryServiceFactory', () => { name: 'protected-action', title: 'Protected Action', description: 'Permission required', - permission: testPermission, + visibilityPermission: testPermission, schema: { input: z => z.object({}), output: z => z.object({}), @@ -640,7 +640,7 @@ describe('actionsRegistryServiceFactory', () => { name: 'protected-action', title: 'Protected Action', description: 'Permission required', - permission: testPermission, + visibilityPermission: testPermission, schema: { input: z => z.object({}), output: z => z.object({}), @@ -688,7 +688,7 @@ describe('actionsRegistryServiceFactory', () => { name: 'protected-action', title: 'Protected Action', description: 'Permission required', - permission: testPermission, + visibilityPermission: testPermission, schema: { input: z => z.object({}), output: z => z.object({}), @@ -741,7 +741,7 @@ describe('actionsRegistryServiceFactory', () => { name: 'protected-action', title: 'Protected Action', description: 'Permission required', - permission: testPermission, + visibilityPermission: testPermission, schema: { input: z => z.object({}), output: z => z.object({ ok: z.boolean() }), @@ -793,7 +793,7 @@ describe('actionsRegistryServiceFactory', () => { name: 'protected-action', title: 'Protected Action', description: 'Permission required', - permission: testPermission, + visibilityPermission: testPermission, schema: { input: z => z.object({}), output: z => z.object({}), @@ -840,7 +840,7 @@ describe('actionsRegistryServiceFactory', () => { name: 'protected-action', title: 'Protected Action', description: 'Permission required', - permission: testPermission, + visibilityPermission: testPermission, schema: { input: z => z.object({}), output: z => z.object({}), diff --git a/packages/backend-plugin-api/report-alpha.api.md b/packages/backend-plugin-api/report-alpha.api.md index 58acb5338b..729c56b2d5 100644 --- a/packages/backend-plugin-api/report-alpha.api.md +++ b/packages/backend-plugin-api/report-alpha.api.md @@ -32,7 +32,7 @@ export type ActionsRegistryActionOptions< input: (zod: typeof z) => TInputSchema; output: (zod: typeof z) => TOutputSchema; }; - permission?: BasicPermission; + visibilityPermission?: 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 cd8a271322..a707e350e9 100644 --- a/packages/backend-plugin-api/src/alpha/ActionsRegistryService.ts +++ b/packages/backend-plugin-api/src/alpha/ActionsRegistryService.ts @@ -43,7 +43,7 @@ export type ActionsRegistryActionOptions< input: (zod: typeof z) => TInputSchema; output: (zod: typeof z) => TOutputSchema; }; - permission?: BasicPermission; + visibilityPermission?: 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 dc81bb3c28..a17c82c6f2 100644 --- a/packages/backend-plugin-api/src/services/definitions/ActionsRegistryService.ts +++ b/packages/backend-plugin-api/src/services/definitions/ActionsRegistryService.ts @@ -41,7 +41,7 @@ export type ActionsRegistryActionOptions< input: (zod: typeof z) => TInputSchema; output: (zod: typeof z) => TOutputSchema; }; - permission?: BasicPermission; + visibilityPermission?: BasicPermission; attributes?: { destructive?: boolean; idempotent?: boolean; From 7bc798c0696c79e00b282ac854500ca1e48ae103 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 17 Mar 2026 09:51:36 +0100 Subject: [PATCH 5/6] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Patrik Oldsberg --- docs/backend-system/core-services/actions-registry.md | 9 +++++++-- .../actionsRegistry/DefaultActionsRegistryService.ts | 4 ++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/backend-system/core-services/actions-registry.md b/docs/backend-system/core-services/actions-registry.md index 23e8c2bff8..b2ebcc1d04 100644 --- a/docs/backend-system/core-services/actions-registry.md +++ b/docs/backend-system/core-services/actions-registry.md @@ -160,9 +160,14 @@ export const myPlugin = createBackendPlugin({ ## Permissions -Actions can optionally declare a `visibilityPermission` to control visibility and access through the Backstage permissions framework. When a permission is set, the action is only visible in listings and accessible by users who are authorized. +Actions can optionally declare a `visibilityPermission` to control visibility and access through the Backstage permissions framework. This permission is evaluated without any resource context and is intended for unconditional (non-resource) permissions only. When such a permission is set, the action is only visible in listings and accessible by users who are authorized. -When accessed via the Actions Service or the `/.backstage/actions/v1/...` HTTP endpoints, actions that are denied by the permission policy are filtered from list results and return a `404 Not Found` on invocation, as if they don't exist. +When accessed via the Actions Service or the `/.backstage/actions/v1/...` HTTP endpoints, actions that are not authorized by the permission policy are filtered from list results and return a `404 Not Found` on invocation, as if they don't exist. In particular: + +- `ALLOW` decisions make the action visible and invokable. +- `DENY` and `CONDITIONAL` decisions are both treated as not authorized, so the action is hidden from listings and returns `404 Not Found` when invoked. + +Because `visibilityPermission` is evaluated without resource context, resource-based or otherwise conditional permissions (that may normally return `CONDITIONAL` decisions) are not supported here and will effectively behave as denied. Permissions declared on actions are automatically registered with the `PermissionsRegistryService` so they appear in the permission policy system. diff --git a/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts b/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts index f4d9acca06..a2109ec1bf 100644 --- a/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts +++ b/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts @@ -154,7 +154,7 @@ export class DefaultActionsRegistryService implements ActionsRegistryService { [{ permission: action.visibilityPermission }], { credentials }, ); - if (decision.result === AuthorizeResult.DENY) { + if (decision.result !== AuthorizeResult.ALLOW) { throw new NotFoundError( `Action "${req.params.actionId}" not found`, ); @@ -233,7 +233,7 @@ export class DefaultActionsRegistryService implements ActionsRegistryService { const deniedIds = new Set( permissionedEntries - .filter((_, index) => decisions[index].result === AuthorizeResult.DENY) + .filter((_, index) => decisions[index].result !== AuthorizeResult.ALLOW) .map(([id]) => id), ); From 54f30c6c91c2eb2e0e806546edd5d7ca1a6af78a Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 17 Mar 2026 10:55:51 +0100 Subject: [PATCH 6/6] docs: simplify visibilityPermission docs to match type constraints Since visibilityPermission is typed as BasicPermission and authorize() returns DefinitivePolicyDecision (ALLOW/DENY only), remove misleading references to CONDITIONAL decisions which can never occur. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- docs/backend-system/core-services/actions-registry.md | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/docs/backend-system/core-services/actions-registry.md b/docs/backend-system/core-services/actions-registry.md index b2ebcc1d04..e8e2dc7bb2 100644 --- a/docs/backend-system/core-services/actions-registry.md +++ b/docs/backend-system/core-services/actions-registry.md @@ -160,14 +160,9 @@ export const myPlugin = createBackendPlugin({ ## Permissions -Actions can optionally declare a `visibilityPermission` to control visibility and access through the Backstage permissions framework. This permission is evaluated without any resource context and is intended for unconditional (non-resource) permissions only. When such a permission is set, the action is only visible in listings and accessible by users who are authorized. +Actions can optionally declare a `visibilityPermission` to control visibility and access through the Backstage permissions framework. The `visibilityPermission` must be a `BasicPermission` (not a resource permission). When set, the action is only visible in listings and accessible by callers who are authorized. -When accessed via the Actions Service or the `/.backstage/actions/v1/...` HTTP endpoints, actions that are not authorized by the permission policy are filtered from list results and return a `404 Not Found` on invocation, as if they don't exist. In particular: - -- `ALLOW` decisions make the action visible and invokable. -- `DENY` and `CONDITIONAL` decisions are both treated as not authorized, so the action is hidden from listings and returns `404 Not Found` when invoked. - -Because `visibilityPermission` is evaluated without resource context, resource-based or otherwise conditional permissions (that may normally return `CONDITIONAL` decisions) are not supported here and will effectively behave as denied. +When accessed via the Actions Service or the `/.backstage/actions/v1/...` HTTP endpoints, actions that are denied by the permission policy are filtered from list results and return a `404 Not Found` on invocation, as if they don't exist. Permissions declared on actions are automatically registered with the `PermissionsRegistryService` so they appear in the permission policy system.