From 6fc00e6804c2031ce5ff82b3300597a9e67a65ce Mon Sep 17 00:00:00 2001 From: Ben Lambert Date: Fri, 9 Jan 2026 16:31:12 +0100 Subject: [PATCH] `ActionsRegistry`: Enable filtering of Actions from sources (#32134) * feat(backend-defaults): add config schema for action filtering Signed-off-by: benjdlambert * feat(backend-defaults): implement action filtering with glob patterns and attributes Signed-off-by: benjdlambert * test(backend-defaults): add edge case tests for action filtering Signed-off-by: benjdlambert * chore: add changeset for action filtering feature Signed-off-by: benjdlambert * feat: code review comments Signed-off-by: benjdlambert * chore: should always exclude Signed-off-by: benjdlambert --------- Signed-off-by: benjdlambert --- .changeset/action-filtering-feature.md | 33 ++ packages/backend-defaults/config.d.ts | 98 ++++ .../actions/DefaultActionsService.ts | 106 +++- .../actions/actionsServiceFactory.test.ts | 470 ++++++++++++++++++ 4 files changed, 706 insertions(+), 1 deletion(-) create mode 100644 .changeset/action-filtering-feature.md diff --git a/.changeset/action-filtering-feature.md b/.changeset/action-filtering-feature.md new file mode 100644 index 0000000000..a397d8d11a --- /dev/null +++ b/.changeset/action-filtering-feature.md @@ -0,0 +1,33 @@ +--- +'@backstage/backend-defaults': minor +--- + +Added action filtering support with glob patterns and attribute constraints. + +The `ActionsService` now supports filtering actions based on configuration. This allows controlling which actions are exposed to consumers like the MCP backend. + +Configuration example: + +```yaml +backend: + actions: + pluginSources: + - catalog + - scaffolder + filter: + include: + - id: 'catalog:*' + attributes: + destructive: false + - id: 'scaffolder:*' + exclude: + - id: '*:delete-*' + - attributes: + readOnly: false +``` + +Filtering logic: + +- `include`: Rules for actions to include. Each rule can specify an `id` glob pattern and/or `attributes` constraints. An action must match at least one rule to be included. If no include rules are specified, all actions are included by default. +- `exclude`: Rules for actions to exclude. Takes precedence over include rules. +- Each rule combines `id` and `attributes` with AND logic (both must match if specified). diff --git a/packages/backend-defaults/config.d.ts b/packages/backend-defaults/config.d.ts index 8095e24660..5a651ef6cd 100644 --- a/packages/backend-defaults/config.d.ts +++ b/packages/backend-defaults/config.d.ts @@ -154,6 +154,104 @@ export interface Config { * List of plugin sources to load actions from. */ pluginSources?: string[]; + + /** + * Filter configuration for actions. Allows controlling which actions + * are exposed to consumers based on patterns and attributes. + */ + filter?: { + /** + * Rules for actions to include. An action must match at least one rule to be included. + * Each rule can specify an id pattern and/or attribute constraints. + * If no include rules are specified, all actions are included by default. + * + * @example + * ```yaml + * include: + * - id: 'catalog:*' + * attributes: + * destructive: false + * - id: 'scaffolder:*' + * ``` + */ + include?: Array<{ + /** + * Glob pattern for action IDs to match. + * Action IDs have the format `{pluginId}:{actionName}`. + * @example 'catalog:*' + */ + id?: string; + + /** + * Attribute constraints. All specified attributes must match. + * Actions are compared against their resolved attributes (with defaults applied). + */ + attributes?: { + /** + * If specified, only match actions where destructive matches this value. + * Actions default to destructive: true if not explicitly set. + */ + destructive?: boolean; + + /** + * If specified, only match actions where readOnly matches this value. + * Actions default to readOnly: false if not explicitly set. + */ + readOnly?: boolean; + + /** + * If specified, only match actions where idempotent matches this value. + * Actions default to idempotent: false if not explicitly set. + */ + idempotent?: boolean; + }; + }>; + + /** + * Rules for actions to exclude. Exclusions take precedence over inclusions. + * Each rule can specify an id pattern and/or attribute constraints. + * + * @example + * ```yaml + * exclude: + * - id: '*:delete-*' + * - attributes: + * readOnly: false + * ``` + */ + exclude?: Array<{ + /** + * Glob pattern for action IDs to match. + * Action IDs have the format `{pluginId}:{actionName}`. + * @example '*:delete-*' + */ + id?: string; + + /** + * Attribute constraints. All specified attributes must match. + * Actions are compared against their resolved attributes (with defaults applied). + */ + attributes?: { + /** + * If specified, only match actions where destructive matches this value. + * Actions default to destructive: true if not explicitly set. + */ + destructive?: boolean; + + /** + * If specified, only match actions where readOnly matches this value. + * Actions default to readOnly: false if not explicitly set. + */ + readOnly?: boolean; + + /** + * If specified, only match actions where idempotent matches this value. + * Actions default to idempotent: false if not explicitly set. + */ + idempotent?: boolean; + }; + }>; + }; }; /** diff --git a/packages/backend-defaults/src/alpha/entrypoints/actions/DefaultActionsService.ts b/packages/backend-defaults/src/alpha/entrypoints/actions/DefaultActionsService.ts index 155bbf8b35..30aeb55227 100644 --- a/packages/backend-defaults/src/alpha/entrypoints/actions/DefaultActionsService.ts +++ b/packages/backend-defaults/src/alpha/entrypoints/actions/DefaultActionsService.ts @@ -26,6 +26,8 @@ import { ActionsService, ActionsServiceAction, } from '@backstage/backend-plugin-api/alpha'; +import { Minimatch } from 'minimatch'; +import { Config } from '@backstage/config'; export class DefaultActionsService implements ActionsService { private readonly discovery: DiscoveryService; @@ -86,7 +88,7 @@ export class DefaultActionsService implements ActionsService { }), ); - return { actions: remoteActionsList.flat() }; + return { actions: this.applyFilters(remoteActionsList.flat()) }; } async invoke(opts: { @@ -148,4 +150,106 @@ export class DefaultActionsService implements ActionsService { } return id.substring(0, colonIndex); } + + private applyFilters( + actions: ActionsServiceAction[], + ): ActionsServiceAction[] { + const filterConfig = this.config.getOptionalConfig( + 'backend.actions.filter', + ); + + if (!filterConfig) { + return actions; + } + + const includeRules = this.parseFilterRules( + filterConfig.getOptionalConfigArray('include') ?? [], + ); + const excludeRules = this.parseFilterRules( + filterConfig.getOptionalConfigArray('exclude') ?? [], + ); + + return actions.filter(action => { + const excluded = excludeRules.some(rule => + this.matchesRule(action, rule), + ); + + if (excluded) { + return false; + } + + // If no include rules, include by default + if (includeRules.length === 0) { + return true; + } + + // Must match at least one include rule + return includeRules.some(rule => this.matchesRule(action, rule)); + }); + } + + private parseFilterRules(configArray: Array): Array<{ + idMatcher?: Minimatch; + attributes?: Partial< + Record<'destructive' | 'readOnly' | 'idempotent', boolean> + >; + }> { + return configArray.map(ruleConfig => { + const idPattern = ruleConfig.getOptionalString('id'); + const attributesConfig = ruleConfig.getOptionalConfig('attributes'); + + const rule: { + idMatcher?: Minimatch; + attributes?: Partial< + Record<'destructive' | 'readOnly' | 'idempotent', boolean> + >; + } = {}; + + if (idPattern) { + rule.idMatcher = new Minimatch(idPattern); + } + + if (attributesConfig) { + rule.attributes = {}; + for (const key of ['destructive', 'readOnly', 'idempotent'] as const) { + const value = attributesConfig.getOptionalBoolean(key); + if (value !== undefined) { + rule.attributes[key] = value; + } + } + } + + return rule; + }); + } + + private matchesRule( + action: ActionsServiceAction, + rule: { + idMatcher?: Minimatch; + attributes?: Partial< + Record<'destructive' | 'readOnly' | 'idempotent', boolean> + >; + }, + ): boolean { + // If id pattern is specified, it must match + if (rule.idMatcher && !rule.idMatcher.match(action.id)) { + return false; + } + + // If attributes are specified, all must match + if (rule.attributes) { + for (const [key, value] of Object.entries(rule.attributes)) { + if ( + action.attributes[ + key as 'destructive' | 'readOnly' | 'idempotent' + ] !== value + ) { + return false; + } + } + } + + return true; + } } diff --git a/packages/backend-defaults/src/alpha/entrypoints/actions/actionsServiceFactory.test.ts b/packages/backend-defaults/src/alpha/entrypoints/actions/actionsServiceFactory.test.ts index af2fcb0c12..dbce47bea4 100644 --- a/packages/backend-defaults/src/alpha/entrypoints/actions/actionsServiceFactory.test.ts +++ b/packages/backend-defaults/src/alpha/entrypoints/actions/actionsServiceFactory.test.ts @@ -115,6 +115,476 @@ describe('actionsServiceFactory', () => { expect(mockActionsListEndpoint).toHaveBeenCalledTimes(1); expect(mockNotFoundActionsListEndpoint).toHaveBeenCalledTimes(1); }); + + it('should filter actions based on include patterns', async () => { + const multipleActions: ActionsServiceAction[] = [ + { + ...mockActionsDefinition, + id: 'my-plugin:get-entity', + name: 'get-entity', + }, + { + ...mockActionsDefinition, + id: 'my-plugin:delete-entity', + name: 'delete-entity', + }, + { + ...mockActionsDefinition, + id: 'other-plugin:get-thing', + name: 'get-thing', + }, + ]; + + server.use( + rest.get( + 'http://localhost:0/api/my-plugin/.backstage/actions/v1/actions', + (_req, res, ctx) => + res( + ctx.json({ + actions: multipleActions.filter(a => + a.id.startsWith('my-plugin:'), + ), + }), + ), + ), + rest.get( + 'http://localhost:0/api/other-plugin/.backstage/actions/v1/actions', + (_req, res, ctx) => + res( + ctx.json({ + actions: multipleActions.filter(a => + a.id.startsWith('other-plugin:'), + ), + }), + ), + ), + ); + + const subject = await ServiceFactoryTester.from(actionsServiceFactory, { + dependencies: [ + mockServices.rootConfig.factory({ + data: { + backend: { + actions: { + pluginSources: ['my-plugin', 'other-plugin'], + filter: { + include: [{ id: 'my-plugin:*' }], + }, + }, + }, + }, + }), + actionsServiceFactory, + httpRouterServiceFactory, + mockServices.httpAuth.factory({ + defaultCredentials: mockCredentials.service('user:default/mock'), + }), + mockServices.discovery.factory(), + actionsRegistryServiceFactory, + ], + }).getSubject(); + + const { actions } = await subject.list({ + credentials: mockCredentials.service('user:default/mock'), + }); + + expect(actions.map(a => a.id)).toEqual([ + 'my-plugin:get-entity', + 'my-plugin:delete-entity', + ]); + }); + + it('should filter actions based on exclude patterns', async () => { + const multipleActions: ActionsServiceAction[] = [ + { + ...mockActionsDefinition, + id: 'my-plugin:get-entity', + name: 'get-entity', + }, + { + ...mockActionsDefinition, + id: 'my-plugin:delete-entity', + name: 'delete-entity', + }, + ]; + + server.use( + rest.get( + 'http://localhost:0/api/my-plugin/.backstage/actions/v1/actions', + (_req, res, ctx) => res(ctx.json({ actions: multipleActions })), + ), + ); + + const subject = await ServiceFactoryTester.from(actionsServiceFactory, { + dependencies: [ + mockServices.rootConfig.factory({ + data: { + backend: { + actions: { + pluginSources: ['my-plugin'], + filter: { + exclude: [{ id: '*:delete-*' }], + }, + }, + }, + }, + }), + actionsServiceFactory, + httpRouterServiceFactory, + mockServices.httpAuth.factory({ + defaultCredentials: mockCredentials.service('user:default/mock'), + }), + mockServices.discovery.factory(), + actionsRegistryServiceFactory, + ], + }).getSubject(); + + const { actions } = await subject.list({ + credentials: mockCredentials.service('user:default/mock'), + }); + + expect(actions.map(a => a.id)).toEqual(['my-plugin:get-entity']); + }); + + it('should have exclude take precedence over include', async () => { + const multipleActions: ActionsServiceAction[] = [ + { + ...mockActionsDefinition, + id: 'my-plugin:get-entity', + name: 'get-entity', + }, + { + ...mockActionsDefinition, + id: 'my-plugin:delete-entity', + name: 'delete-entity', + }, + ]; + + server.use( + rest.get( + 'http://localhost:0/api/my-plugin/.backstage/actions/v1/actions', + (_req, res, ctx) => res(ctx.json({ actions: multipleActions })), + ), + ); + + const subject = await ServiceFactoryTester.from(actionsServiceFactory, { + dependencies: [ + mockServices.rootConfig.factory({ + data: { + backend: { + actions: { + pluginSources: ['my-plugin'], + filter: { + include: [{ id: 'my-plugin:*' }], + exclude: [{ id: 'my-plugin:delete-entity' }], + }, + }, + }, + }, + }), + actionsServiceFactory, + httpRouterServiceFactory, + mockServices.httpAuth.factory({ + defaultCredentials: mockCredentials.service('user:default/mock'), + }), + mockServices.discovery.factory(), + actionsRegistryServiceFactory, + ], + }).getSubject(); + + const { actions } = await subject.list({ + credentials: mockCredentials.service('user:default/mock'), + }); + + expect(actions.map(a => a.id)).toEqual(['my-plugin:get-entity']); + }); + + it('should always apply exclude rules even when action matches include', async () => { + // This tests that exclude is checked FIRST and always wins, + // regardless of whether the action would match an include rule + const multipleActions: ActionsServiceAction[] = [ + { + ...mockActionsDefinition, + id: 'my-plugin:safe-action', + name: 'safe-action', + attributes: { + destructive: false, + readOnly: true, + idempotent: true, + }, + }, + { + ...mockActionsDefinition, + id: 'my-plugin:dangerous-action', + name: 'dangerous-action', + attributes: { + destructive: true, + readOnly: false, + idempotent: false, + }, + }, + ]; + + server.use( + rest.get( + 'http://localhost:0/api/my-plugin/.backstage/actions/v1/actions', + (_req, res, ctx) => res(ctx.json({ actions: multipleActions })), + ), + ); + + const subject = await ServiceFactoryTester.from(actionsServiceFactory, { + dependencies: [ + mockServices.rootConfig.factory({ + data: { + backend: { + actions: { + pluginSources: ['my-plugin'], + filter: { + // Include all my-plugin actions + include: [{ id: 'my-plugin:*' }], + // But exclude any destructive ones + exclude: [{ attributes: { destructive: true } }], + }, + }, + }, + }, + }), + actionsServiceFactory, + httpRouterServiceFactory, + mockServices.httpAuth.factory({ + defaultCredentials: mockCredentials.service('user:default/mock'), + }), + mockServices.discovery.factory(), + actionsRegistryServiceFactory, + ], + }).getSubject(); + + const { actions } = await subject.list({ + credentials: mockCredentials.service('user:default/mock'), + }); + + // dangerous-action matches include (my-plugin:*) but is excluded due to destructive: true + expect(actions.map(a => a.id)).toEqual(['my-plugin:safe-action']); + }); + + it('should filter actions based on attribute constraints', async () => { + const multipleActions: ActionsServiceAction[] = [ + { + ...mockActionsDefinition, + id: 'my-plugin:read-action', + name: 'read-action', + attributes: { + destructive: false, + readOnly: true, + idempotent: true, + }, + }, + { + ...mockActionsDefinition, + id: 'my-plugin:write-action', + name: 'write-action', + attributes: { + destructive: true, + readOnly: false, + idempotent: false, + }, + }, + ]; + + server.use( + rest.get( + 'http://localhost:0/api/my-plugin/.backstage/actions/v1/actions', + (_req, res, ctx) => res(ctx.json({ actions: multipleActions })), + ), + ); + + const subject = await ServiceFactoryTester.from(actionsServiceFactory, { + dependencies: [ + mockServices.rootConfig.factory({ + data: { + backend: { + actions: { + pluginSources: ['my-plugin'], + filter: { + include: [ + { + attributes: { + readOnly: true, + }, + }, + ], + }, + }, + }, + }, + }), + actionsServiceFactory, + httpRouterServiceFactory, + mockServices.httpAuth.factory({ + defaultCredentials: mockCredentials.service('user:default/mock'), + }), + mockServices.discovery.factory(), + actionsRegistryServiceFactory, + ], + }).getSubject(); + + const { actions } = await subject.list({ + credentials: mockCredentials.service('user:default/mock'), + }); + + expect(actions.map(a => a.id)).toEqual(['my-plugin:read-action']); + }); + + it('should combine pattern and attribute filtering with AND logic', async () => { + const multipleActions: ActionsServiceAction[] = [ + { + ...mockActionsDefinition, + id: 'my-plugin:safe-read', + name: 'safe-read', + attributes: { + destructive: false, + readOnly: true, + idempotent: true, + }, + }, + { + ...mockActionsDefinition, + id: 'my-plugin:dangerous-read', + name: 'dangerous-read', + attributes: { + destructive: true, + readOnly: true, + idempotent: false, + }, + }, + { + ...mockActionsDefinition, + id: 'other-plugin:safe-read', + name: 'safe-read', + attributes: { + destructive: false, + readOnly: true, + idempotent: true, + }, + }, + ]; + + server.use( + rest.get( + 'http://localhost:0/api/my-plugin/.backstage/actions/v1/actions', + (_req, res, ctx) => + res( + ctx.json({ + actions: multipleActions.filter(a => + a.id.startsWith('my-plugin:'), + ), + }), + ), + ), + rest.get( + 'http://localhost:0/api/other-plugin/.backstage/actions/v1/actions', + (_req, res, ctx) => + res( + ctx.json({ + actions: multipleActions.filter(a => + a.id.startsWith('other-plugin:'), + ), + }), + ), + ), + ); + + const subject = await ServiceFactoryTester.from(actionsServiceFactory, { + dependencies: [ + mockServices.rootConfig.factory({ + data: { + backend: { + actions: { + pluginSources: ['my-plugin', 'other-plugin'], + filter: { + include: [ + { + id: 'my-plugin:*', + attributes: { + destructive: false, + }, + }, + ], + }, + }, + }, + }, + }), + actionsServiceFactory, + httpRouterServiceFactory, + mockServices.httpAuth.factory({ + defaultCredentials: mockCredentials.service('user:default/mock'), + }), + mockServices.discovery.factory(), + actionsRegistryServiceFactory, + ], + }).getSubject(); + + const { actions } = await subject.list({ + credentials: mockCredentials.service('user:default/mock'), + }); + + // Only my-plugin:safe-read matches: my-plugin:* pattern AND destructive: false + expect(actions.map(a => a.id)).toEqual(['my-plugin:safe-read']); + }); + + it('should return all actions when no filter config is provided', async () => { + const multipleActions: ActionsServiceAction[] = [ + { + ...mockActionsDefinition, + id: 'my-plugin:action-one', + name: 'action-one', + }, + { + ...mockActionsDefinition, + id: 'my-plugin:action-two', + name: 'action-two', + }, + ]; + + server.use( + rest.get( + 'http://localhost:0/api/my-plugin/.backstage/actions/v1/actions', + (_req, res, ctx) => res(ctx.json({ actions: multipleActions })), + ), + ); + + const subject = await ServiceFactoryTester.from(actionsServiceFactory, { + dependencies: [ + mockServices.rootConfig.factory({ + data: { + backend: { + actions: { + pluginSources: ['my-plugin'], + // No filter config + }, + }, + }, + }), + actionsServiceFactory, + httpRouterServiceFactory, + mockServices.httpAuth.factory({ + defaultCredentials: mockCredentials.service('user:default/mock'), + }), + mockServices.discovery.factory(), + actionsRegistryServiceFactory, + ], + }).getSubject(); + + const { actions } = await subject.list({ + credentials: mockCredentials.service('user:default/mock'), + }); + + expect(actions.map(a => a.id)).toEqual([ + 'my-plugin:action-one', + 'my-plugin:action-two', + ]); + }); }); describe('invoke', () => {