ActionsRegistry: Enable filtering of Actions from sources (#32134)

* feat(backend-defaults): add config schema for action filtering

Signed-off-by: benjdlambert <ben@blam.sh>

* feat(backend-defaults): implement action filtering with glob patterns and attributes

Signed-off-by: benjdlambert <ben@blam.sh>

* test(backend-defaults): add edge case tests for action filtering

Signed-off-by: benjdlambert <ben@blam.sh>

* chore: add changeset for action filtering feature

Signed-off-by: benjdlambert <ben@blam.sh>

* feat: code review comments

Signed-off-by: benjdlambert <ben@blam.sh>

* chore: should always exclude

Signed-off-by: benjdlambert <ben@blam.sh>

---------

Signed-off-by: benjdlambert <ben@blam.sh>
This commit is contained in:
Ben Lambert
2026-01-09 16:31:12 +01:00
committed by GitHub
parent ff48856d29
commit 6fc00e6804
4 changed files with 706 additions and 1 deletions
+33
View File
@@ -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).
+98
View File
@@ -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;
};
}>;
};
};
/**
@@ -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<Config>): 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;
}
}
@@ -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', () => {