diff --git a/.changeset/dry-tips-build.md b/.changeset/dry-tips-build.md index 9137acd62b..b800161fc8 100644 --- a/.changeset/dry-tips-build.md +++ b/.changeset/dry-tips-build.md @@ -1,5 +1,5 @@ --- -'@backstage/plugin-auth-node': minor +'@backstage/plugin-auth-node': patch --- `IdentityClient` is now deprecated. Please migrate to `IdentityApi` and `DefaultIdentityClient` instead. The authenticate function on `DefaultIdentityClient` is also deprecated. Please use `getIdentity` instead. diff --git a/.changeset/itchy-kiwis-warn.md b/.changeset/itchy-kiwis-warn.md index 6f5289d074..28c4c875ff 100644 --- a/.changeset/itchy-kiwis-warn.md +++ b/.changeset/itchy-kiwis-warn.md @@ -47,6 +47,7 @@ export async function createRouter( ): Promise { const { identity } = options; - const user = identity.getIdentity(req); - ... + router.get('/user', async (req, res) => { + const user = await identity.getIdentity({ request: req }); + ... ``` diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 00fc2d529e..4fcbcab826 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -75,8 +75,6 @@ function makeCreateEnv(config: Config) { const taskScheduler = TaskScheduler.fromConfig(config); const identity = DefaultIdentityClient.create({ discovery, - algorithms: undefined, - issuer: undefined, }); root.info(`Created UrlReader ${reader}`); diff --git a/packages/backend/src/plugins/permission.ts b/packages/backend/src/plugins/permission.ts index ab647b7f95..0d3dcc588d 100644 --- a/packages/backend/src/plugins/permission.ts +++ b/packages/backend/src/plugins/permission.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import { DefaultIdentityClient } from '@backstage/plugin-auth-node'; import { createRouter } from '@backstage/plugin-permission-backend'; import { AuthorizeResult, @@ -40,9 +39,6 @@ export default async function createPlugin( logger: env.logger, discovery: env.discovery, policy: new AllowAllPermissionPolicy(), - identity: DefaultIdentityClient.create({ - discovery: env.discovery, - issuer: await env.discovery.getExternalBaseUrl('auth'), - }), + identity: env.identity, }); } diff --git a/packages/create-app/templates/default-app/packages/backend/src/index.ts b/packages/create-app/templates/default-app/packages/backend/src/index.ts index 44f4caf777..11422e00dd 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/index.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/index.ts @@ -44,7 +44,6 @@ function makeCreateEnv(config: Config) { const identity = DefaultIdentityClient.create({ discovery, }); - const permissions = ServerPermissionClient.fromConfig(config, { discovery, tokenManager, diff --git a/plugins/auth-node/api-report.md b/plugins/auth-node/api-report.md index 0f1c5bb934..c6d4b4e3f8 100644 --- a/plugins/auth-node/api-report.md +++ b/plugins/auth-node/api-report.md @@ -29,7 +29,11 @@ export class DefaultIdentityClient implements IdentityApi { authenticate(token: string | undefined): Promise; static create(options: IdentityClientOptions): DefaultIdentityClient; // (undocumented) - getIdentity(req: Request_2): Promise; + getIdentity({ + request, + }: IdentityApiGetIdentityRequest): Promise< + BackstageIdentityResponse | undefined + >; } // @public @@ -40,10 +44,15 @@ export function getBearerTokenFromAuthorizationHeader( // @public export interface IdentityApi { getIdentity( - req: Request_2, + options: IdentityApiGetIdentityRequest, ): Promise; } +// @public +export type IdentityApiGetIdentityRequest = { + request: Request_2; +}; + // @public @deprecated export class IdentityClient { // @deprecated diff --git a/plugins/auth-node/src/DefaultIdentityClient.test.ts b/plugins/auth-node/src/DefaultIdentityClient.test.ts index 27aceaa780..13d9c5a17b 100644 --- a/plugins/auth-node/src/DefaultIdentityClient.test.ts +++ b/plugins/auth-node/src/DefaultIdentityClient.test.ts @@ -26,6 +26,7 @@ import { setupServer } from 'msw/node'; import { v4 as uuid } from 'uuid'; import { DefaultIdentityClient } from './DefaultIdentityClient'; +import { IdentityApiGetIdentityRequest } from './types'; interface AnyJWK extends Record { use: 'sig'; @@ -366,4 +367,51 @@ describe('DefaultIdentityClient', () => { dateSpy.mockClear(); }); }); + + describe('getIdentity', () => { + beforeEach(() => { + server.use( + rest.get( + `${mockBaseUrl}/.well-known/jwks.json`, + async (_, res, ctx) => { + const keys = await factory.listPublicKeys(); + return res(ctx.json(keys)); + }, + ), + ); + }); + + it('returns the identity', async () => { + const token = await factory.issueToken({ + claims: { sub: 'foo', ent: ['entity1', 'entity2'] }, + }); + const response = await client.getIdentity({ + request: { headers: { authorization: `Bearer ${token}` } }, + } as IdentityApiGetIdentityRequest); + expect(response).toEqual({ + token: token, + identity: { + type: 'user', + userEntityRef: 'foo', + ownershipEntityRefs: ['entity1', 'entity2'], + }, + }); + }); + + it('given a corrupt the identity', async () => { + await expect( + client.getIdentity({ + request: { headers: { authorization: `Bearer bad-token` } }, + } as IdentityApiGetIdentityRequest), + ).rejects.toThrow('Invalid JWT'); + }); + + it('given no authorization header', async () => { + expect( + await client.getIdentity({ + request: { headers: {} }, + } as IdentityApiGetIdentityRequest), + ).toEqual(undefined); + }); + }); }); diff --git a/plugins/auth-node/src/DefaultIdentityClient.ts b/plugins/auth-node/src/DefaultIdentityClient.ts index 8c09157c5c..167b0c2451 100644 --- a/plugins/auth-node/src/DefaultIdentityClient.ts +++ b/plugins/auth-node/src/DefaultIdentityClient.ts @@ -24,9 +24,11 @@ import { jwtVerify, } from 'jose'; import { GetKeyFunction } from 'jose/dist/types/types'; -import { Request } from 'express'; -import { BackstageIdentityResponse } from './types'; +import { + BackstageIdentityResponse, + IdentityApiGetIdentityRequest, +} from './types'; import { getBearerTokenFromAuthorizationHeader, IdentityApi } from '.'; const CLOCK_MARGIN_S = 10; @@ -75,14 +77,13 @@ export class DefaultIdentityClient implements IdentityApi { : ['ES256']; } - async getIdentity(req: Request) { - try { - return await this.authenticate( - getBearerTokenFromAuthorizationHeader(req.headers.authorization), - ); - } catch (e) { + async getIdentity({ request }: IdentityApiGetIdentityRequest) { + if (!request.headers.authorization) { return undefined; } + return await this.authenticate( + getBearerTokenFromAuthorizationHeader(request.headers.authorization), + ); } /** diff --git a/plugins/auth-node/src/IdentityApi.ts b/plugins/auth-node/src/IdentityApi.ts index 32a1040d18..86171fa3ab 100644 --- a/plugins/auth-node/src/IdentityApi.ts +++ b/plugins/auth-node/src/IdentityApi.ts @@ -13,8 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { BackstageIdentityResponse } from './types'; -import { Request } from 'express'; +import { + BackstageIdentityResponse, + IdentityApiGetIdentityRequest, +} from './types'; /** * An identity client api to authenticate Backstage @@ -30,6 +32,6 @@ export interface IdentityApi { * The method throws an error if verification fails. */ getIdentity( - req: Request, + options: IdentityApiGetIdentityRequest, ): Promise; } diff --git a/plugins/auth-node/src/index.ts b/plugins/auth-node/src/index.ts index a17678a038..a70f667adf 100644 --- a/plugins/auth-node/src/index.ts +++ b/plugins/auth-node/src/index.ts @@ -29,4 +29,5 @@ export type { BackstageIdentityResponse, BackstageSignInResult, BackstageUserIdentity, + IdentityApiGetIdentityRequest, } from './types'; diff --git a/plugins/auth-node/src/types.ts b/plugins/auth-node/src/types.ts index 0d3e015beb..9aec98a372 100644 --- a/plugins/auth-node/src/types.ts +++ b/plugins/auth-node/src/types.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +import { Request } from 'express'; + /** * A representation of a successful Backstage sign-in. * @@ -29,6 +31,15 @@ export interface BackstageSignInResult { token: string; } +/** + * Options to request the identity from a Backstage backend request + * + * @public + */ +export type IdentityApiGetIdentityRequest = { + request: Request; +}; + /** * Response object containing the {@link BackstageUserIdentity} and the token * from the authentication provider. diff --git a/plugins/example-todo-list-backend/src/service/router.ts b/plugins/example-todo-list-backend/src/service/router.ts index 58ce730d24..2973b6eab6 100644 --- a/plugins/example-todo-list-backend/src/service/router.ts +++ b/plugins/example-todo-list-backend/src/service/router.ts @@ -61,7 +61,7 @@ export async function createRouter( router.post('/todos', async (req, res) => { let author: string | undefined = undefined; - const user = await identity.getIdentity(req); + const user = await identity.getIdentity({ request: req }); author = user?.identity.userEntityRef; if (!isTodoCreateRequest(req.body)) { diff --git a/plugins/permission-backend/src/service/router.test.ts b/plugins/permission-backend/src/service/router.test.ts index 7668245679..7278835c5c 100644 --- a/plugins/permission-backend/src/service/router.test.ts +++ b/plugins/permission-backend/src/service/router.test.ts @@ -70,7 +70,7 @@ describe('createRouter', () => { getExternalBaseUrl: jest.fn(), }, identity: { - getIdentity: jest.fn(req => { + getIdentity: jest.fn(({ request: req }) => { const token = req.headers.authorization?.replace(/^Bearer[ ]+/, ''); if (!token) { diff --git a/plugins/permission-backend/src/service/router.ts b/plugins/permission-backend/src/service/router.ts index 217c135ac3..684cdebefd 100644 --- a/plugins/permission-backend/src/service/router.ts +++ b/plugins/permission-backend/src/service/router.ts @@ -188,7 +188,7 @@ export async function createRouter( req: Request, res: Response, ) => { - const user = await identity.getIdentity(req); + const user = await identity.getIdentity({ request: req }); const parseResult = evaluatePermissionRequestBatchSchema.safeParse( req.body, diff --git a/plugins/scaffolder-backend/api-report.md b/plugins/scaffolder-backend/api-report.md index c49db66aa4..c692cee9aa 100644 --- a/plugins/scaffolder-backend/api-report.md +++ b/plugins/scaffolder-backend/api-report.md @@ -420,7 +420,7 @@ export const createPublishGitlabMergeRequestAction: (options: { branchName: string; targetPath: string; token?: string | undefined; - commitAction?: 'update' | 'create' | 'delete' | undefined; + commitAction?: 'update' | 'delete' | 'create' | undefined; projectid?: string | undefined; removeSourceBranch?: boolean | undefined; assignee?: string | undefined; @@ -536,7 +536,7 @@ export interface RouterOptions { // (undocumented) database: PluginDatabaseManager; // (undocumented) - identity: IdentityApi; + identity?: IdentityApi; // (undocumented) logger: Logger; // (undocumented) diff --git a/plugins/scaffolder-backend/src/service/router.test.ts b/plugins/scaffolder-backend/src/service/router.test.ts index 9e2c7b9265..1c26ee57fd 100644 --- a/plugins/scaffolder-backend/src/service/router.test.ts +++ b/plugins/scaffolder-backend/src/service/router.test.ts @@ -40,6 +40,10 @@ import { } from '@backstage/catalog-model'; import { createRouter, DatabaseTaskStore, TaskBroker } from '../index'; import { StorageTaskBroker } from '../scaffolder/tasks/StorageTaskBroker'; +import { + IdentityApiGetIdentityRequest, + BackstageIdentityResponse, +} from '@backstage/plugin-auth-node'; const mockAccess = jest.fn(); @@ -74,6 +78,8 @@ const mockUrlReader = UrlReaders.default({ config: new ConfigReader({}), }); +const getIdentity = jest.fn(); + describe('createRouter', () => { let app: express.Express; let loggerSpy: jest.SpyInstance; @@ -125,372 +131,511 @@ describe('createRouter', () => { }, }; - beforeEach(async () => { - const logger = getVoidLogger(); - const databaseTaskStore = await DatabaseTaskStore.create({ - database: createDatabase(), - }); - taskBroker = new StorageTaskBroker(databaseTaskStore, logger); - - jest.spyOn(taskBroker, 'dispatch'); - jest.spyOn(taskBroker, 'get'); - jest.spyOn(taskBroker, 'list'); - jest.spyOn(taskBroker, 'event$'); - loggerSpy = jest.spyOn(logger, 'info'); - - const router = await createRouter({ - logger: logger, - config: new ConfigReader({}), - database: createDatabase(), - 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}`); + describe('not providing an identity api', () => { + beforeEach(async () => { + const logger = getVoidLogger(); + const databaseTaskStore = await DatabaseTaskStore.create({ + database: createDatabase(), }); - }); + taskBroker = new StorageTaskBroker(databaseTaskStore, logger); - afterEach(() => { - jest.resetAllMocks(); - }); + jest.spyOn(taskBroker, 'dispatch'); + jest.spyOn(taskBroker, 'get'); + jest.spyOn(taskBroker, 'list'); + jest.spyOn(taskBroker, 'event$'); + loggerSpy = jest.spyOn(logger, 'info'); - describe('GET /v2/actions', () => { - it('lists available actions', async () => { - const response = await request(app).get('/v2/actions').send(); - expect(response.status).toEqual(200); - expect(response.body[0].id).toBeDefined(); - expect(response.body.length).toBeGreaterThan(8); - }); - }); + const router = await createRouter({ + logger: logger, + config: new ConfigReader({}), + database: createDatabase(), + catalogClient, + reader: mockUrlReader, + taskBroker, + }); + app = express().use(router); - describe('POST /v2/tasks', () => { - it('rejects template values which do not match the template schema definition', async () => { - const response = await request(app) - .post('/v2/tasks') - .send({ - templateRef: stringifyEntityRef({ - kind: 'template', - name: 'create-react-app-template', - }), - values: { - storePath: 'https://github.com/backstage/backstage', - }, + 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}`); }); - - expect(response.status).toEqual(400); }); - it('return the template id', async () => { - const broker = taskBroker.dispatch as jest.Mocked['dispatch']; - broker.mockResolvedValue({ - taskId: 'a-random-id', + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('GET /v2/actions', () => { + it('lists available actions', async () => { + const response = await request(app).get('/v2/actions').send(); + expect(response.status).toEqual(200); + expect(response.body[0].id).toBeDefined(); + expect(response.body.length).toBeGreaterThan(8); + }); + }); + + describe('POST /v2/tasks', () => { + it('rejects template values which do not match the template schema definition', async () => { + const response = await request(app) + .post('/v2/tasks') + .send({ + templateRef: stringifyEntityRef({ + kind: 'template', + name: 'create-react-app-template', + }), + values: { + storePath: 'https://github.com/backstage/backstage', + }, + }); + + expect(response.status).toEqual(400); }); - const response = await request(app) - .post('/v2/tasks') - .send({ - templateRef: stringifyEntityRef({ - kind: 'template', - name: 'create-react-app-template', - }), - values: { - required: 'required-value', - }, + it('return the template id', async () => { + const broker = + taskBroker.dispatch as jest.Mocked['dispatch']; + broker.mockResolvedValue({ + taskId: 'a-random-id', }); - 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'; - - 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: { + const response = await request(app) + .post('/v2/tasks') + .send({ + templateRef: stringifyEntityRef({ + kind: 'template', + name: 'create-react-app-template', + }), + values: { 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', - entity: { - metadata: mockTemplate.metadata, - }, - }, - }, - }), - ); - }); + }); - it('should not throw when an invalid authorization header is passed', async () => { - const broker = taskBroker.dispatch as jest.Mocked['dispatch']; - const mockToken = 'blob.eyJzdWIiOiIiLCJuYW1lIjoiSm9obiBEb2UifQ.blob'; + expect(response.body.id).toBe('a-random-id'); + expect(response.status).toEqual(201); + }); - 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: undefined, - secrets: { - backstageToken: undefined, - }, + it('should call the broker with a correct spec', async () => { + const broker = + taskBroker.dispatch as jest.Mocked['dispatch']; + const mockToken = + 'blob.eyJzdWIiOiJ1c2VyOmRlZmF1bHQvZ3Vlc3QiLCJuYW1lIjoiSm9obiBEb2UifQ.blob'; - 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: { + await request(app) + .post('/v2/tasks') + .set('Authorization', `Bearer ${mockToken}`) + .send({ + templateRef: stringifyEntityRef({ + kind: 'template', + name: 'create-react-app-template', + }), + values: { required: 'required-value', }, - user: { - entity: undefined, - ref: undefined, + }); + expect(broker).toHaveBeenCalledWith( + expect.objectContaining({ + createdBy: 'user:default/guest', + secrets: { + backstageToken: mockToken, }, - templateInfo: { - entityRef: stringifyEntityRef({ - kind: 'Template', - namespace: 'Default', - name: mockTemplate.metadata?.name, - }), - baseUrl: 'https://dev.azure.com', - entity: { - metadata: mockTemplate.metadata, + + 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', + entity: { + metadata: mockTemplate.metadata, + }, }, }, - }, - }), - ); + }), + ); + }); + + it('should not throw when an invalid authorization header is passed', async () => { + const broker = + taskBroker.dispatch as jest.Mocked['dispatch']; + const mockToken = 'blob.eyJzdWIiOiIiLCJuYW1lIjoiSm9obiBEb2UifQ.blob'; + + 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: 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: { + required: 'required-value', + }, + user: { + entity: undefined, + ref: undefined, + }, + templateInfo: { + entityRef: stringifyEntityRef({ + kind: 'Template', + namespace: 'Default', + name: mockTemplate.metadata?.name, + }), + baseUrl: 'https://dev.azure.com', + entity: { + metadata: mockTemplate.metadata, + }, + }, + }, + }), + ); + }); + + it('should not decorate a user when no backstage auth is passed', async () => { + const broker = + taskBroker.dispatch as jest.Mocked['dispatch']; + + await request(app) + .post('/v2/tasks') + .send({ + templateRef: stringifyEntityRef({ + kind: 'template', + name: 'create-react-app-template', + }), + values: { + required: 'required-value', + }, + }); + + 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: { + required: 'required-value', + }, + }); + + 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'; + + 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(loggerSpy).toHaveBeenCalledTimes(1); + expect(loggerSpy).toHaveBeenCalledWith( + 'Scaffolding task for template:default/create-react-app-template created by user:default/guest', + ); + }); }); - 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', - }, + describe('GET /v2/tasks', () => { + it('return all tasks', async () => { + ( + taskBroker.list as jest.Mocked>['list'] + ).mockResolvedValue({ + tasks: [ + { + id: 'a-random-id', + spec: {} as any, + status: 'completed', + createdAt: '', + createdBy: '', + }, + ], }); - expect(broker).toHaveBeenCalledWith( - expect.objectContaining({ + const response = await request(app).get(`/v2/tasks`); + expect(taskBroker.list).toBeCalledWith({ createdBy: undefined, - spec: expect.objectContaining({ - user: { entity: undefined, ref: undefined }, - }), - }), - ); - }); + }); + expect(response.status).toEqual(200); + expect(response.body).toStrictEqual({ + tasks: [ + { + id: 'a-random-id', + spec: {} as any, + status: 'completed', + createdAt: '', + createdBy: '', + }, + ], + }); + }); - 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: { - required: 'required-value', - }, + it('return filtered tasks', async () => { + ( + taskBroker.list as jest.Mocked>['list'] + ).mockResolvedValue({ + tasks: [ + { + id: 'a-random-id', + spec: {} as any, + status: 'completed', + createdAt: '', + createdBy: 'user:default/foo', + }, + ], }); - 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'; - - await request(app) - .post('/v2/tasks') - .set('Authorization', `Bearer ${mockToken}`) - .send({ - templateRef: stringifyEntityRef({ - kind: 'template', - name: 'create-react-app-template', - }), - values: { - required: 'required-value', - }, + const response = await request(app).get( + `/v2/tasks?createdBy=user:default/foo`, + ); + expect(taskBroker.list).toBeCalledWith({ + createdBy: 'user:default/foo', }); - expect(loggerSpy).toHaveBeenCalledTimes(1); - expect(loggerSpy).toHaveBeenCalledWith( - 'Scaffolding task for template:default/create-react-app-template created by user:default/guest', - ); - }); - }); - - describe('GET /v2/tasks', () => { - it('return all tasks', async () => { - ( - taskBroker.list as jest.Mocked>['list'] - ).mockResolvedValue({ - tasks: [ - { - id: 'a-random-id', - spec: {} as any, - status: 'completed', - createdAt: '', - createdBy: '', - }, - ], - }); - - const response = await request(app).get(`/v2/tasks`); - expect(taskBroker.list).toBeCalledWith({ - createdBy: undefined, - }); - expect(response.status).toEqual(200); - expect(response.body).toStrictEqual({ - tasks: [ - { - id: 'a-random-id', - spec: {} as any, - status: 'completed', - createdAt: '', - createdBy: '', - }, - ], + expect(response.status).toEqual(200); + expect(response.body).toStrictEqual({ + tasks: [ + { + id: 'a-random-id', + spec: {} as any, + status: 'completed', + createdAt: '', + createdBy: 'user:default/foo', + }, + ], + }); }); }); - it('return filtered tasks', async () => { - ( - taskBroker.list as jest.Mocked>['list'] - ).mockResolvedValue({ - tasks: [ - { - id: 'a-random-id', - spec: {} as any, - status: 'completed', - createdAt: '', - createdBy: 'user:default/foo', - }, - ], - }); + describe('GET /v2/tasks/:taskId', () => { + it('does not divulge secrets', async () => { + (taskBroker.get as jest.Mocked['get']).mockResolvedValue({ + id: 'a-random-id', + spec: {} as any, + status: 'completed', + createdAt: '', + secrets: { backstageToken: 'secret' }, + createdBy: '', + }); - const response = await request(app).get( - `/v2/tasks?createdBy=user:default/foo`, - ); - expect(taskBroker.list).toBeCalledWith({ - createdBy: 'user:default/foo', - }); - - expect(response.status).toEqual(200); - expect(response.body).toStrictEqual({ - tasks: [ - { - id: 'a-random-id', - spec: {} as any, - status: 'completed', - createdAt: '', - createdBy: 'user:default/foo', - }, - ], + const response = await request(app).get(`/v2/tasks/a-random-id`); + expect(response.status).toEqual(200); + expect(response.body.status).toBe('completed'); + expect(response.body.secrets).toBeUndefined(); }); }); - }); - describe('GET /v2/tasks/:taskId', () => { - it('does not divulge secrets', async () => { - (taskBroker.get as jest.Mocked['get']).mockResolvedValue({ - id: 'a-random-id', - spec: {} as any, - status: 'completed', - createdAt: '', - secrets: { backstageToken: 'secret' }, - createdBy: '', + describe('GET /v2/tasks/:taskId/eventstream', () => { + it('should return log messages', async () => { + let subscriber: ZenObservable.SubscriptionObserver; + ( + taskBroker.event$ as jest.Mocked['event$'] + ).mockImplementation(({ taskId }) => { + return new ObservableImpl(observer => { + subscriber = observer; + setImmediate(() => { + observer.next({ + events: [ + { + id: 0, + taskId, + type: 'log', + createdAt: '', + body: { message: 'My log message' }, + }, + ], + }); + observer.next({ + events: [ + { + id: 1, + taskId, + type: 'completion', + createdAt: '', + body: { message: 'Finished!' }, + }, + ], + }); + }); + }); + // emit after this function returned + }); + + let statusCode: any = undefined; + let headers: any = {}; + const responseDataFn = jest.fn(); + + const req = request(app) + .get('/v2/tasks/a-random-id/eventstream') + .set('accept', 'text/event-stream') + .parse((res, _) => { + ({ statusCode, headers } = res as any); + + res.on('data', chunk => { + responseDataFn(chunk.toString()); + + // the server expects the client to abort the request + if (chunk.includes('completion')) { + req.abort(); + } + }); + }); + + // wait for the request to finish + await req.catch(() => { + // ignore 'aborted' error + }); + + expect(statusCode).toBe(200); + expect(headers['content-type']).toBe('text/event-stream'); + expect(responseDataFn).toBeCalledTimes(2); + expect(responseDataFn).toBeCalledWith(`event: log +data: {"id":0,"taskId":"a-random-id","type":"log","createdAt":"","body":{"message":"My log message"}} + +`); + expect(responseDataFn).toBeCalledWith(`event: completion +data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{"message":"Finished!"}} + +`); + + expect(taskBroker.event$).toBeCalledTimes(1); + expect(taskBroker.event$).toBeCalledWith({ taskId: 'a-random-id' }); + expect(subscriber!.closed).toBe(true); }); - const response = await request(app).get(`/v2/tasks/a-random-id`); - expect(response.status).toEqual(200); - expect(response.body.status).toBe('completed'); - expect(response.body.secrets).toBeUndefined(); - }); - }); + it('should return log messages with after query', async () => { + let subscriber: ZenObservable.SubscriptionObserver; + ( + taskBroker.event$ as jest.Mocked['event$'] + ).mockImplementation(({ taskId }) => { + return new ObservableImpl(observer => { + subscriber = observer; + setImmediate(() => { + observer.next({ + events: [ + { + id: 1, + taskId, + type: 'completion', + createdAt: '', + body: { message: 'Finished!' }, + }, + ], + }); + }); + }); + }); - describe('GET /v2/tasks/:taskId/eventstream', () => { - it('should return log messages', async () => { - let subscriber: ZenObservable.SubscriptionObserver; - ( - taskBroker.event$ as jest.Mocked['event$'] - ).mockImplementation(({ taskId }) => { - return new ObservableImpl(observer => { - subscriber = observer; - setImmediate(() => { + let statusCode: any = undefined; + let headers: any = {}; + + const req = request(app) + .get('/v2/tasks/a-random-id/eventstream') + .query({ after: 10 }) + .set('accept', 'text/event-stream') + .parse((res, _) => { + ({ statusCode, headers } = res as any); + + res.on('data', () => { + // close immediately + req.abort(); + }); + }); + + // wait for the request to finish + await req.catch(() => { + // ignore 'aborted' error + }); + + expect(statusCode).toBe(200); + expect(headers['content-type']).toBe('text/event-stream'); + + expect(taskBroker.event$).toBeCalledTimes(1); + expect(taskBroker.event$).toBeCalledWith({ + taskId: 'a-random-id', + after: 10, + }); + + expect(subscriber!.closed).toBe(true); + }); + }); + + describe('GET /v2/tasks/:taskId/events', () => { + it('should return log messages', async () => { + let subscriber: ZenObservable.SubscriptionObserver; + ( + taskBroker.event$ as jest.Mocked['event$'] + ).mockImplementation(({ taskId }) => { + return new ObservableImpl(observer => { + subscriber = observer; observer.next({ events: [ { @@ -500,10 +645,6 @@ describe('createRouter', () => { createdAt: '', body: { message: 'My log message' }, }, - ], - }); - observer.next({ - events: [ { id: 1, taskId, @@ -515,61 +656,614 @@ describe('createRouter', () => { }); }); }); - // emit after this function returned + + const response = await request(app).get('/v2/tasks/a-random-id/events'); + + expect(response.status).toEqual(200); + expect(response.body).toEqual([ + { + id: 0, + taskId: 'a-random-id', + type: 'log', + createdAt: '', + body: { message: 'My log message' }, + }, + { + id: 1, + taskId: 'a-random-id', + type: 'completion', + createdAt: '', + body: { message: 'Finished!' }, + }, + ]); + + expect(taskBroker.event$).toBeCalledTimes(1); + expect(taskBroker.event$).toBeCalledWith({ taskId: 'a-random-id' }); + expect(subscriber!.closed).toBe(true); }); - let statusCode: any = undefined; - let headers: any = {}; - const responseDataFn = jest.fn(); - - const req = request(app) - .get('/v2/tasks/a-random-id/eventstream') - .set('accept', 'text/event-stream') - .parse((res, _) => { - ({ statusCode, headers } = res as any); - - res.on('data', chunk => { - responseDataFn(chunk.toString()); - - // the server expects the client to abort the request - if (chunk.includes('completion')) { - req.abort(); - } + it('should return log messages with after query', async () => { + let subscriber: ZenObservable.SubscriptionObserver; + ( + taskBroker.event$ as jest.Mocked['event$'] + ).mockImplementation(() => { + return new ObservableImpl(observer => { + subscriber = observer; + observer.next({ events: [] }); }); }); - // wait for the request to finish - await req.catch(() => { - // ignore 'aborted' error + const response = await request(app) + .get('/v2/tasks/a-random-id/events') + .query({ after: 10 }); + + expect(response.status).toEqual(200); + expect(response.body).toEqual([]); + + expect(taskBroker.event$).toBeCalledTimes(1); + expect(taskBroker.event$).toBeCalledWith({ + taskId: 'a-random-id', + after: 10, + }); + expect(subscriber!.closed).toBe(true); + }); + }); + }); + + describe('providing an identity api', () => { + beforeEach(async () => { + const logger = getVoidLogger(); + const databaseTaskStore = await DatabaseTaskStore.create({ + database: createDatabase(), + }); + taskBroker = new StorageTaskBroker(databaseTaskStore, logger); + + jest.spyOn(taskBroker, 'dispatch'); + jest.spyOn(taskBroker, 'get'); + jest.spyOn(taskBroker, 'list'); + 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({}), + database: createDatabase(), + catalogClient, + reader: mockUrlReader, + taskBroker, + identity: { getIdentity }, + }); + 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(() => { + jest.resetAllMocks(); + }); + + describe('GET /v2/actions', () => { + it('lists available actions', async () => { + const response = await request(app).get('/v2/actions').send(); + expect(response.status).toEqual(200); + expect(response.body[0].id).toBeDefined(); + expect(response.body.length).toBeGreaterThan(8); + }); + }); + + describe('POST /v2/tasks', () => { + it('rejects template values which do not match the template schema definition', async () => { + const response = await request(app) + .post('/v2/tasks') + .send({ + templateRef: stringifyEntityRef({ + kind: 'template', + name: 'create-react-app-template', + }), + values: { + storePath: 'https://github.com/backstage/backstage', + }, + }); + + expect(response.status).toEqual(400); }); - expect(statusCode).toBe(200); - expect(headers['content-type']).toBe('text/event-stream'); - expect(responseDataFn).toBeCalledTimes(2); - expect(responseDataFn).toBeCalledWith(`event: log + it('return the template id', async () => { + const broker = + taskBroker.dispatch as jest.Mocked['dispatch']; + broker.mockResolvedValue({ + taskId: 'a-random-id', + }); + + const response = await request(app) + .post('/v2/tasks') + .send({ + templateRef: stringifyEntityRef({ + kind: 'template', + name: 'create-react-app-template', + }), + values: { + required: 'required-value', + }, + }); + + 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']; + + 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: 'user:default/guest', + secrets: { + backstageToken: 'token', + }, + + 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', + entity: { + metadata: mockTemplate.metadata, + }, + }, + }, + }), + ); + }); + + describe('when the identity api throws an error', () => { + beforeEach(() => { + getIdentity.mockImplementation( + async ({ + request: _request, + }: IdentityApiGetIdentityRequest): Promise< + BackstageIdentityResponse | undefined + > => { + throw new Error('whoops!'); + }, + ); + }); + it('should not throw', 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, + 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: { + required: 'required-value', + }, + 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, + }, + }, + }, + }), + ); + }); + }); + + describe('no auth is provided', () => { + beforeEach(() => { + getIdentity.mockImplementation( + async ({ + request: _request, + }: IdentityApiGetIdentityRequest): Promise< + BackstageIdentityResponse | undefined + > => { + return undefined; + }, + ); + }); + + it('should not decorate a user when no backstage auth is passed', async () => { + const broker = + taskBroker.dispatch as jest.Mocked['dispatch']; + + await request(app) + .post('/v2/tasks') + .send({ + templateRef: stringifyEntityRef({ + kind: 'template', + name: 'create-react-app-template', + }), + values: { + required: 'required-value', + }, + }); + + 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: { + required: 'required-value', + }, + }); + + 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'; + + 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(loggerSpy).toHaveBeenCalledTimes(1); + expect(loggerSpy).toHaveBeenCalledWith( + 'Scaffolding task for template:default/create-react-app-template created by user:default/guest', + ); + }); + }); + + describe('GET /v2/tasks', () => { + it('return all tasks', async () => { + ( + taskBroker.list as jest.Mocked>['list'] + ).mockResolvedValue({ + tasks: [ + { + id: 'a-random-id', + spec: {} as any, + status: 'completed', + createdAt: '', + createdBy: '', + }, + ], + }); + + const response = await request(app).get(`/v2/tasks`); + expect(taskBroker.list).toBeCalledWith({ + createdBy: undefined, + }); + expect(response.status).toEqual(200); + expect(response.body).toStrictEqual({ + tasks: [ + { + id: 'a-random-id', + spec: {} as any, + status: 'completed', + createdAt: '', + createdBy: '', + }, + ], + }); + }); + + it('return filtered tasks', async () => { + ( + taskBroker.list as jest.Mocked>['list'] + ).mockResolvedValue({ + tasks: [ + { + id: 'a-random-id', + spec: {} as any, + status: 'completed', + createdAt: '', + createdBy: 'user:default/foo', + }, + ], + }); + + const response = await request(app).get( + `/v2/tasks?createdBy=user:default/foo`, + ); + expect(taskBroker.list).toBeCalledWith({ + createdBy: 'user:default/foo', + }); + + expect(response.status).toEqual(200); + expect(response.body).toStrictEqual({ + tasks: [ + { + id: 'a-random-id', + spec: {} as any, + status: 'completed', + createdAt: '', + createdBy: 'user:default/foo', + }, + ], + }); + }); + }); + + describe('GET /v2/tasks/:taskId', () => { + it('does not divulge secrets', async () => { + (taskBroker.get as jest.Mocked['get']).mockResolvedValue({ + id: 'a-random-id', + spec: {} as any, + status: 'completed', + createdAt: '', + secrets: { backstageToken: 'secret' }, + createdBy: '', + }); + + const response = await request(app).get(`/v2/tasks/a-random-id`); + expect(response.status).toEqual(200); + expect(response.body.status).toBe('completed'); + expect(response.body.secrets).toBeUndefined(); + }); + }); + + describe('GET /v2/tasks/:taskId/eventstream', () => { + it('should return log messages', async () => { + let subscriber: ZenObservable.SubscriptionObserver; + ( + taskBroker.event$ as jest.Mocked['event$'] + ).mockImplementation(({ taskId }) => { + return new ObservableImpl(observer => { + subscriber = observer; + setImmediate(() => { + observer.next({ + events: [ + { + id: 0, + taskId, + type: 'log', + createdAt: '', + body: { message: 'My log message' }, + }, + ], + }); + observer.next({ + events: [ + { + id: 1, + taskId, + type: 'completion', + createdAt: '', + body: { message: 'Finished!' }, + }, + ], + }); + }); + }); + // emit after this function returned + }); + + let statusCode: any = undefined; + let headers: any = {}; + const responseDataFn = jest.fn(); + + const req = request(app) + .get('/v2/tasks/a-random-id/eventstream') + .set('accept', 'text/event-stream') + .parse((res, _) => { + ({ statusCode, headers } = res as any); + + res.on('data', chunk => { + responseDataFn(chunk.toString()); + + // the server expects the client to abort the request + if (chunk.includes('completion')) { + req.abort(); + } + }); + }); + + // wait for the request to finish + await req.catch(() => { + // ignore 'aborted' error + }); + + expect(statusCode).toBe(200); + expect(headers['content-type']).toBe('text/event-stream'); + expect(responseDataFn).toBeCalledTimes(2); + expect(responseDataFn).toBeCalledWith(`event: log data: {"id":0,"taskId":"a-random-id","type":"log","createdAt":"","body":{"message":"My log message"}} `); - expect(responseDataFn).toBeCalledWith(`event: completion + expect(responseDataFn).toBeCalledWith(`event: completion data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{"message":"Finished!"}} `); - expect(taskBroker.event$).toBeCalledTimes(1); - expect(taskBroker.event$).toBeCalledWith({ taskId: 'a-random-id' }); - expect(subscriber!.closed).toBe(true); + expect(taskBroker.event$).toBeCalledTimes(1); + expect(taskBroker.event$).toBeCalledWith({ taskId: 'a-random-id' }); + expect(subscriber!.closed).toBe(true); + }); + + it('should return log messages with after query', async () => { + let subscriber: ZenObservable.SubscriptionObserver; + ( + taskBroker.event$ as jest.Mocked['event$'] + ).mockImplementation(({ taskId }) => { + return new ObservableImpl(observer => { + subscriber = observer; + setImmediate(() => { + observer.next({ + events: [ + { + id: 1, + taskId, + type: 'completion', + createdAt: '', + body: { message: 'Finished!' }, + }, + ], + }); + }); + }); + }); + + let statusCode: any = undefined; + let headers: any = {}; + + const req = request(app) + .get('/v2/tasks/a-random-id/eventstream') + .query({ after: 10 }) + .set('accept', 'text/event-stream') + .parse((res, _) => { + ({ statusCode, headers } = res as any); + + res.on('data', () => { + // close immediately + req.abort(); + }); + }); + + // wait for the request to finish + await req.catch(() => { + // ignore 'aborted' error + }); + + expect(statusCode).toBe(200); + expect(headers['content-type']).toBe('text/event-stream'); + + expect(taskBroker.event$).toBeCalledTimes(1); + expect(taskBroker.event$).toBeCalledWith({ + taskId: 'a-random-id', + after: 10, + }); + + expect(subscriber!.closed).toBe(true); + }); }); - it('should return log messages with after query', async () => { - let subscriber: ZenObservable.SubscriptionObserver; - ( - taskBroker.event$ as jest.Mocked['event$'] - ).mockImplementation(({ taskId }) => { - return new ObservableImpl(observer => { - subscriber = observer; - setImmediate(() => { + describe('GET /v2/tasks/:taskId/events', () => { + it('should return log messages', async () => { + let subscriber: ZenObservable.SubscriptionObserver; + ( + taskBroker.event$ as jest.Mocked['event$'] + ).mockImplementation(({ taskId }) => { + return new ObservableImpl(observer => { + subscriber = observer; observer.next({ events: [ + { + id: 0, + taskId, + type: 'log', + createdAt: '', + body: { message: 'My log message' }, + }, { id: 1, taskId, @@ -581,120 +1275,57 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{ }); }); }); + + const response = await request(app).get('/v2/tasks/a-random-id/events'); + + expect(response.status).toEqual(200); + expect(response.body).toEqual([ + { + id: 0, + taskId: 'a-random-id', + type: 'log', + createdAt: '', + body: { message: 'My log message' }, + }, + { + id: 1, + taskId: 'a-random-id', + type: 'completion', + createdAt: '', + body: { message: 'Finished!' }, + }, + ]); + + expect(taskBroker.event$).toBeCalledTimes(1); + expect(taskBroker.event$).toBeCalledWith({ taskId: 'a-random-id' }); + expect(subscriber!.closed).toBe(true); }); - let statusCode: any = undefined; - let headers: any = {}; - - const req = request(app) - .get('/v2/tasks/a-random-id/eventstream') - .query({ after: 10 }) - .set('accept', 'text/event-stream') - .parse((res, _) => { - ({ statusCode, headers } = res as any); - - res.on('data', () => { - // close immediately - req.abort(); + it('should return log messages with after query', async () => { + let subscriber: ZenObservable.SubscriptionObserver; + ( + taskBroker.event$ as jest.Mocked['event$'] + ).mockImplementation(() => { + return new ObservableImpl(observer => { + subscriber = observer; + observer.next({ events: [] }); }); }); - // wait for the request to finish - await req.catch(() => { - // ignore 'aborted' error - }); + const response = await request(app) + .get('/v2/tasks/a-random-id/events') + .query({ after: 10 }); - expect(statusCode).toBe(200); - expect(headers['content-type']).toBe('text/event-stream'); + expect(response.status).toEqual(200); + expect(response.body).toEqual([]); - expect(taskBroker.event$).toBeCalledTimes(1); - expect(taskBroker.event$).toBeCalledWith({ - taskId: 'a-random-id', - after: 10, - }); - - expect(subscriber!.closed).toBe(true); - }); - }); - - describe('GET /v2/tasks/:taskId/events', () => { - it('should return log messages', async () => { - let subscriber: ZenObservable.SubscriptionObserver; - ( - taskBroker.event$ as jest.Mocked['event$'] - ).mockImplementation(({ taskId }) => { - return new ObservableImpl(observer => { - subscriber = observer; - observer.next({ - events: [ - { - id: 0, - taskId, - type: 'log', - createdAt: '', - body: { message: 'My log message' }, - }, - { - id: 1, - taskId, - type: 'completion', - createdAt: '', - body: { message: 'Finished!' }, - }, - ], - }); - }); - }); - - const response = await request(app).get('/v2/tasks/a-random-id/events'); - - expect(response.status).toEqual(200); - expect(response.body).toEqual([ - { - id: 0, + expect(taskBroker.event$).toBeCalledTimes(1); + expect(taskBroker.event$).toBeCalledWith({ taskId: 'a-random-id', - type: 'log', - createdAt: '', - body: { message: 'My log message' }, - }, - { - id: 1, - taskId: 'a-random-id', - type: 'completion', - createdAt: '', - body: { message: 'Finished!' }, - }, - ]); - - expect(taskBroker.event$).toBeCalledTimes(1); - expect(taskBroker.event$).toBeCalledWith({ taskId: 'a-random-id' }); - expect(subscriber!.closed).toBe(true); - }); - - it('should return log messages with after query', async () => { - let subscriber: ZenObservable.SubscriptionObserver; - ( - taskBroker.event$ as jest.Mocked['event$'] - ).mockImplementation(() => { - return new ObservableImpl(observer => { - subscriber = observer; - observer.next({ events: [] }); + after: 10, }); + expect(subscriber!.closed).toBe(true); }); - - const response = await request(app) - .get('/v2/tasks/a-random-id/events') - .query({ after: 10 }); - - expect(response.status).toEqual(200); - expect(response.body).toEqual([]); - - expect(taskBroker.event$).toBeCalledTimes(1); - expect(taskBroker.event$).toBeCalledWith({ - taskId: 'a-random-id', - after: 10, - }); - expect(subscriber!.closed).toBe(true); }); }); }); diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index a9ddfb6888..c26b6570aa 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -22,8 +22,8 @@ import { stringifyEntityRef, UserEntity, } from '@backstage/catalog-model'; -import { Config, JsonObject } from '@backstage/config'; -import { InputError, NotFoundError } from '@backstage/errors'; +import { Config, JsonObject, JsonValue } from '@backstage/config'; +import { InputError, NotFoundError, stringifyError } from '@backstage/errors'; import { ScmIntegrations } from '@backstage/integration'; import { TaskSpec, @@ -47,7 +47,10 @@ import { import { createDryRunner } from '../scaffolder/dryrun'; import { StorageTaskBroker } from '../scaffolder/tasks/StorageTaskBroker'; import { findTemplate, getEntityBaseUrl, getWorkingDirectory } from './helpers'; -import { IdentityApi } from '@backstage/plugin-auth-node'; +import { + IdentityApi, + IdentityApiGetIdentityRequest, +} from '@backstage/plugin-auth-node'; /** * RouterOptions @@ -65,13 +68,87 @@ export interface RouterOptions { taskWorkers?: number; taskBroker?: TaskBroker; additionalTemplateFilters?: Record; - identity: IdentityApi; + identity?: IdentityApi; } function isSupportedTemplate(entity: TemplateEntityV1beta3) { return entity.apiVersion === 'scaffolder.backstage.io/v1beta3'; } +function buildDefaultIdentityClient({ + logger, +}: { + logger: Logger; +}): IdentityApi { + return { + getIdentity: async ({ request }: IdentityApiGetIdentityRequest) => { + const header = request.headers.authorization; + + 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'); + } + + // 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 getIdentity = async ({ + request, + identity, + logger, +}: { + request: express.Request; + identity: IdentityApi; + logger: Logger; +}) => { + let callerIdentity = undefined; + try { + callerIdentity = await identity.getIdentity({ request }); + } catch (e: any) { + logger.debug(`identity could not be determined: ${e.message}`); + } + return callerIdentity; +}; + /** * A method to create a router for the scaffolder backend plugin. * @public @@ -91,10 +168,13 @@ export async function createRouter( actions, taskWorkers, additionalTemplateFilters, - identity, } = options; const logger = parentLogger.child({ plugin: 'scaffolder' }); + + const identity: IdentityApi = + options.identity || buildDefaultIdentityClient({ logger }); + const workingDirectory = await getWorkingDirectory(config, logger); const integrations = ScmIntegrations.fromConfig(config); let taskBroker: TaskBroker; @@ -148,7 +228,11 @@ export async function createRouter( async (req, res) => { const { namespace, kind, name } = req.params; - const userIdentity = await identity.getIdentity(req); + const userIdentity = await getIdentity({ + request: req, + logger, + identity, + }); const token = userIdentity?.token; const template = await findTemplate({ @@ -192,7 +276,11 @@ export async function createRouter( defaultKind: 'template', }); - const callerIdentity = await identity.getIdentity(req); + const callerIdentity = await getIdentity({ + request: req, + logger, + identity, + }); const token = callerIdentity?.token; const userEntityRef = callerIdentity?.identity.userEntityRef; @@ -397,7 +485,8 @@ export async function createRouter( throw new InputError('Input template is not a template'); } - const token = (await identity.getIdentity(req))?.token; + const token = (await getIdentity({ request: req, logger, identity })) + ?.token; for (const parameters of [template.spec.parameters ?? []].flat()) { const result = validate(body.values, parameters);