diff --git a/packages/backend/src/plugins/scaffolder.ts b/packages/backend/src/plugins/scaffolder.ts index 505a514344..a2aa104406 100644 --- a/packages/backend/src/plugins/scaffolder.ts +++ b/packages/backend/src/plugins/scaffolder.ts @@ -55,7 +55,7 @@ export default async function createPlugin( database: env.database, catalogClient: catalogClient, reader: env.reader, - identity: env.identity, + discovery: env.discovery, scheduler: env.scheduler, permissions: env.permissions, actions, diff --git a/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts b/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts index a12fee2295..20ca420dae 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts @@ -16,7 +16,7 @@ export default async function createPlugin( database: env.database, reader: env.reader, catalogClient, - identity: env.identity, + discovery: env.discovery, permissions: env.permissions, }); } diff --git a/plugins/scaffolder-backend/api-report.md b/plugins/scaffolder-backend/api-report.md index f9a810ebea..502d55b9d5 100644 --- a/plugins/scaffolder-backend/api-report.md +++ b/plugins/scaffolder-backend/api-report.md @@ -4,12 +4,15 @@ ```ts import { ActionContext as ActionContext_2 } from '@backstage/plugin-scaffolder-node'; +import { AuthService } from '@backstage/backend-plugin-api'; import * as azure from '@backstage/plugin-scaffolder-backend-module-azure'; +import { BackstageCredentials } from '@backstage/backend-plugin-api'; import * as bitbucket from '@backstage/plugin-scaffolder-backend-module-bitbucket'; import * as bitbucketCloud from '@backstage/plugin-scaffolder-backend-module-bitbucket-cloud'; import * as bitbucketServer from '@backstage/plugin-scaffolder-backend-module-bitbucket-server'; import { CatalogApi } from '@backstage/catalog-client'; import { Config } from '@backstage/config'; +import { DiscoveryService } from '@backstage/backend-plugin-api'; import { Duration } from 'luxon'; import { executeShellCommand as executeShellCommand_2 } from '@backstage/plugin-scaffolder-node'; import { ExecuteShellCommandOptions } from '@backstage/plugin-scaffolder-node'; @@ -18,8 +21,8 @@ import { fetchContents as fetchContents_2 } from '@backstage/plugin-scaffolder-n import * as gerrit from '@backstage/plugin-scaffolder-backend-module-gerrit'; import * as github from '@backstage/plugin-scaffolder-backend-module-github'; import * as gitlab from '@backstage/plugin-scaffolder-backend-module-gitlab'; +import { HttpAuthService } from '@backstage/backend-plugin-api'; import { HumanDuration } from '@backstage/types'; -import { IdentityApi } from '@backstage/plugin-auth-node'; import { JsonObject } from '@backstage/types'; import { JsonValue } from '@backstage/types'; import { Knex } from 'knex'; @@ -28,6 +31,7 @@ import { Logger } from 'winston'; import { PermissionEvaluator } from '@backstage/plugin-permission-common'; import { PermissionRule } from '@backstage/plugin-permission-node'; import { PermissionRuleParams } from '@backstage/plugin-permission-common'; +import { PermissionsService } from '@backstage/backend-plugin-api'; import { PluginDatabaseManager } from '@backstage/backend-common'; import { PluginTaskScheduler } from '@backstage/backend-tasks'; import { RESOURCE_TYPE_SCAFFOLDER_ACTION } from '@backstage/plugin-scaffolder-common/alpha'; @@ -82,6 +86,7 @@ export interface CreateBuiltInActionsOptions { additionalTemplateFilters?: Record; // (undocumented) additionalTemplateGlobals?: Record; + auth?: AuthService; catalogClient: CatalogApi; config: Config; integrations: ScmIntegrations; @@ -92,6 +97,7 @@ export interface CreateBuiltInActionsOptions { export function createCatalogRegisterAction(options: { catalogClient: CatalogApi; integrations: ScmIntegrations; + auth?: AuthService; }): TemplateAction_2< | { catalogInfoUrl: string; @@ -126,6 +132,7 @@ export function createDebugLogAction(): TemplateAction_2< // @public export function createFetchCatalogEntityAction(options: { catalogClient: CatalogApi; + auth?: AuthService; }): TemplateAction_2< { entityRef?: string | undefined; @@ -452,6 +459,8 @@ export interface RouterOptions { // (undocumented) additionalTemplateGlobals?: Record; // (undocumented) + auth?: AuthService; + // (undocumented) catalogClient: CatalogApi; concurrentTasksLimit?: number; // (undocumented) @@ -459,7 +468,9 @@ export interface RouterOptions { // (undocumented) database: PluginDatabaseManager; // (undocumented) - identity?: IdentityApi; + discovery: DiscoveryService; + // (undocumented) + httpAuth?: HttpAuthService; // (undocumented) lifecycle?: LifecycleService; // (undocumented) @@ -469,7 +480,7 @@ export interface RouterOptions { TemplatePermissionRuleInput | ActionPermissionRuleInput >; // (undocumented) - permissions?: PermissionEvaluator; + permissions?: PermissionsService; // (undocumented) reader: UrlReader; // (undocumented) @@ -514,7 +525,7 @@ export type TaskEventType = TaskEventType_2; export class TaskManager implements TaskContext_2 { // (undocumented) get cancelSignal(): AbortSignal; - // (undocumented) + // (undocumented)gs complete(result: TaskCompletionState_2, metadata?: JsonObject): Promise; // (undocumented) static create( @@ -537,8 +548,12 @@ export class TaskManager implements TaskContext_2 { | undefined >; // (undocumented) + getInitiatorCredentials(): Promise; + // (undocumented) getWorkspaceName(): Promise; // (undocumented) + isDryRun?: boolean | undefined; + // (undocumented) get secrets(): TaskSecrets_2 | undefined; // (undocumented) get spec(): TaskSpecV1beta3; diff --git a/plugins/scaffolder-backend/src/ScaffolderPlugin.ts b/plugins/scaffolder-backend/src/ScaffolderPlugin.ts index 61c5ed12ee..a8213cf585 100644 --- a/plugins/scaffolder-backend/src/ScaffolderPlugin.ts +++ b/plugins/scaffolder-backend/src/ScaffolderPlugin.ts @@ -90,7 +90,10 @@ export const scaffolderPlugin = createBackendPlugin({ reader: coreServices.urlReader, permissions: coreServices.permissions, database: coreServices.database, + auth: coreServices.auth, + discovery: coreServices.discovery, httpRouter: coreServices.httpRouter, + httpAuth: coreServices.httpAuth, catalogClient: catalogServiceRef, }, async init({ @@ -99,7 +102,10 @@ export const scaffolderPlugin = createBackendPlugin({ lifecycle, reader, database, + auth, + discovery, httpRouter, + httpAuth, catalogClient, permissions, }) { @@ -128,8 +134,8 @@ export const scaffolderPlugin = createBackendPlugin({ createDebugLogAction(), createWaitAction(), // todo(blam): maybe these should be a -catalog module? - createCatalogRegisterAction({ catalogClient, integrations }), - createFetchCatalogEntityAction({ catalogClient }), + createCatalogRegisterAction({ catalogClient, integrations, auth }), + createFetchCatalogEntityAction({ catalogClient, auth }), createCatalogWriteAction(), createFilesystemDeleteAction(), createFilesystemRenameAction(), @@ -152,6 +158,9 @@ export const scaffolderPlugin = createBackendPlugin({ taskBroker, additionalTemplateFilters, additionalTemplateGlobals, + auth, + httpAuth, + discovery, permissions, }); httpRouter.use(router); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/fetch.examples.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/fetch.examples.test.ts index a368144784..549ed923c2 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/fetch.examples.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/fetch.examples.test.ts @@ -20,6 +20,7 @@ import { Entity } from '@backstage/catalog-model'; import { createFetchCatalogEntityAction } from './fetch'; import { examples } from './fetch.examples'; import yaml from 'yaml'; +import { mockCredentials, mockServices } from '@backstage/backend-test-utils'; describe('catalog:fetch examples', () => { const getEntityByRef = jest.fn(); @@ -32,10 +33,18 @@ describe('catalog:fetch examples', () => { const action = createFetchCatalogEntityAction({ catalogClient: catalogClient as unknown as CatalogApi, + auth: mockServices.auth(), + }); + + const credentials = mockCredentials.user(); + + const token = mockCredentials.service.token({ + onBehalfOf: credentials, + targetPluginId: 'catalog', }); const mockContext = createMockActionContext({ - secrets: { backstageToken: 'secret' }, + secrets: { backstageToken: token }, }); beforeEach(() => { jest.resetAllMocks(); @@ -57,7 +66,7 @@ describe('catalog:fetch examples', () => { }); expect(getEntityByRef).toHaveBeenCalledWith('component:default/name', { - token: 'secret', + token, }); expect(mockContext.output).toHaveBeenCalledWith('entity', { metadata: { diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/fetch.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/fetch.test.ts index d3fd398a90..747414f206 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/fetch.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/fetch.test.ts @@ -18,6 +18,7 @@ import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test- import { CatalogApi } from '@backstage/catalog-client'; import { Entity } from '@backstage/catalog-model'; import { createFetchCatalogEntityAction } from './fetch'; +import { mockCredentials, mockServices } from '@backstage/backend-test-utils'; describe('catalog:fetch', () => { const getEntityByRef = jest.fn(); @@ -30,10 +31,18 @@ describe('catalog:fetch', () => { const action = createFetchCatalogEntityAction({ catalogClient: catalogClient as unknown as CatalogApi, + auth: mockServices.auth(), + }); + + const credentials = mockCredentials.user(); + + const token = mockCredentials.service.token({ + onBehalfOf: credentials, + targetPluginId: 'catalog', }); const mockContext = createMockActionContext({ - secrets: { backstageToken: 'secret' }, + secrets: { backstageToken: token }, }); beforeEach(() => { @@ -58,7 +67,7 @@ describe('catalog:fetch', () => { }); expect(getEntityByRef).toHaveBeenCalledWith('component:default/test', { - token: 'secret', + token, }); expect(mockContext.output).toHaveBeenCalledWith('entity', { metadata: { @@ -84,7 +93,7 @@ describe('catalog:fetch', () => { ).rejects.toThrow('Not found'); expect(getEntityByRef).toHaveBeenCalledWith('component:default/test', { - token: 'secret', + token, }); expect(mockContext.output).not.toHaveBeenCalled(); }); @@ -102,7 +111,7 @@ describe('catalog:fetch', () => { ).rejects.toThrow('Entity component:default/test not found'); expect(getEntityByRef).toHaveBeenCalledWith('component:default/test', { - token: 'secret', + token, }); expect(mockContext.output).not.toHaveBeenCalled(); }); @@ -127,7 +136,7 @@ describe('catalog:fetch', () => { }); expect(getEntityByRef).toHaveBeenCalledWith('group:ns/test', { - token: 'secret', + token, }); expect(mockContext.output).toHaveBeenCalledWith('entity', entity); }); @@ -157,7 +166,7 @@ describe('catalog:fetch', () => { expect(getEntitiesByRefs).toHaveBeenCalledWith( { entityRefs: ['component:default/test'] }, { - token: 'secret', + token, }, ); expect(mockContext.output).toHaveBeenCalledWith('entities', [ @@ -198,7 +207,7 @@ describe('catalog:fetch', () => { expect(getEntitiesByRefs).toHaveBeenCalledWith( { entityRefs: ['component:default/test', 'component:default/test2'] }, { - token: 'secret', + token, }, ); expect(mockContext.output).not.toHaveBeenCalled(); @@ -229,7 +238,7 @@ describe('catalog:fetch', () => { expect(getEntitiesByRefs).toHaveBeenCalledWith( { entityRefs: ['component:default/test', 'component:default/test2'] }, { - token: 'secret', + token, }, ); expect(mockContext.output).toHaveBeenCalledWith('entities', [ @@ -275,7 +284,7 @@ describe('catalog:fetch', () => { expect(getEntitiesByRefs).toHaveBeenCalledWith( { entityRefs: ['group:ns/test', 'user:default/test'] }, { - token: 'secret', + token, }, ); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/fetch.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/fetch.ts index 75c4bc94ce..824ca4deb3 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/fetch.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/fetch.ts @@ -19,6 +19,7 @@ import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; import { z } from 'zod'; import { parseEntityRef, stringifyEntityRef } from '@backstage/catalog-model'; import { examples } from './fetch.examples'; +import { AuthService } from '@backstage/backend-plugin-api'; const id = 'catalog:fetch'; @@ -29,8 +30,9 @@ const id = 'catalog:fetch'; */ export function createFetchCatalogEntityAction(options: { catalogClient: CatalogApi; + auth?: AuthService; }) { - const { catalogClient } = options; + const { catalogClient, auth } = options; return createTemplateAction({ id, @@ -88,13 +90,18 @@ export function createFetchCatalogEntityAction(options: { throw new Error('Missing entity reference or references'); } + const { token } = (await auth?.getPluginRequestToken({ + onBehalfOf: await ctx.getInitiatorCredentials(), + targetPluginId: 'catalog', + })) ?? { token: ctx.secrets?.backstageToken }; + if (entityRef) { const entity = await catalogClient.getEntityByRef( stringifyEntityRef( parseEntityRef(entityRef, { defaultKind, defaultNamespace }), ), { - token: ctx.secrets?.backstageToken, + token, }, ); @@ -114,7 +121,7 @@ export function createFetchCatalogEntityAction(options: { ), }, { - token: ctx.secrets?.backstageToken, + token, }, ); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.examples.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.examples.test.ts index db3d7151d8..d279af78dc 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.examples.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.examples.test.ts @@ -22,6 +22,7 @@ import { createCatalogRegisterAction } from './register'; import { Entity } from '@backstage/catalog-model'; import { examples } from './register.examples'; import yaml from 'yaml'; +import { mockCredentials, mockServices } from '@backstage/backend-test-utils'; describe('catalog:register', () => { const integrations = ScmIntegrations.fromConfig( @@ -40,6 +41,14 @@ describe('catalog:register', () => { const action = createCatalogRegisterAction({ integrations, catalogClient: catalogClient as unknown as CatalogApi, + auth: mockServices.auth(), + }); + + const credentials = mockCredentials.user(); + + const token = mockCredentials.service.token({ + onBehalfOf: credentials, + targetPluginId: 'catalog', }); const mockContext = createMockActionContext(); @@ -75,7 +84,7 @@ describe('catalog:register', () => { target: 'http://github.com/backstage/backstage/blob/master/catalog-info.yaml', }, - {}, + { token }, ); expect(addLocation).toHaveBeenNthCalledWith( 2, @@ -85,7 +94,7 @@ describe('catalog:register', () => { target: 'http://github.com/backstage/backstage/blob/master/catalog-info.yaml', }, - {}, + { token }, ); expect(mockContext.output).toHaveBeenCalledWith( diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.test.ts index 8f6219b64c..2db232afa0 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.test.ts @@ -20,6 +20,7 @@ import { ConfigReader } from '@backstage/config'; import { ScmIntegrations } from '@backstage/integration'; import { createCatalogRegisterAction } from './register'; import { Entity } from '@backstage/catalog-model'; +import { mockCredentials, mockServices } from '@backstage/backend-test-utils'; describe('catalog:register', () => { const integrations = ScmIntegrations.fromConfig( @@ -38,6 +39,14 @@ describe('catalog:register', () => { const action = createCatalogRegisterAction({ integrations, catalogClient: catalogClient as unknown as CatalogApi, + auth: mockServices.auth(), + }); + + const credentials = mockCredentials.user(); + + const token = mockCredentials.service.token({ + onBehalfOf: credentials, + targetPluginId: 'catalog', }); const mockContext = createMockActionContext(); @@ -88,7 +97,7 @@ describe('catalog:register', () => { type: 'url', target: 'http://foo/var', }, - {}, + { token }, ); expect(addLocation).toHaveBeenNthCalledWith( 2, @@ -97,7 +106,7 @@ describe('catalog:register', () => { type: 'url', target: 'http://foo/var', }, - {}, + { token }, ); expect(mockContext.output).toHaveBeenCalledWith( @@ -274,7 +283,7 @@ describe('catalog:register', () => { type: 'url', target: 'http://foo/var', }, - {}, + { token }, ); expect(addLocation).toHaveBeenNthCalledWith( 2, @@ -283,7 +292,7 @@ describe('catalog:register', () => { type: 'url', target: 'http://foo/var', }, - {}, + { token }, ); expect(mockContext.output).toHaveBeenCalledWith( @@ -320,7 +329,7 @@ describe('catalog:register', () => { type: 'url', target: 'http://foo/var', }, - {}, + { token }, ); expect(addLocation).toHaveBeenNthCalledWith( 2, @@ -329,7 +338,7 @@ describe('catalog:register', () => { type: 'url', target: 'http://foo/var', }, - {}, + { token }, ); expect(mockContext.output).toHaveBeenCalledWith( diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.ts index 39372f484f..eab31f14df 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.ts @@ -20,6 +20,7 @@ import { CatalogApi } from '@backstage/catalog-client'; import { stringifyEntityRef, Entity } from '@backstage/catalog-model'; import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; import { examples } from './register.examples'; +import { AuthService } from '@backstage/backend-plugin-api'; const id = 'catalog:register'; @@ -30,8 +31,9 @@ const id = 'catalog:register'; export function createCatalogRegisterAction(options: { catalogClient: CatalogApi; integrations: ScmIntegrations; + auth?: AuthService; }) { - const { catalogClient, integrations } = options; + const { catalogClient, integrations, auth } = options; return createTemplateAction< | { catalogInfoUrl: string; optional?: boolean } @@ -125,6 +127,11 @@ export function createCatalogRegisterAction(options: { ctx.logger.info(`Registering ${catalogInfoUrl} in the catalog`); + const { token } = (await auth?.getPluginRequestToken({ + onBehalfOf: await ctx.getInitiatorCredentials(), + targetPluginId: 'catalog', + })) ?? { token: ctx.secrets?.backstageToken }; + try { // 1st try to register the location, this will throw an error if the location already exists (see catch) await catalogClient.addLocation( @@ -132,9 +139,7 @@ export function createCatalogRegisterAction(options: { type: 'url', target: catalogInfoUrl, }, - ctx.secrets?.backstageToken - ? { token: ctx.secrets.backstageToken } - : {}, + token ? { token } : {}, ); } catch (e) { if (!input.optional) { @@ -151,9 +156,7 @@ export function createCatalogRegisterAction(options: { type: 'url', target: catalogInfoUrl, }, - ctx.secrets?.backstageToken - ? { token: ctx.secrets.backstageToken } - : {}, + token ? { token } : {}, ); if (result.entities.length) { diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts index 2ee0a7644f..a017c2e224 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts @@ -82,6 +82,7 @@ import { } from '@backstage/plugin-scaffolder-backend-module-gitlab'; import { createPublishGiteaAction } from '@backstage/plugin-scaffolder-backend-module-gitea'; +import { AuthService } from '@backstage/backend-plugin-api'; /** * The options passed to {@link createBuiltinActions} @@ -100,6 +101,10 @@ export interface CreateBuiltInActionsOptions { * The {@link @backstage/catalog-client#CatalogApi} that will be used in the default actions. */ catalogClient: CatalogApi; + /** + * The {@link @backstage/backend-plugin-api#AuthService} that will be used in the default actions. + */ + auth?: AuthService; /** * The {@link @backstage/config#Config} that will be used in the default actions. */ @@ -129,6 +134,7 @@ export const createBuiltinActions = ( reader, integrations, catalogClient, + auth, config, additionalTemplateFilters, additionalTemplateGlobals, @@ -205,8 +211,8 @@ export const createBuiltinActions = ( }), createDebugLogAction(), createWaitAction(), - createCatalogRegisterAction({ catalogClient, integrations }), - createFetchCatalogEntityAction({ catalogClient }), + createCatalogRegisterAction({ catalogClient, integrations, auth }), + createFetchCatalogEntityAction({ catalogClient, auth }), createCatalogWriteAction(), createFilesystemDeleteAction(), createFilesystemRenameAction(), diff --git a/plugins/scaffolder-backend/src/scaffolder/dryrun/createDryRunner.ts b/plugins/scaffolder-backend/src/scaffolder/dryrun/createDryRunner.ts index e202111d79..bfab492a1d 100644 --- a/plugins/scaffolder-backend/src/scaffolder/dryrun/createDryRunner.ts +++ b/plugins/scaffolder-backend/src/scaffolder/dryrun/createDryRunner.ts @@ -35,11 +35,13 @@ import { DecoratedActionsRegistry } from './DecoratedActionsRegistry'; import fs from 'fs-extra'; import { resolveSafeChildPath } from '@backstage/backend-common'; import { PermissionEvaluator } from '@backstage/plugin-permission-common'; +import { BackstageCredentials } from '@backstage/backend-plugin-api'; interface DryRunInput { spec: TaskSpec; secrets?: TaskSecrets; directoryContents: SerializedFile[]; + getInitiatorCredentials(): Promise; } interface DryRunResult { @@ -116,6 +118,7 @@ export function createDryRunner(options: TemplateTesterCreateOptions) { }, }, secrets: input.secrets, + getInitiatorCredentials: input.getInitiatorCredentials, // No need to update this at the end of the run, so just hard-code it done: false, isDryRun: true, diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts index f5bbee2831..2e19da669f 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts @@ -34,7 +34,10 @@ import { PermissionEvaluator, } from '@backstage/plugin-permission-common'; import { RESOURCE_TYPE_SCAFFOLDER_ACTION } from '@backstage/plugin-scaffolder-common/alpha'; -import { createMockDirectory } from '@backstage/backend-test-utils'; +import { + createMockDirectory, + mockCredentials, +} from '@backstage/backend-test-utils'; import stripAnsi from 'strip-ansi'; describe('NunjucksWorkflowRunner', () => { @@ -58,6 +61,13 @@ describe('NunjucksWorkflowRunner', () => { }), ); + const credentials = mockCredentials.user(); + + const token = mockCredentials.service.token({ + onBehalfOf: credentials, + targetPluginId: 'catalog', + }); + const createMockTaskWithSpec = ( spec: TaskSpec, secrets?: TaskSecrets, @@ -71,6 +81,7 @@ describe('NunjucksWorkflowRunner', () => { emitLog: fakeTaskLog, cancelSignal: new AbortController().signal, getWorkspaceName: () => Promise.resolve('test-workspace'), + getInitiatorCredentials: () => Promise.resolve(credentials), }); function expectTaskLog(message: string) { @@ -296,7 +307,6 @@ describe('NunjucksWorkflowRunner', () => { }); it('should pass token through', async () => { - const fakeToken = 'secret'; const task = createMockTaskWithSpec( { apiVersion: 'scaffolder.backstage.io/v1beta3', @@ -312,14 +322,15 @@ describe('NunjucksWorkflowRunner', () => { ], }, { - backstageToken: fakeToken, + backstageToken: token, + initiatorCredentials: JSON.stringify(credentials), }, ); await runner.execute(task); expect(fakeActionHandler.mock.calls[0][0].secrets).toEqual( - expect.objectContaining({ backstageToken: fakeToken }), + expect.objectContaining({ backstageToken: token }), ); }); }); @@ -1172,7 +1183,7 @@ describe('NunjucksWorkflowRunner', () => { ], }, { - backstageToken: 'secret', + backstageToken: token, }, true, ); diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts index 574f424fe4..f29fd2ce1f 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts @@ -48,12 +48,12 @@ import { createCounterMetric, createHistogramMetric } from '../../util/metrics'; import { createDefaultFilters } from '../../lib/templating/filters'; import { AuthorizeResult, - PermissionEvaluator, PolicyDecision, } from '@backstage/plugin-permission-common'; import { scaffolderActionRules } from '../../service/rules'; import { actionExecutePermission } from '@backstage/plugin-scaffolder-common/alpha'; import { TaskRecovery } from '@backstage/plugin-scaffolder-common'; +import { PermissionsService } from '@backstage/backend-plugin-api'; type NunjucksWorkflowRunnerOptions = { workingDirectory: string; @@ -62,7 +62,7 @@ type NunjucksWorkflowRunnerOptions = { logger: winston.Logger; additionalTemplateFilters?: Record; additionalTemplateGlobals?: Record; - permissions?: PermissionEvaluator; + permissions?: PermissionsService; }; type TemplateContext = { @@ -414,6 +414,7 @@ export class NunjucksWorkflowRunner implements WorkflowRunner { user: task.spec.user, isDryRun: task.isDryRun, signal: task.cancelSignal, + getInitiatorCredentials: task.getInitiatorCredentials, }); } @@ -472,7 +473,7 @@ export class NunjucksWorkflowRunner implements WorkflowRunner { this.options.permissions && task.spec.steps.length ? await this.options.permissions.authorizeConditional( [{ permission: actionExecutePermission }], - { token: task.secrets?.backstageToken }, + { credentials: await task.getInitiatorCredentials() }, ) : [{ result: AuthorizeResult.ALLOW }]; diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.ts index fec745a653..8df66a50aa 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.ts @@ -30,6 +30,7 @@ import { } from '@backstage/plugin-scaffolder-node'; import { TaskStore } from './types'; import { readDuration } from './helper'; +import { BackstageCredentials } from '@backstage/backend-plugin-api'; type TaskState = { checkpoints: { @@ -72,6 +73,7 @@ export class TaskManager implements TaskContext { private readonly signal: AbortSignal, private readonly logger: Logger, ) {} + isDryRun?: boolean | undefined; get spec() { return this.task.spec; @@ -171,6 +173,10 @@ export class TaskManager implements TaskContext { } }, 1000); } + + getInitiatorCredentials(): Promise { + return JSON.parse(this.task.secrets!.initiatorCredentials!); + } } /** diff --git a/plugins/scaffolder-backend/src/service/router.test.ts b/plugins/scaffolder-backend/src/service/router.test.ts index 8bfbd6b915..d3cb2d1897 100644 --- a/plugins/scaffolder-backend/src/service/router.test.ts +++ b/plugins/scaffolder-backend/src/service/router.test.ts @@ -41,14 +41,11 @@ import { import { createRouter, DatabaseTaskStore } from '../index'; import { TaskBroker } from '@backstage/plugin-scaffolder-node'; import { StorageTaskBroker } from '../scaffolder/tasks/StorageTaskBroker'; -import { - IdentityApiGetIdentityRequest, - BackstageIdentityResponse, -} from '@backstage/plugin-auth-node'; import { AuthorizeResult, PermissionEvaluator, } from '@backstage/plugin-permission-common'; +import { mockCredentials, mockServices } from '@backstage/backend-test-utils'; const mockAccess = jest.fn(); @@ -83,8 +80,6 @@ const mockUrlReader = UrlReaders.default({ config: new ConfigReader({}), }); -const getIdentity = jest.fn(); - const config = new ConfigReader({}); describe('createRouter', () => { @@ -96,6 +91,15 @@ describe('createRouter', () => { authorize: jest.fn(), authorizeConditional: jest.fn(), } as unknown as PermissionEvaluator; + const auth = mockServices.auth(); + const httpAuth = mockServices.httpAuth(); + const discovery = mockServices.discovery(); + + const credentials = mockCredentials.user(); + const token = mockCredentials.service.token({ + onBehalfOf: credentials, + targetPluginId: 'catalog', + }); const getMockTemplate = (): TemplateEntityV1beta3 => ({ apiVersion: 'scaffolder.backstage.io/v1beta3', @@ -199,6 +203,9 @@ describe('createRouter', () => { reader: mockUrlReader, taskBroker, permissions: permissionApi, + auth, + httpAuth, + discovery, }); app = express().use(router); @@ -287,8 +294,7 @@ describe('createRouter', () => { it('should call the broker with a correct spec', async () => { const broker = taskBroker.dispatch as jest.Mocked['dispatch']; - const mockToken = - 'blob.eyJzdWIiOiJ1c2VyOmRlZmF1bHQvZ3Vlc3QiLCJuYW1lIjoiSm9obiBEb2UifQ.blob'; + const mockToken = mockCredentials.user.token(); const mockTemplate = getMockTemplate(); await request(app) @@ -304,11 +310,13 @@ describe('createRouter', () => { requiredParameter2: 'required-value-2', }, }); + expect(broker).toHaveBeenCalledWith( expect.objectContaining({ - createdBy: 'user:default/guest', + createdBy: 'user:default/mock', secrets: { - backstageToken: mockToken, + backstageToken: token, + initiatorCredentials: JSON.stringify(credentials), }, spec: { @@ -325,7 +333,7 @@ describe('createRouter', () => { }, user: { entity: mockUser, - ref: 'user:default/guest', + ref: 'user:default/mock', }, templateInfo: { entityRef: stringifyEntityRef({ @@ -343,114 +351,8 @@ describe('createRouter', () => { ); }); - it('should not throw when an invalid authorization header is passed', async () => { - const broker = - taskBroker.dispatch as jest.Mocked['dispatch']; - const mockToken = 'blob.eyJzdWIiOiIiLCJuYW1lIjoiSm9obiBEb2UifQ.blob'; - const mockTemplate = getMockTemplate(); - - await request(app) - .post('/v2/tasks') - .set('Authorization', `Bearer ${mockToken}`) - .send({ - templateRef: stringifyEntityRef({ - kind: 'template', - name: 'create-react-app-template', - }), - values: { - requiredParameter1: 'required-value-1', - requiredParameter2: 'required-value-2', - }, - }); - expect(broker).toHaveBeenCalledWith( - expect.objectContaining({ - createdBy: undefined, - secrets: { - backstageToken: undefined, - }, - - spec: { - apiVersion: mockTemplate.apiVersion, - steps: mockTemplate.spec.steps.map((step, index) => ({ - ...step, - id: step.id ?? `step-${index + 1}`, - name: step.name ?? step.action, - })), - output: mockTemplate.spec.output ?? {}, - parameters: { - requiredParameter1: 'required-value-1', - requiredParameter2: 'required-value-2', - }, - user: { - entity: undefined, - ref: undefined, - }, - templateInfo: { - entityRef: stringifyEntityRef({ - kind: 'Template', - namespace: 'Default', - name: mockTemplate.metadata?.name, - }), - baseUrl: 'https://dev.azure.com', - entity: { - metadata: mockTemplate.metadata, - }, - }, - }, - }), - ); - }); - - it('should not decorate a user when no backstage auth is passed', async () => { - const broker = - taskBroker.dispatch as jest.Mocked['dispatch']; - - await request(app) - .post('/v2/tasks') - .send({ - templateRef: stringifyEntityRef({ - kind: 'template', - name: 'create-react-app-template', - }), - values: { - requiredParameter1: 'required-value-1', - requiredParameter2: 'required-value-2', - }, - }); - - expect(broker).toHaveBeenCalledWith( - expect.objectContaining({ - createdBy: undefined, - spec: expect.objectContaining({ - user: { entity: undefined, ref: undefined }, - }), - }), - ); - }); - - it('should emit auditlog containing without user identifier when no backstage auth is passed', async () => { - await request(app) - .post('/v2/tasks') - .send({ - templateRef: stringifyEntityRef({ - kind: 'template', - name: 'create-react-app-template', - }), - values: { - requiredParameter1: 'required-value-1', - requiredParameter2: 'required-value-2', - }, - }); - - expect(loggerSpy).toHaveBeenCalledTimes(1); - expect(loggerSpy).toHaveBeenCalledWith( - 'Scaffolding task for template:default/create-react-app-template', - ); - }); - it('should emit auditlog containing user identifier when backstage auth is passed', async () => { - const mockToken = - 'blob.eyJzdWIiOiJ1c2VyOmRlZmF1bHQvZ3Vlc3QiLCJuYW1lIjoiSm9obiBEb2UifQ.blob'; + const mockToken = mockCredentials.user.token(); await request(app) .post('/v2/tasks') @@ -468,7 +370,7 @@ describe('createRouter', () => { expect(loggerSpy).toHaveBeenCalledTimes(1); expect(loggerSpy).toHaveBeenCalledWith( - 'Scaffolding task for template:default/create-react-app-template created by user:default/guest', + 'Scaffolding task for template:default/create-react-app-template created by user:default/mock', ); }); }); @@ -551,7 +453,10 @@ describe('createRouter', () => { spec: {} as any, status: 'completed', createdAt: '', - secrets: { backstageToken: 'secret' }, + secrets: { + backstageToken: token, + initiatorCredentials: JSON.stringify(credentials), + }, createdBy: '', }); @@ -797,23 +702,6 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{ jest.spyOn(taskBroker, 'event$'); loggerSpy = jest.spyOn(logger, 'info'); - getIdentity.mockImplementation( - async ({ - request: _request, - }: IdentityApiGetIdentityRequest): Promise< - BackstageIdentityResponse | undefined - > => { - return { - identity: { - userEntityRef: 'user:default/guest', - ownershipEntityRefs: [], - type: 'user', - }, - token: 'token', - }; - }, - ); - const router = await createRouter({ logger: logger, config: new ConfigReader({}), @@ -821,8 +709,10 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{ catalogClient, reader: mockUrlReader, taskBroker, - identity: { getIdentity }, permissions: permissionApi, + auth, + httpAuth, + discovery, }); app = express().use(router); @@ -1032,9 +922,10 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{ }); expect(broker).toHaveBeenCalledWith( expect.objectContaining({ - createdBy: 'user:default/guest', + createdBy: 'user:default/mock', secrets: { - backstageToken: 'token', + backstageToken: token, + initiatorCredentials: JSON.stringify(credentials), }, spec: { @@ -1047,7 +938,7 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{ }, user: { entity: mockUser, - ref: 'user:default/guest', + ref: 'user:default/mock', }, templateInfo: { entityRef: stringifyEntityRef({ @@ -1101,9 +992,10 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{ }); expect(broker).toHaveBeenCalledWith( expect.objectContaining({ - createdBy: 'user:default/guest', + createdBy: 'user:default/mock', secrets: { - backstageToken: 'token', + backstageToken: token, + initiatorCredentials: JSON.stringify(credentials), }, spec: { @@ -1128,7 +1020,7 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{ }, user: { entity: mockUser, - ref: 'user:default/guest', + ref: 'user:default/mock', }, templateInfo: { entityRef: stringifyEntityRef({ @@ -1189,9 +1081,10 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{ }); expect(broker).toHaveBeenCalledWith( expect.objectContaining({ - createdBy: 'user:default/guest', + createdBy: 'user:default/mock', secrets: { - backstageToken: 'token', + backstageToken: token, + initiatorCredentials: JSON.stringify(credentials), }, spec: { @@ -1208,7 +1101,7 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{ }, user: { entity: mockUser, - ref: 'user:default/guest', + ref: 'user:default/mock', }, templateInfo: { entityRef: stringifyEntityRef({ @@ -1226,96 +1119,6 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{ ); }); - describe('when the identity api throws an error', () => { - beforeEach(() => { - getIdentity.mockImplementation( - async ({ - request: _request, - }: IdentityApiGetIdentityRequest): Promise< - BackstageIdentityResponse | undefined - > => { - throw new Error('whoops!'); - }, - ); - }); - it('return an error', async () => { - const response = await request(app) - .post('/v2/tasks') - .send({ - templateRef: stringifyEntityRef({ - kind: 'template', - name: 'create-react-app-template', - }), - values: { - requiredParameter1: 'required-value-1', - requiredParameter2: 'required-value-2', - }, - }); - expect(response.status).not.toEqual(201); - }); - }); - - describe('no auth is provided', () => { - beforeEach(() => { - getIdentity.mockImplementation( - async ({ - request: _request, - }: IdentityApiGetIdentityRequest): Promise< - BackstageIdentityResponse | undefined - > => { - return undefined; - }, - ); - }); - - it('should not decorate a user when no backstage auth is passed', async () => { - const broker = - taskBroker.dispatch as jest.Mocked['dispatch']; - - await request(app) - .post('/v2/tasks') - .send({ - templateRef: stringifyEntityRef({ - kind: 'template', - name: 'create-react-app-template', - }), - values: { - requiredParameter1: 'required-value-1', - requiredParameter2: 'required-value-2', - }, - }); - - expect(broker).toHaveBeenCalledWith( - expect.objectContaining({ - createdBy: undefined, - spec: expect.objectContaining({ - user: { entity: undefined, ref: undefined }, - }), - }), - ); - }); - - it('should emit auditlog containing without user identifier when no backstage auth is passed', async () => { - await request(app) - .post('/v2/tasks') - .send({ - templateRef: stringifyEntityRef({ - kind: 'template', - name: 'create-react-app-template', - }), - values: { - requiredParameter1: 'required-value-1', - requiredParameter2: 'required-value-2', - }, - }); - - expect(loggerSpy).toHaveBeenCalledTimes(1); - expect(loggerSpy).toHaveBeenCalledWith( - 'Scaffolding task for template:default/create-react-app-template', - ); - }); - }); - it('should emit auditlog containing user identifier when backstage auth is passed', async () => { await request(app) .post('/v2/tasks') @@ -1332,7 +1135,7 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{ expect(loggerSpy).toHaveBeenCalledTimes(1); expect(loggerSpy).toHaveBeenCalledWith( - 'Scaffolding task for template:default/create-react-app-template created by user:default/guest', + 'Scaffolding task for template:default/create-react-app-template created by user:default/mock', ); }); }); @@ -1415,7 +1218,10 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{ spec: {} as any, status: 'completed', createdAt: '', - secrets: { backstageToken: 'secret' }, + secrets: { + backstageToken: token, + initiatorCredentials: JSON.stringify(credentials), + }, createdBy: '', }); diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index c0322af283..51286f76c9 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -14,7 +14,11 @@ * limitations under the License. */ -import { PluginDatabaseManager, UrlReader } from '@backstage/backend-common'; +import { + PluginDatabaseManager, + UrlReader, + createLegacyAuthAdapters, +} from '@backstage/backend-common'; import { PluginTaskScheduler } from '@backstage/backend-tasks'; import { CatalogApi } from '@backstage/catalog-client'; import { @@ -25,9 +29,9 @@ import { UserEntity, } from '@backstage/catalog-model'; import { Config, readDurationFromConfig } from '@backstage/config'; -import { InputError, NotFoundError, stringifyError } from '@backstage/errors'; +import { InputError, NotFoundError } from '@backstage/errors'; import { ScmIntegrations } from '@backstage/integration'; -import { HumanDuration, JsonObject, JsonValue } from '@backstage/types'; +import { HumanDuration, JsonObject } from '@backstage/types'; import { TaskSpec, TemplateEntityV1beta3, @@ -62,15 +66,8 @@ import { import { createDryRunner } from '../scaffolder/dryrun'; import { StorageTaskBroker } from '../scaffolder/tasks/StorageTaskBroker'; import { findTemplate, getEntityBaseUrl, getWorkingDirectory } from './helpers'; -import { - IdentityApi, - IdentityApiGetIdentityRequest, -} from '@backstage/plugin-auth-node'; import { TemplateAction } from '@backstage/plugin-scaffolder-node'; -import { - PermissionEvaluator, - PermissionRuleParams, -} from '@backstage/plugin-permission-common'; +import { PermissionRuleParams } from '@backstage/plugin-permission-common'; import { createConditionAuthorizer, createPermissionIntegrationRouter, @@ -78,7 +75,14 @@ import { } from '@backstage/plugin-permission-node'; import { scaffolderActionRules, scaffolderTemplateRules } from './rules'; import { Duration } from 'luxon'; -import { LifecycleService } from '@backstage/backend-plugin-api'; +import { + AuthService, + BackstageCredentials, + DiscoveryService, + HttpAuthService, + LifecycleService, + PermissionsService, +} from '@backstage/backend-plugin-api'; /** * @@ -143,81 +147,19 @@ export interface RouterOptions { taskBroker?: TaskBroker; additionalTemplateFilters?: Record; additionalTemplateGlobals?: Record; - permissions?: PermissionEvaluator; + permissions?: PermissionsService; permissionRules?: Array< TemplatePermissionRuleInput | ActionPermissionRuleInput >; - identity?: IdentityApi; + auth?: AuthService; + httpAuth?: HttpAuthService; + discovery: DiscoveryService; } function isSupportedTemplate(entity: TemplateEntityV1beta3) { return entity.apiVersion === 'scaffolder.backstage.io/v1beta3'; } -/* - * @deprecated This function remains as the DefaultIdentityClient behaves slightly differently to the pre-existing - * scaffolder behaviour. Specifically if the token fails to parse, the DefaultIdentityClient will raise an error. - * The scaffolder did not raise an error in this case. As such we chose to allow it to behave as it did previously - * until someone explicitly passes an IdentityApi. When we have reasonable confidence that most backstage deployments - * are using the IdentityApi, we can remove this function. - */ -function buildDefaultIdentityClient(options: RouterOptions): IdentityApi { - return { - getIdentity: async ({ request }: IdentityApiGetIdentityRequest) => { - const header = request.headers.authorization; - const { logger } = options; - - if (!header) { - return undefined; - } - - try { - const token = header.match(/^Bearer\s(\S+\.\S+\.\S+)$/i)?.[1]; - if (!token) { - throw new TypeError('Expected Bearer with JWT'); - } - - const [_header, rawPayload, _signature] = token.split('.'); - const payload: JsonValue = JSON.parse( - Buffer.from(rawPayload, 'base64').toString(), - ); - - if ( - typeof payload !== 'object' || - payload === null || - Array.isArray(payload) - ) { - throw new TypeError('Malformed JWT payload'); - } - - const sub = payload.sub; - if (typeof sub !== 'string') { - throw new TypeError('Expected string sub claim'); - } - - if (sub === 'backstage-server') { - return undefined; - } - - // Check that it's a valid ref, otherwise this will throw. - parseEntityRef(sub); - - return { - identity: { - userEntityRef: sub, - ownershipEntityRefs: [], - type: 'user', - }, - token, - }; - } catch (e) { - logger.error(`Invalid authorization header: ${stringifyError(e)}`); - return undefined; - } - }, - }; -} - const readDuration = ( config: Config, key: string, @@ -236,6 +178,8 @@ const readDuration = ( export async function createRouter( options: RouterOptions, ): Promise { + const { auth, httpAuth } = createLegacyAuthAdapters(options); + const router = Router(); // Be generous in upload size to support a wide range of templates in dry-run mode. router.use(express.json({ limit: '10MB' })); @@ -260,8 +204,6 @@ export async function createRouter( const logger = parentLogger.child({ plugin: 'scaffolder' }); - const identity: IdentityApi = - options.identity || buildDefaultIdentityClient(options); const workingDirectory = await getWorkingDirectory(config, logger); const integrations = ScmIntegrations.fromConfig(config); @@ -330,6 +272,7 @@ export async function createRouter( config, additionalTemplateFilters, additionalTemplateGlobals, + auth, }); actionsToRegister.forEach(action => actionRegistry.register(action)); @@ -394,12 +337,18 @@ export async function createRouter( .get( '/v2/templates/:namespace/:kind/:name/parameter-schema', async (req, res) => { - const userIdentity = await identity.getIdentity({ - request: req, - }); - const token = userIdentity?.token; + const credentials = await httpAuth.credentials(req); - const template = await authorizeTemplate(req.params, token); + const { token } = await auth.getPluginRequestToken({ + onBehalfOf: credentials, + targetPluginId: 'catalog', + }); + + const template = await authorizeTemplate( + req.params, + token, + credentials, + ); const parameters = [template.spec.parameters ?? []].flat(); @@ -435,11 +384,13 @@ export async function createRouter( defaultKind: 'template', }); - const callerIdentity = await identity.getIdentity({ - request: req, + const credentials = await httpAuth.credentials(req, { allow: ['user'] }); + + const { token } = await auth.getPluginRequestToken({ + onBehalfOf: credentials, + targetPluginId: 'catalog', }); - const token = callerIdentity?.token; - const userEntityRef = callerIdentity?.identity.userEntityRef; + const userEntityRef = credentials.principal.userEntityRef; const userEntity = userEntityRef ? await catalogClient.getEntityByRef(userEntityRef, { token }) @@ -456,6 +407,7 @@ export async function createRouter( const template = await authorizeTemplate( { kind, namespace, name }, token, + credentials, ); for (const parameters of [template.spec.parameters ?? []].flat()) { @@ -498,6 +450,7 @@ export async function createRouter( secrets: { ...req.body.secrets, backstageToken: token, + initiatorCredentials: JSON.stringify(credentials), }, }); @@ -636,11 +589,12 @@ export async function createRouter( throw new InputError('Input template is not a template'); } - const token = ( - await identity.getIdentity({ - request: req, - }) - )?.token; + const credentials = await httpAuth.credentials(req); + + const { token } = await auth.getPluginRequestToken({ + onBehalfOf: credentials, + targetPluginId: 'catalog', + }); for (const parameters of [template.spec.parameters ?? []].flat()) { const result = validate(body.values, parameters); @@ -670,6 +624,10 @@ export async function createRouter( secrets: { ...body.secrets, ...(token && { backstageToken: token }), + initiatorCredentials: JSON.stringify(credentials), + }, + getInitiatorCredentials(): Promise { + return Promise.resolve(credentials); }, }); @@ -691,6 +649,7 @@ export async function createRouter( async function authorizeTemplate( entityRef: CompoundEntityRef, token: string | undefined, + credentials: BackstageCredentials, ) { const template = await findTemplate({ catalogApi: catalogClient, @@ -716,7 +675,7 @@ export async function createRouter( { permission: templateParameterReadPermission }, { permission: templateStepReadPermission }, ], - { token }, + { credentials }, ); // Authorize parameters diff --git a/plugins/scaffolder-node-test-utils/src/actions/mockActionConext.ts b/plugins/scaffolder-node-test-utils/src/actions/mockActionConext.ts index f9cd556fd9..15ad5eb769 100644 --- a/plugins/scaffolder-node-test-utils/src/actions/mockActionConext.ts +++ b/plugins/scaffolder-node-test-utils/src/actions/mockActionConext.ts @@ -16,7 +16,10 @@ import { PassThrough } from 'stream'; import { getVoidLogger } from '@backstage/backend-common'; -import { createMockDirectory } from '@backstage/backend-test-utils'; +import { + createMockDirectory, + mockCredentials, +} from '@backstage/backend-test-utils'; import { JsonObject } from '@backstage/types'; import { ActionContext } from '@backstage/plugin-scaffolder-node'; @@ -32,6 +35,7 @@ export const createMockActionContext = < >( options?: Partial>, ): ActionContext => { + const credentials = mockCredentials.user(); const defaultContext = { logger: getVoidLogger(), logStream: new PassThrough(), @@ -39,6 +43,7 @@ export const createMockActionContext = < createTemporaryDirectory: jest.fn(), input: {} as TActionInput, checkpoint: jest.fn(), + getInitiatorCredentials: () => Promise.resolve(credentials), }; const createDefaultWorkspace = () => ({ diff --git a/plugins/scaffolder-node/api-report.md b/plugins/scaffolder-node/api-report.md index ba914f8f87..b97b5d44b6 100644 --- a/plugins/scaffolder-node/api-report.md +++ b/plugins/scaffolder-node/api-report.md @@ -5,6 +5,7 @@ ```ts /// +import { BackstageCredentials } from '@backstage/backend-plugin-api'; import { JsonObject } from '@backstage/types'; import { JsonValue } from '@backstage/types'; import { Logger } from 'winston'; @@ -47,6 +48,7 @@ export type ActionContext< }; signal?: AbortSignal; each?: JsonObject; + getInitiatorCredentials(): Promise; }; // @public (undocumented) @@ -356,6 +358,8 @@ export interface TaskContext { | undefined >; // (undocumented) + getInitiatorCredentials(): Promise; + // (undocumented) getWorkspaceName(): Promise; // (undocumented) isDryRun?: boolean; @@ -385,6 +389,7 @@ export type TaskEventType = 'completion' | 'log' | 'cancelled' | 'recovered'; // @public export type TaskSecrets = Record & { backstageToken?: string; + initiatorCredentials?: string; }; // @public diff --git a/plugins/scaffolder-node/src/actions/types.ts b/plugins/scaffolder-node/src/actions/types.ts index 5abeb29a2b..fd2a519825 100644 --- a/plugins/scaffolder-node/src/actions/types.ts +++ b/plugins/scaffolder-node/src/actions/types.ts @@ -21,6 +21,7 @@ import { TaskSecrets } from '../tasks'; import { TemplateInfo } from '@backstage/plugin-scaffolder-common'; import { UserEntity } from '@backstage/catalog-model'; import { Schema } from 'jsonschema'; +import { BackstageCredentials } from '@backstage/backend-plugin-api'; /** * ActionContext is passed into scaffolder actions. @@ -80,6 +81,11 @@ export type ActionContext< * Optional value of each invocation */ each?: JsonObject; + + /** + * Get the credentials for the current request + */ + getInitiatorCredentials(): Promise; }; /** @public */ diff --git a/plugins/scaffolder-node/src/tasks/types.ts b/plugins/scaffolder-node/src/tasks/types.ts index e57668e346..c3a5f65b6c 100644 --- a/plugins/scaffolder-node/src/tasks/types.ts +++ b/plugins/scaffolder-node/src/tasks/types.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { BackstageCredentials } from '@backstage/backend-plugin-api'; import { TaskSpec } from '@backstage/plugin-scaffolder-common'; import { JsonObject, JsonValue, Observable } from '@backstage/types'; @@ -23,7 +24,11 @@ import { JsonObject, JsonValue, Observable } from '@backstage/types'; * @public */ export type TaskSecrets = Record & { + /** + * @deprecated use "initiatorCredentials" instead + */ backstageToken?: string; + initiatorCredentials?: string; }; /** @@ -140,6 +145,8 @@ export interface TaskContext { ): Promise; getWorkspaceName(): Promise; + + getInitiatorCredentials(): Promise; } /**