feat: add permissions integration to actions registry

Signed-off-by: benjdlambert <ben@blam.sh>
This commit is contained in:
benjdlambert
2026-03-12 09:58:04 +01:00
parent 26b5f7a5f9
commit cc8348ef2e
10 changed files with 352 additions and 4 deletions
+5
View File
@@ -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.
@@ -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.
+1
View File
@@ -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",
@@ -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<string, ActionsRegistryActionOptions<any, any>> =
@@ -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<any, any>][],
credentials: Parameters<PermissionsService['authorize']>[1]['credentials'],
): Promise<[string, ActionsRegistryActionOptions<any, any>][]> {
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));
}
}
@@ -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() }),
);
});
});
});
@@ -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());
@@ -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;
@@ -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;
@@ -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;
+1
View File
@@ -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"