diff --git a/app-config.yaml b/app-config.yaml index 89e417fad3..5651b03f91 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -35,7 +35,6 @@ backend: # auth: # keys: # - secret: ${BACKEND_SECRET} - auth: # TODO: once plugins have been migrated we can remove this, but right now it # is require for the backend-next to work in this repo diff --git a/packages/backend-defaults/config.d.ts b/packages/backend-defaults/config.d.ts index 14732bbccd..1ef054a537 100644 --- a/packages/backend-defaults/config.d.ts +++ b/packages/backend-defaults/config.d.ts @@ -121,6 +121,16 @@ export interface Config { }; }; + /** + * Options used by the default actions service. + */ + actions?: { + /** + * List of plugin sources to load actions from. + */ + pluginSources?: string[]; + }; + /** * Options used by the default auth, httpAuth and userInfo services. */ diff --git a/packages/backend-defaults/src/entrypoints/actions/DefaultActionsService.ts b/packages/backend-defaults/src/entrypoints/actions/DefaultActionsService.ts new file mode 100644 index 0000000000..73920015a0 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/actions/DefaultActionsService.ts @@ -0,0 +1,99 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + ActionsRegistryAction, + ActionsService, + ActionsServiceAction, + DiscoveryService, + LoggerService, + RootConfigService, +} from '@backstage/backend-plugin-api'; +import { NotFoundError, ResponseError } from '@backstage/errors'; +import { JsonObject } from '@backstage/types'; + +export class DefaultActionsService implements ActionsService { + private constructor( + private readonly discovery: DiscoveryService, + private readonly config: RootConfigService, + private readonly logger: LoggerService, + ) {} + + static create({ + discovery, + config, + logger, + }: { + discovery: DiscoveryService; + config: RootConfigService; + logger: LoggerService; + }) { + return new DefaultActionsService(discovery, config, logger); + } + + async listActions() { + const pluginSources = + this.config.getOptionalStringArray('actions.pluginSources') ?? []; + + const remoteActionsList = await Promise.all( + pluginSources.map(async source => { + const pluginBaseUrl = await this.discovery.getBaseUrl(source); + const response = await fetch(`${pluginBaseUrl}/.backstage/v1/actions`); + if (!response.ok) { + this.logger.warn(`Failed to fetch actions from ${source}`); + return []; + } + + const { actions } = (await response.json()) as { + actions: Omit, 'action'>[]; + }; + + return actions; + }), + ); + + return { actions: remoteActionsList.flat() }; + } + + async invokeAction(opts: { id: string; input?: JsonObject }) { + const pluginId = this.pluginIdFromActionId(opts.id); + + const baseUrl = await this.discovery.getBaseUrl(pluginId); + const response = await fetch( + `${baseUrl}/.backstage/v1/actions/${opts.id}/invoke`, + { + method: 'POST', + body: JSON.stringify(opts.input), + headers: { + 'Content-Type': 'application/json', + }, + }, + ); + + if (!response.ok) { + throw await ResponseError.fromResponse(response); + } + const { output } = await response.json(); + return { output }; + } + + private pluginIdFromActionId(id: string): string { + const colonIndex = id.indexOf(':'); + if (colonIndex === -1) { + throw new Error(`Invalid action id: ${id}`); + } + return id.substring(0, colonIndex); + } +} diff --git a/packages/backend-defaults/src/entrypoints/actions/actionsServiceFactory.ts b/packages/backend-defaults/src/entrypoints/actions/actionsServiceFactory.ts new file mode 100644 index 0000000000..721769b847 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/actions/actionsServiceFactory.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { createServiceFactory } from '@backstage/backend-plugin-api'; +import { coreServices } from '@backstage/backend-plugin-api'; +import { DefaultActionsService } from './DefaultActionsService'; + +export const actionsServiceFactory = createServiceFactory({ + service: coreServices.actions, + deps: { + discovery: coreServices.discovery, + config: coreServices.rootConfig, + logger: coreServices.logger, + }, + factory: ({ discovery, config, logger }) => + DefaultActionsService.create({ + discovery, + config, + logger, + }), +}); diff --git a/packages/backend-defaults/src/entrypoints/actions/index.ts b/packages/backend-defaults/src/entrypoints/actions/index.ts new file mode 100644 index 0000000000..379ce77f52 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/actions/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ diff --git a/packages/backend-defaults/src/entrypoints/actionsRegistry/PluginActionsRegistry.ts b/packages/backend-defaults/src/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts similarity index 58% rename from packages/backend-defaults/src/entrypoints/actionsRegistry/PluginActionsRegistry.ts rename to packages/backend-defaults/src/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts index 8dedf40658..fb3a5cb396 100644 --- a/packages/backend-defaults/src/entrypoints/actionsRegistry/PluginActionsRegistry.ts +++ b/packages/backend-defaults/src/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts @@ -15,25 +15,30 @@ */ import { - ActionsRegistryAction, + ActionsRegistryActionOptions, + ActionsRegistryService, + AuthService, HttpAuthService, LoggerService, + PluginMetadataService, } from '@backstage/backend-plugin-api'; import PromiseRouter from 'express-promise-router'; import { Router, json } from 'express'; import { z, ZodType } from 'zod'; import zodToJsonSchema from 'zod-to-json-schema'; -import { InputError, NotFoundError } from '@backstage/errors'; +import { InputError, NotAllowedError, NotFoundError } from '@backstage/errors'; -export class PluginActionsRegistry { +export class DefaultActionsRegistryService implements ActionsRegistryService { private constructor( private readonly actions: Map< string, - ActionsRegistryAction + ActionsRegistryActionOptions >, private readonly router: Router, private readonly logger: LoggerService, private readonly httpAuth: HttpAuthService, + private readonly auth: AuthService, + private readonly metadata: PluginMetadataService, ) { this.bindRoutes(); } @@ -41,15 +46,21 @@ export class PluginActionsRegistry { static create({ httpAuth, logger, + auth, + metadata, }: { httpAuth: HttpAuthService; logger: LoggerService; - }): PluginActionsRegistry { - return new PluginActionsRegistry( + auth: AuthService; + metadata: PluginMetadataService; + }): DefaultActionsRegistryService { + return new DefaultActionsRegistryService( new Map(), PromiseRouter(), logger, httpAuth, + auth, + metadata, ); } @@ -58,24 +69,31 @@ export class PluginActionsRegistry { } register( - options: ActionsRegistryAction, + options: ActionsRegistryActionOptions, ): void { - this.actions.set(options.id, options as ActionsRegistryAction); + const id = `${this.metadata.getId()}:${options.name}`; + + if (this.actions.has(id)) { + throw new Error(`Action with id "${id}" is already registered`); + } + + this.actions.set(id, options); } private bindRoutes() { this.router.use(json()); - this.router.get('/.backstage/v1/actions', (_, res) => { + this.router.get('/.backstage/actions/v1/actions', (_, res) => { return res.json({ - actions: Array.from(this.actions.entries()).map(([_id, action]) => ({ + actions: Array.from(this.actions.entries()).map(([id, action]) => ({ + id, ...action, schema: { input: action.schema?.input - ? zodToJsonSchema(action.schema.input) + ? zodToJsonSchema(action.schema.input(z)) : zodToJsonSchema(z.any()), output: action.schema?.output - ? zodToJsonSchema(action.schema.output) + ? zodToJsonSchema(action.schema.output(z)) : zodToJsonSchema(z.any()), }, })), @@ -83,7 +101,7 @@ export class PluginActionsRegistry { }); this.router.post( - '/.backstage/v1/actions/:actionId/invoke', + '/.backstage/actions/v1/actions/:actionId/invoke', async (req, res) => { const action = this.actions.get(req.params.actionId); @@ -92,7 +110,7 @@ export class PluginActionsRegistry { } const input = action.schema?.input - ? action.schema.input.safeParse(req.body) + ? action.schema.input(z).safeParse(req.body) : ({ success: true, data: undefined } as const); if (!input.success) { @@ -103,7 +121,19 @@ export class PluginActionsRegistry { } const credentials = await this.httpAuth.credentials(req); + if (this.auth.isPrincipal(credentials, 'user')) { + if (!credentials.principal.actor) { + throw new NotAllowedError( + `Actions must be invoked by a service, not a user`, + ); + } + } else if (this.auth.isPrincipal(credentials, 'none')) { + throw new NotAllowedError( + `Actions must be invoked by a service, not an anonymous request`, + ); + } + // todo: wrap up in forwardederror? const result = await action.action({ input: input.data, credentials, @@ -111,8 +141,8 @@ export class PluginActionsRegistry { }); const output = action.schema?.output - ? action.schema.output.safeParse(result) - : ({ success: true, data: result } as const); + ? action.schema.output(z).safeParse(result?.output) + : ({ success: true, data: result?.output } as const); if (!output.success) { throw new InputError( @@ -121,7 +151,7 @@ export class PluginActionsRegistry { ); } - return res.json({ response: output.data }); + return res.json({ output: output.data }); }, ); } diff --git a/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts b/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts index b3b518a8e9..88f72239d0 100644 --- a/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts +++ b/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts @@ -25,13 +25,14 @@ import { import { httpRouterServiceFactory } from '../httpRouter'; import request from 'supertest'; import { actionsRegistryServiceFactory } from './actionsRegistryServiceFactory'; +import { InputError } from '@backstage/errors'; describe('actionsRegistryServiceFactory', () => { const defaultServices = [ actionsRegistryServiceFactory, httpRouterServiceFactory, mockServices.httpAuth.factory({ - defaultCredentials: mockCredentials.user(), + defaultCredentials: mockCredentials.service('user:default/mock'), }), ]; @@ -110,7 +111,7 @@ describe('actionsRegistryServiceFactory', () => { }); }); - describe('/.backstage/v1/actions', () => { + describe('/.backstage/actions/v1/actions', () => { it('should allow registering of actions', async () => { const pluginSubject = createBackendPlugin({ pluginId: 'my-plugin', @@ -146,7 +147,7 @@ describe('actionsRegistryServiceFactory', () => { }); const { body, status } = await request(server).get( - '/api/my-plugin/.backstage/v1/actions', + '/api/my-plugin/.backstage/actions/v1/actions', ); expect(status).toBe(200); @@ -205,7 +206,7 @@ describe('actionsRegistryServiceFactory', () => { }); const { body, status } = await request(server).get( - '/api/my-plugin/.backstage/v1/actions', + '/api/my-plugin/.backstage/actions/v1/actions', ); expect(status).toBe(200); @@ -226,11 +227,11 @@ describe('actionsRegistryServiceFactory', () => { }); }); - describe('/.backstage/v1/actions/:actionId/invoke', () => { + describe('/.backstage/actions/v1/actions/:actionId/invoke', () => { const mockAction = jest.fn(); beforeEach(() => { - mockAction.mockResolvedValue({ ok: true }); + mockAction.mockResolvedValue({ output: { ok: true } }); }); const pluginSubject = createBackendPlugin({ @@ -268,7 +269,7 @@ describe('actionsRegistryServiceFactory', () => { }); const { body, status } = await request(server).post( - '/api/my-plugin/.backstage/v1/actions/test/invoke', + '/api/my-plugin/.backstage/actions/v1/actions/test/invoke', ); expect(status).toBe(404); @@ -285,7 +286,9 @@ describe('actionsRegistryServiceFactory', () => { }); const { body, status } = await request(server) - .post('/api/my-plugin/.backstage/v1/actions/my-plugin:test/invoke') + .post( + '/api/my-plugin/.backstage/actions/v1/actions/my-plugin:test/invoke', + ) .send({ name: 123, }); @@ -306,7 +309,9 @@ describe('actionsRegistryServiceFactory', () => { }); await request(server) - .post('/api/my-plugin/.backstage/v1/actions/my-plugin:test/invoke') + .post( + '/api/my-plugin/.backstage/actions/v1/actions/my-plugin:test/invoke', + ) .send({ name: 'test', }); @@ -318,14 +323,43 @@ describe('actionsRegistryServiceFactory', () => { credentials: { $$type: '@backstage/BackstageCredentials', principal: { - type: 'user', - userEntityRef: 'user:default/mock', + type: 'service', + subject: 'user:default/mock', }, }, logger: expect.anything(), }); }); + it('should throw an error if the action is invoked by a user', async () => { + const testServices = [ + actionsRegistryServiceFactory, + httpRouterServiceFactory, + mockServices.httpAuth.factory({ + defaultCredentials: mockCredentials.user(), + }), + ]; + + const { server } = await startTestBackend({ + features: [pluginSubject, ...testServices], + }); + + const { body, status } = await request(server) + .post( + '/api/my-plugin/.backstage/actions/v1/actions/my-plugin:test/invoke', + ) + .send({ + name: 'test', + }); + + expect(status).toBe(403); + expect(body).toMatchObject({ + error: { + message: 'Actions must be invoked by a service, not a user', + }, + }); + }); + it('should validate the output of the action if provided', async () => { const { server } = await startTestBackend({ features: [pluginSubject, ...defaultServices], @@ -334,7 +368,9 @@ describe('actionsRegistryServiceFactory', () => { mockAction.mockResolvedValue({ ok: 'blob' }); const { body, status } = await request(server) - .post('/api/my-plugin/.backstage/v1/actions/my-plugin:test/invoke') + .post( + '/api/my-plugin/.backstage/actions/v1/actions/my-plugin:test/invoke', + ) .send({ name: 'test', }); @@ -355,13 +391,38 @@ describe('actionsRegistryServiceFactory', () => { }); const { body, status } = await request(server) - .post('/api/my-plugin/.backstage/v1/actions/my-plugin:test/invoke') + .post( + '/api/my-plugin/.backstage/actions/v1/actions/my-plugin:test/invoke', + ) .send({ name: 'test', }); expect(status).toBe(200); - expect(body).toMatchObject({ response: { ok: true } }); + expect(body).toMatchObject({ output: { ok: true } }); + }); + + it('should return the error from the action if it throws', async () => { + const { server } = await startTestBackend({ + features: [pluginSubject, ...defaultServices], + }); + + mockAction.mockRejectedValue(new InputError('test')); + + const { body, status } = await request(server) + .post( + '/api/my-plugin/.backstage/actions/v1/actions/my-plugin:test/invoke', + ) + .send({ + name: 'test', + }); + + expect(status).toBe(400); + expect(body).toMatchObject({ + error: { + message: 'test', + }, + }); }); }); }); diff --git a/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.ts b/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.ts index 424b60acc4..d894694091 100644 --- a/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.ts +++ b/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.ts @@ -15,12 +15,10 @@ */ import { - ActionsRegistryActionOptions, coreServices, createServiceFactory, } from '@backstage/backend-plugin-api'; -import { PluginActionsRegistry } from './PluginActionsRegistry'; -import { z, ZodType } from 'zod'; +import { DefaultActionsRegistryService } from './DefaultActionsRegistryService'; export const actionsRegistryServiceFactory = createServiceFactory({ service: coreServices.actionsRegistry, @@ -29,29 +27,18 @@ export const actionsRegistryServiceFactory = createServiceFactory({ httpRouter: coreServices.httpRouter, httpAuth: coreServices.httpAuth, logger: coreServices.logger, + auth: coreServices.auth, }, - factory: ({ metadata, httpRouter, httpAuth, logger }) => { - const pluginActionsRegistry = PluginActionsRegistry.create({ + factory: ({ metadata, httpRouter, httpAuth, logger, auth }) => { + const actionsRegistryService = DefaultActionsRegistryService.create({ httpAuth, logger, + auth, + metadata, }); - httpRouter.use(pluginActionsRegistry.getRouter()); + httpRouter.use(actionsRegistryService.getRouter()); - return { - async register< - TInputSchema extends ZodType, - TOutputSchema extends ZodType, - >(options: ActionsRegistryActionOptions) { - pluginActionsRegistry.register({ - ...options, - id: `${metadata.getId()}:${options.name}`, - schema: { - input: options.schema?.input?.(z), - output: options.schema?.output?.(z), - }, - }); - }, - }; + return actionsRegistryService; }, }); diff --git a/packages/backend-plugin-api/package.json b/packages/backend-plugin-api/package.json index 2f2c8fd552..666c703ed7 100644 --- a/packages/backend-plugin-api/package.json +++ b/packages/backend-plugin-api/package.json @@ -61,7 +61,9 @@ "@backstage/plugin-permission-node": "workspace:^", "@backstage/types": "workspace:^", "@types/express": "^4.17.6", + "@types/json-schema": "^7.0.6", "@types/luxon": "^3.0.0", + "json-schema": "^0.4.0", "knex": "^3.0.0", "luxon": "^3.0.0", "zod": "^3.22.4" diff --git a/packages/backend-plugin-api/src/services/definitions/ActionsRegistryService.ts b/packages/backend-plugin-api/src/services/definitions/ActionsRegistryService.ts index 895eeb9338..9240891227 100644 --- a/packages/backend-plugin-api/src/services/definitions/ActionsRegistryService.ts +++ b/packages/backend-plugin-api/src/services/definitions/ActionsRegistryService.ts @@ -23,25 +23,6 @@ export type ActionsRegistryActionContext = { credentials: BackstageCredentials; }; -// todo: opaque type? -export type ActionsRegistryAction< - TInputSchema extends ZodType, - TOutputSchema extends ZodType, -> = { - id: string; - name: string; - title: string; - description: string; - // todo: what about additional metadata? - schema?: { - input?: TInputSchema; - output?: TOutputSchema; - }; - action: ( - context: ActionsRegistryActionContext, - ) => Promise : void>; -}; - export type ActionsRegistryActionOptions< TInputSchema extends ZodType, TOutputSchema extends ZodType, @@ -49,14 +30,15 @@ export type ActionsRegistryActionOptions< name: string; title: string; description: string; - // todo: what about additional metadata? schema?: { input?: (zod: typeof z) => TInputSchema; output?: (zod: typeof z) => TOutputSchema; }; action: ( context: ActionsRegistryActionContext, - ) => Promise : void>; + ) => Promise< + TOutputSchema extends ZodType ? { output: z.infer } : void + >; }; export interface ActionsRegistryService { diff --git a/packages/backend-plugin-api/src/services/definitions/ActionsService.ts b/packages/backend-plugin-api/src/services/definitions/ActionsService.ts new file mode 100644 index 0000000000..7887546878 --- /dev/null +++ b/packages/backend-plugin-api/src/services/definitions/ActionsService.ts @@ -0,0 +1,36 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { JsonObject, JsonValue } from '@backstage/types'; +import { JSONSchema7 } from 'json-schema'; + +export type ActionsServiceAction = { + id: string; + name: string; + title: string; + description: string; + schema?: { + input?: JSONSchema7; + output?: JSONSchema7; + }; +}; + +export interface ActionsService { + listActions: () => Promise<{ actions: ActionsServiceAction[] }>; + invokeAction(opts: { + id: string; + input?: JsonObject; + }): Promise<{ output: JsonValue }>; +} diff --git a/packages/backend-plugin-api/src/services/definitions/coreServices.ts b/packages/backend-plugin-api/src/services/definitions/coreServices.ts index 48ee4c084f..c5282368ac 100644 --- a/packages/backend-plugin-api/src/services/definitions/coreServices.ts +++ b/packages/backend-plugin-api/src/services/definitions/coreServices.ts @@ -36,7 +36,22 @@ export namespace coreServices { }); /** - * Service for registering and managing actions. + * Service for calling distributed actions + * + * See {@link ActionsService} + * and {@link https://backstage.io/docs/backend-system/core-services/actions | the service docs} + * for more information. + * + * @public + */ + export const actions = createServiceRef< + import('./ActionsService').ActionsService + >({ + id: 'core.actions', + }); + + /** + * Service for registering and managing distributed actions. * * See {@link ActionsRegistryService} * and {@link https://backstage.io/docs/backend-system/core-services/actions-registry | the service docs} diff --git a/packages/backend-plugin-api/src/services/definitions/index.ts b/packages/backend-plugin-api/src/services/definitions/index.ts index 2967c7baab..a01544b60f 100644 --- a/packages/backend-plugin-api/src/services/definitions/index.ts +++ b/packages/backend-plugin-api/src/services/definitions/index.ts @@ -18,8 +18,9 @@ export type { ActionsRegistryService, ActionsRegistryActionOptions, ActionsRegistryActionContext, - ActionsRegistryAction, } from './ActionsRegistryService'; + +export type { ActionsService, ActionsServiceAction } from './ActionsService'; export type { AuditorService, AuditorServiceCreateEventOptions, diff --git a/yarn.lock b/yarn.lock index 26bdf48fb7..1809a03667 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3736,7 +3736,9 @@ __metadata: "@backstage/plugin-permission-node": "workspace:^" "@backstage/types": "workspace:^" "@types/express": "npm:^4.17.6" + "@types/json-schema": "npm:^7.0.6" "@types/luxon": "npm:^3.0.0" + json-schema: "npm:^0.4.0" knex: "npm:^3.0.0" luxon: "npm:^3.0.0" zod: "npm:^3.22.4"