diff --git a/.changeset/gentle-penguins-kick.md b/.changeset/gentle-penguins-kick.md new file mode 100644 index 0000000000..c19834c6af --- /dev/null +++ b/.changeset/gentle-penguins-kick.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-scaffolder-backend': minor +'@backstage/plugin-scaffolder-common': minor +--- + +Added the ability to reference the user in the `template.yaml` manifest diff --git a/plugins/scaffolder-backend/api-report.md b/plugins/scaffolder-backend/api-report.md index 1bc8a27f54..7cf26aa473 100644 --- a/plugins/scaffolder-backend/api-report.md +++ b/plugins/scaffolder-backend/api-report.md @@ -342,6 +342,7 @@ export type CreateWorkerOptions = { // @public export interface CurrentClaimedTask { + createdBy?: string; secrets?: TaskSecrets; spec: TaskSpec; taskId: string; @@ -476,6 +477,7 @@ export type SerializedTask = { status: TaskStatus; createdAt: string; lastHeartbeatAt?: string; + createdBy?: string; secrets?: TaskSecrets; }; @@ -510,6 +512,7 @@ export interface TaskBroker { export type TaskBrokerDispatchOptions = { spec: TaskSpec; secrets?: TaskSecrets; + createdBy?: string; }; // @public @@ -525,6 +528,8 @@ export interface TaskContext { // (undocumented) complete(result: TaskCompletionState, metadata?: JsonObject): Promise; // (undocumented) + createdBy?: string; + // (undocumented) done: boolean; // (undocumented) emitLog(message: string, logMetadata?: JsonObject): Promise; @@ -550,6 +555,8 @@ export class TaskManager implements TaskContext { logger: Logger, ): TaskManager; // (undocumented) + get createdBy(): string | undefined; + // (undocumented) get done(): boolean; // (undocumented) emitLog(message: string, logMetadata?: JsonObject): Promise; @@ -609,6 +616,7 @@ export interface TaskStore { // @public export type TaskStoreCreateTaskOptions = { spec: TaskSpec; + createdBy?: string; secrets?: TaskSecrets; }; diff --git a/plugins/scaffolder-backend/migrations/20220129100000_created_by.js b/plugins/scaffolder-backend/migrations/20220129100000_created_by.js new file mode 100644 index 0000000000..3391a883c2 --- /dev/null +++ b/plugins/scaffolder-backend/migrations/20220129100000_created_by.js @@ -0,0 +1,38 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + await knex.schema.alterTable('tasks', table => { + table + .text('created_by') + .nullable() + .comment('An entityRef of the user who created the task'); + }); +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + await knex.schema.alterTable('tasks', table => { + table.dropColumn('created_by'); + }); +}; diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/DatabaseTaskStore.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/DatabaseTaskStore.ts index 9fef68529f..d0a0fec0a6 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/DatabaseTaskStore.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/DatabaseTaskStore.ts @@ -43,6 +43,7 @@ export type RawDbTaskRow = { status: TaskStatus; last_heartbeat_at?: string; created_at: string; + created_by: string | null; secrets?: string | null; }; @@ -100,6 +101,7 @@ export class DatabaseTaskStore implements TaskStore { status: result.status, lastHeartbeatAt: result.last_heartbeat_at, createdAt: result.created_at, + createdBy: result.created_by ?? undefined, secrets, }; } catch (error) { @@ -115,6 +117,7 @@ export class DatabaseTaskStore implements TaskStore { id: taskId, spec: JSON.stringify(options.spec), secrets: options.secrets ? JSON.stringify(options.secrets) : undefined, + created_by: options.createdBy ?? null, status: 'open', }); return { taskId }; @@ -155,6 +158,7 @@ export class DatabaseTaskStore implements TaskStore { status: 'processing', lastHeartbeatAt: task.last_heartbeat_at, createdAt: task.created_at, + createdBy: task.created_by ?? undefined, secrets, }; } catch (error) { diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts index 0ef0846ddb..6b1701380b 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts @@ -24,6 +24,7 @@ import { ScmIntegrations } from '@backstage/integration'; import { ConfigReader } from '@backstage/config'; import { TaskContext, TaskSecrets } from './types'; import { TaskSpec } from '@backstage/plugin-scaffolder-common'; +import { UserEntity } from '@backstage/catalog-model'; // The Stream module is lazy loaded, so make sure it's in the module cache before mocking fs void winston.transports.Stream; @@ -558,6 +559,36 @@ describe('DefaultWorkflowRunner', () => { }); }); + describe('user', () => { + it('allows access to the user entity at the templating level', async () => { + const task = createMockTaskWithSpec({ + apiVersion: 'scaffolder.backstage.io/v1beta3', + steps: [ + { + id: 'test', + name: 'name', + action: 'output-action', + input: {}, + }, + ], + user: { + entity: { metadata: { name: 'bob' } } as UserEntity, + ref: 'user:default/guest', + }, + output: { + foo: '${{ user.entity.metadata.name }} ${{ user.ref }}', + }, + parameters: { + repoUrl: 'github.com?repo=repo&owner=owner', + }, + }); + + const { output } = await runner.execute(task); + + expect(output.foo).toEqual('bob user:default/guest'); + }); + }); + describe('filters', () => { it('provides the parseRepoUrl filter', async () => { const task = createMockTaskWithSpec({ diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts index 0bd5a3c840..c9d0714392 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts @@ -37,6 +37,7 @@ import { TaskSpecV1beta3, TaskStep, } from '@backstage/plugin-scaffolder-common'; +import { UserEntity } from '@backstage/catalog-model'; type NunjucksWorkflowRunnerOptions = { workingDirectory: string; @@ -52,6 +53,10 @@ type TemplateContext = { [stepName: string]: { output: { [outputName: string]: JsonValue } }; }; secrets?: Record; + user?: { + entity?: UserEntity; + ref?: string; + }; }; const isValidTaskSpec = (taskSpec: TaskSpec): taskSpec is TaskSpecV1beta3 => { @@ -203,6 +208,7 @@ export class NunjucksWorkflowRunner implements WorkflowRunner { const context: TemplateContext = { parameters: task.spec.parameters, steps: {}, + user: task.spec.user, }; for (const step of task.spec.steps) { diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.ts index a92d6abdb8..17f07f702d 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.ts @@ -59,6 +59,10 @@ export class TaskManager implements TaskContext { return this.task.secrets; } + get createdBy() { + return this.task.createdBy; + } + async getWorkspaceName() { return this.task.taskId; } @@ -127,6 +131,10 @@ export interface CurrentClaimedTask { * The secrets that are stored with the task. */ secrets?: TaskSecrets; + /** + * The creator of the task. + */ + createdBy?: string; } function defer() { @@ -156,6 +164,7 @@ export class StorageTaskBroker implements TaskBroker { taskId: pendingTask.id, spec: pendingTask.spec, secrets: pendingTask.secrets, + createdBy: pendingTask.createdBy, }, this.storage, this.logger, diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts index 2af98b111b..a3a9ef84d1 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts @@ -47,6 +47,7 @@ export type SerializedTask = { status: TaskStatus; createdAt: string; lastHeartbeatAt?: string; + createdBy?: string; secrets?: TaskSecrets; }; @@ -97,6 +98,7 @@ export type TaskBrokerDispatchResult = { export type TaskBrokerDispatchOptions = { spec: TaskSpec; secrets?: TaskSecrets; + createdBy?: string; }; /** @@ -107,6 +109,7 @@ export type TaskBrokerDispatchOptions = { export interface TaskContext { spec: TaskSpec; secrets?: TaskSecrets; + createdBy?: string; done: boolean; emitLog(message: string, logMetadata?: JsonObject): Promise; complete(result: TaskCompletionState, metadata?: JsonObject): Promise; @@ -157,6 +160,7 @@ export type TaskStoreListEventsOptions = { */ export type TaskStoreCreateTaskOptions = { spec: TaskSpec; + createdBy?: string; secrets?: TaskSecrets; }; diff --git a/plugins/scaffolder-backend/src/service/router.test.ts b/plugins/scaffolder-backend/src/service/router.test.ts index 6caed7b0cb..14c543249e 100644 --- a/plugins/scaffolder-backend/src/service/router.test.ts +++ b/plugins/scaffolder-backend/src/service/router.test.ts @@ -48,12 +48,11 @@ import request from 'supertest'; */ import { createRouter, DatabaseTaskStore, TaskBroker } from '../index'; import { StorageTaskBroker } from '../scaffolder/tasks/StorageTaskBroker'; -import { stringifyEntityRef } from '@backstage/catalog-model'; - -const createCatalogClient = (template: any) => - ({ - getEntityByRef: async () => template, - } as unknown as CatalogApi); +import { + parseEntityRef, + stringifyEntityRef, + UserEntity, +} from '@backstage/catalog-model'; function createDatabase(): PluginDatabaseManager { return DatabaseManager.fromConfig( @@ -76,7 +75,9 @@ const mockUrlReader = UrlReaders.default({ describe('createRouter', () => { let app: express.Express; let taskBroker: TaskBroker; - const template: TemplateEntityV1beta3 = { + const catalogClient = { getEntityByRef: jest.fn() } as unknown as CatalogApi; + + const mockTemplate: TemplateEntityV1beta3 = { apiVersion: 'scaffolder.backstage.io/v1beta3', kind: 'Template', metadata: { @@ -105,6 +106,22 @@ describe('createRouter', () => { }, }; + const mockUser: UserEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { + name: 'guest', + annotations: { + 'google.com/email': 'bobby@tables.com', + }, + }, + spec: { + profile: { + displayName: 'Robert Tables of the North', + }, + }, + }; + beforeEach(async () => { const logger = getVoidLogger(); const databaseTaskStore = await DatabaseTaskStore.create({ @@ -120,11 +137,26 @@ describe('createRouter', () => { logger: getVoidLogger(), config: new ConfigReader({}), database: createDatabase(), - catalogClient: createCatalogClient(template), + catalogClient, reader: mockUrlReader, taskBroker, }); app = express().use(router); + + jest + .spyOn(catalogClient, 'getEntityByRef') + .mockImplementation(async ref => { + const { kind } = parseEntityRef(ref); + + if (kind === 'template') { + return mockTemplate; + } + + if (kind === 'user') { + return mockUser; + } + throw new Error(`no mock found for kind: ${kind}`); + }); }); afterEach(() => { @@ -158,9 +190,8 @@ describe('createRouter', () => { }); it('return the template id', async () => { - ( - taskBroker.dispatch as jest.Mocked['dispatch'] - ).mockResolvedValue({ + const broker = taskBroker.dispatch as jest.Mocked['dispatch']; + broker.mockResolvedValue({ taskId: 'a-random-id', }); @@ -179,6 +210,88 @@ describe('createRouter', () => { expect(response.body.id).toBe('a-random-id'); expect(response.status).toEqual(201); }); + + it('should call the broker with a correct spec', async () => { + const broker = taskBroker.dispatch as jest.Mocked['dispatch']; + const mockToken = + 'blob.eyJzdWIiOiJ1c2VyOmRlZmF1bHQvZ3Vlc3QiLCJuYW1lIjoiSm9obiBEb2UifQ.blob'; + + console.log( + JSON.stringify( + await request(app) + .post('/v2/tasks') + .set('Authorization', `Bearer ${mockToken}`) + .send({ + templateRef: stringifyEntityRef({ + kind: 'template', + name: 'create-react-app-template', + }), + values: { + required: 'required-value', + }, + }), + ), + ); + + expect(broker).toHaveBeenCalledWith( + expect.objectContaining({ + createdBy: 'user:default/guest', + secrets: { + backstageToken: mockToken, + }, + + 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: { + required: 'required-value', + }, + user: { + entity: mockUser, + ref: 'user:default/guest', + }, + templateInfo: { + entityRef: stringifyEntityRef({ + kind: 'Template', + namespace: 'Default', + name: mockTemplate.metadata?.name, + }), + baseUrl: 'https://dev.azure.com', + }, + }, + }), + ); + }); + + it('should not decorate a user when no backstage auth is passed', async () => { + const broker = taskBroker.dispatch as jest.Mocked['dispatch']; + + await request(app) + .post('/v2/tasks') + .send({ + templateRef: stringifyEntityRef({ + kind: 'template', + name: 'create-react-app-template', + }), + values: { + required: 'required-value', + }, + }); + + expect(broker).toHaveBeenCalledWith( + expect.objectContaining({ + createdBy: undefined, + spec: expect.objectContaining({ + user: { entity: undefined, ref: undefined }, + }), + }), + ); + }); }); describe('GET /v2/tasks/:taskId', () => { diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index e7413b3a87..5453dcd234 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -16,7 +16,11 @@ import { PluginDatabaseManager, UrlReader } from '@backstage/backend-common'; import { CatalogApi } from '@backstage/catalog-client'; -import { parseEntityRef, stringifyEntityRef } from '@backstage/catalog-model'; +import { + parseEntityRef, + stringifyEntityRef, + UserEntity, +} from '@backstage/catalog-model'; import { Entity } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import { InputError, NotFoundError } from '@backstage/errors'; @@ -130,10 +134,11 @@ export async function createRouter( '/v2/templates/:namespace/:kind/:name/parameter-schema', async (req, res) => { const { namespace, kind, name } = req.params; + const { token } = parseBearerToken(req.headers.authorization); const template = await findTemplate({ catalogApi: catalogClient, entityRef: { kind, namespace, name }, - token: getBearerToken(req.headers.authorization), + token, }); if (isSupportedTemplate(template)) { const parameters = [template.spec.parameters ?? []].flat(); @@ -168,12 +173,20 @@ export async function createRouter( const { kind, namespace, name } = parseEntityRef(templateRef, { defaultKind: 'template', }); + const { token, entityRef: userEntityRef } = parseBearerToken( + req.headers.authorization, + ); + + const userEntity = userEntityRef + ? await catalogClient.getEntityByRef(userEntityRef, { token }) + : undefined; + const values = req.body.values; - const token = getBearerToken(req.headers.authorization); + const template = await findTemplate({ catalogApi: catalogClient, entityRef: { kind, namespace, name }, - token: getBearerToken(req.headers.authorization), + token, }); if (!isSupportedTemplate(template)) { @@ -203,6 +216,10 @@ export async function createRouter( })), output: template.spec.output ?? {}, parameters: values, + user: { + entity: userEntity as UserEntity, + ref: userEntityRef, + }, templateInfo: { entityRef: stringifyEntityRef({ kind, @@ -215,6 +232,7 @@ export async function createRouter( const result = await taskBroker.dispatch({ spec: taskSpec, + createdBy: userEntityRef, secrets: { ...req.body.secrets, backstageToken: token, @@ -315,6 +333,21 @@ export async function createRouter( return app; } -function getBearerToken(header?: string): string | undefined { - return header?.match(/Bearer\s+(\S+)/i)?.[1]; +function parseBearerToken(header?: string): { + token?: string; + entityRef?: string; +} { + const token = header?.match(/Bearer\s+(\S+)/i)?.[1]; + + if (!token) return {}; + + const [_header, rawPayload, _signature] = token.split('.'); + const payload: { sub: string } = JSON.parse( + Buffer.from(rawPayload, 'base64').toString(), + ); + + return { + entityRef: payload.sub, + token, + }; } diff --git a/plugins/scaffolder-common/api-report.md b/plugins/scaffolder-common/api-report.md index 532e72d3fd..a1c5c2bf23 100644 --- a/plugins/scaffolder-common/api-report.md +++ b/plugins/scaffolder-common/api-report.md @@ -7,6 +7,7 @@ import { Entity } from '@backstage/catalog-model'; import { JsonObject } from '@backstage/types'; import { JsonValue } from '@backstage/types'; import { KindValidator } from '@backstage/catalog-model'; +import { UserEntity } from '@backstage/catalog-model'; // @public export type TaskSpec = TaskSpecV1beta3; @@ -20,6 +21,10 @@ export interface TaskSpecV1beta3 { parameters: JsonObject; steps: TaskStep[]; templateInfo?: TemplateInfo; + user?: { + entity?: UserEntity; + ref?: string; + }; } // @public diff --git a/plugins/scaffolder-common/src/TaskSpec.ts b/plugins/scaffolder-common/src/TaskSpec.ts index e745964ff7..4ba43092ae 100644 --- a/plugins/scaffolder-common/src/TaskSpec.ts +++ b/plugins/scaffolder-common/src/TaskSpec.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { UserEntity } from '@backstage/catalog-model'; import { JsonValue, JsonObject } from '@backstage/types'; /** @@ -91,6 +92,19 @@ export interface TaskSpecV1beta3 { * Some information about the template that is stored on the task spec. */ templateInfo?: TemplateInfo; + /** + * Some decoration of the author of the task that should be available in the context + */ + user?: { + /** + * The decorated entity from the Catalog + */ + entity?: UserEntity; + /** + * An entity ref for the author of the task + */ + ref?: string; + }; } /**