feat(scaffolder-backend): start migrating to use new auth services

Signed-off-by: Camila Belo <camilaibs@gmail.com>
This commit is contained in:
Camila Belo
2024-02-21 16:45:25 +01:00
committed by blam
parent 6e0a8e5227
commit 0139e4c36e
21 changed files with 267 additions and 382 deletions
+1 -1
View File
@@ -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,
@@ -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,
});
}
+19 -4
View File
@@ -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<string, TemplateFilter_2>;
// (undocumented)
additionalTemplateGlobals?: Record<string, TemplateGlobal_2>;
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<string, TemplateGlobal_2>;
// (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<void>;
// (undocumented)
static create(
@@ -537,8 +548,12 @@ export class TaskManager implements TaskContext_2 {
| undefined
>;
// (undocumented)
getInitiatorCredentials(): Promise<BackstageCredentials>;
// (undocumented)
getWorkspaceName(): Promise<string>;
// (undocumented)
isDryRun?: boolean | undefined;
// (undocumented)
get secrets(): TaskSecrets_2 | undefined;
// (undocumented)
get spec(): TaskSpecV1beta3;
@@ -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);
@@ -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: {
@@ -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,
},
);
@@ -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,
},
);
@@ -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(
@@ -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(
@@ -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) {
@@ -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(),
@@ -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<BackstageCredentials>;
}
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,
@@ -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,
);
@@ -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<string, TemplateFilter>;
additionalTemplateGlobals?: Record<string, TemplateGlobal>;
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 }];
@@ -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<BackstageCredentials> {
return JSON.parse(this.task.secrets!.initiatorCredentials!);
}
}
/**
@@ -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<TaskBroker>['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<TaskBroker>['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<TaskBroker>['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<TaskBroker>['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: '',
});
@@ -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<string, TemplateFilter>;
additionalTemplateGlobals?: Record<string, TemplateGlobal>;
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<express.Router> {
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<BackstageCredentials> {
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
@@ -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<TActionInput, TActionOutput>>,
): ActionContext<TActionInput, TActionOutput> => {
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 = () => ({
+5
View File
@@ -5,6 +5,7 @@
```ts
/// <reference types="node" />
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<BackstageCredentials>;
};
// @public (undocumented)
@@ -356,6 +358,8 @@ export interface TaskContext {
| undefined
>;
// (undocumented)
getInitiatorCredentials(): Promise<BackstageCredentials>;
// (undocumented)
getWorkspaceName(): Promise<string>;
// (undocumented)
isDryRun?: boolean;
@@ -385,6 +389,7 @@ export type TaskEventType = 'completion' | 'log' | 'cancelled' | 'recovered';
// @public
export type TaskSecrets = Record<string, string> & {
backstageToken?: string;
initiatorCredentials?: string;
};
// @public
@@ -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<BackstageCredentials>;
};
/** @public */
@@ -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<string, string> & {
/**
* @deprecated use "initiatorCredentials" instead
*/
backstageToken?: string;
initiatorCredentials?: string;
};
/**
@@ -140,6 +145,8 @@ export interface TaskContext {
): Promise<void>;
getWorkspaceName(): Promise<string>;
getInitiatorCredentials(): Promise<BackstageCredentials>;
}
/**