feat(scaffolder-backend): start migrating to use new auth services
Signed-off-by: Camila Belo <camilaibs@gmail.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
+1
-1
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
+11
-2
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
+11
-2
@@ -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,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>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user