diff --git a/plugins/scaffolder-backend/api-report.md b/plugins/scaffolder-backend/api-report.md index 2cb969fa41..c9f92d6172 100644 --- a/plugins/scaffolder-backend/api-report.md +++ b/plugins/scaffolder-backend/api-report.md @@ -25,8 +25,11 @@ import { Logger } from 'winston'; import { Observable } from '@backstage/types'; import { Octokit } from 'octokit'; import { PermissionEvaluator } from '@backstage/plugin-permission-common'; +import { PermissionRule } from '@backstage/plugin-permission-node'; +import { PermissionRuleParams } from '@backstage/plugin-permission-common'; import { PluginDatabaseManager } from '@backstage/backend-common'; import { PluginTaskScheduler } from '@backstage/backend-tasks'; +import { RESOURCE_TYPE_SCAFFOLDER_TEMPLATE } from '@backstage/plugin-scaffolder-common/alpha'; import { Schema } from 'jsonschema'; import { ScmIntegrationRegistry } from '@backstage/integration'; import { ScmIntegrations } from '@backstage/integration'; @@ -36,6 +39,8 @@ import { TaskSpec } from '@backstage/plugin-scaffolder-common'; import { TaskSpecV1beta3 } from '@backstage/plugin-scaffolder-common'; import { TemplateAction as TemplateAction_2 } from '@backstage/plugin-scaffolder-node'; import { TemplateActionOptions } from '@backstage/plugin-scaffolder-node'; +import { TemplateEntityStepV1beta3 } from '@backstage/plugin-scaffolder-common'; +import { TemplateParameterV1beta3 } from '@backstage/plugin-scaffolder-common'; import { UrlReader } from '@backstage/backend-common'; import { Writable } from 'stream'; import { ZodType } from 'zod'; @@ -651,6 +656,8 @@ export interface RouterOptions { // (undocumented) config: Config; // (undocumented) + customPermissionRules?: ScaffolderPermissionRuleInput[]; + // (undocumented) database: PluginDatabaseManager; // (undocumented) identity?: IdentityApi; @@ -690,6 +697,16 @@ export class ScaffolderEntitiesProcessor implements CatalogProcessor { validateEntityKind(entity: Entity): Promise; } +// @public +export type ScaffolderPermissionRuleInput< + TParams extends PermissionRuleParams = PermissionRuleParams, +> = PermissionRule< + TemplateEntityStepV1beta3 | TemplateParameterV1beta3, + {}, + typeof RESOURCE_TYPE_SCAFFOLDER_TEMPLATE, + TParams +>; + // @public export type SerializedTask = { id: string; diff --git a/plugins/scaffolder-backend/src/service/router.test.ts b/plugins/scaffolder-backend/src/service/router.test.ts index 2bf9203056..4f5ed0fe22 100644 --- a/plugins/scaffolder-backend/src/service/router.test.ts +++ b/plugins/scaffolder-backend/src/service/router.test.ts @@ -109,17 +109,52 @@ describe('createRouter', () => { spec: { owner: 'web@example.com', type: 'website', - steps: [], - parameters: { - type: 'object', - required: ['required'], - properties: { - required: { - type: 'string', - description: 'Required parameter', + steps: [ + { + id: 'step-one', + name: 'First log', + action: 'debug:log', + input: { + message: 'hello', }, }, - }, + { + id: 'step-two', + name: 'Second log', + action: 'debug:log', + input: { + message: 'world', + }, + 'backstage:permissions': { + tags: ['steps-tag'], + }, + }, + ], + parameters: [ + { + type: 'object', + required: ['requiredParameter1'], + properties: { + requiredParameter1: { + type: 'string', + description: 'Required parameter 1', + }, + }, + }, + { + type: 'object', + required: ['requiredParameter2'], + 'backstage:permissions': { + tags: ['parameters-tag'], + }, + properties: { + requiredParameter2: { + type: 'string', + description: 'Required parameter 2', + }, + }, + }, + ], }, }); @@ -237,12 +272,13 @@ describe('createRouter', () => { name: 'create-react-app-template', }), values: { - required: 'required-value', + requiredParameter1: 'required-value-1', + requiredParameter2: 'required-value-2', }, }); - expect(response.body.id).toBe('a-random-id'); expect(response.status).toEqual(201); + expect(response.body.id).toBe('a-random-id'); }); it('should call the broker with a correct spec', async () => { @@ -261,7 +297,8 @@ describe('createRouter', () => { name: 'create-react-app-template', }), values: { - required: 'required-value', + requiredParameter1: 'required-value-1', + requiredParameter2: 'required-value-2', }, }); expect(broker).toHaveBeenCalledWith( @@ -280,7 +317,8 @@ describe('createRouter', () => { })), output: mockTemplate.spec.output ?? {}, parameters: { - required: 'required-value', + requiredParameter1: 'required-value-1', + requiredParameter2: 'required-value-2', }, user: { entity: mockUser, @@ -317,7 +355,8 @@ describe('createRouter', () => { name: 'create-react-app-template', }), values: { - required: 'required-value', + requiredParameter1: 'required-value-1', + requiredParameter2: 'required-value-2', }, }); expect(broker).toHaveBeenCalledWith( @@ -336,7 +375,8 @@ describe('createRouter', () => { })), output: mockTemplate.spec.output ?? {}, parameters: { - required: 'required-value', + requiredParameter1: 'required-value-1', + requiredParameter2: 'required-value-2', }, user: { entity: undefined, @@ -370,7 +410,8 @@ describe('createRouter', () => { name: 'create-react-app-template', }), values: { - required: 'required-value', + requiredParameter1: 'required-value-1', + requiredParameter2: 'required-value-2', }, }); @@ -393,7 +434,8 @@ describe('createRouter', () => { name: 'create-react-app-template', }), values: { - required: 'required-value', + requiredParameter1: 'required-value-1', + requiredParameter2: 'required-value-2', }, }); @@ -416,7 +458,8 @@ describe('createRouter', () => { name: 'create-react-app-template', }), values: { - required: 'required-value', + requiredParameter1: 'required-value-1', + requiredParameter2: 'required-value-2', }, }); @@ -836,16 +879,32 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{ { title: 'Please enter the following information', schema: { - required: ['required'], + required: ['requiredParameter1'], type: 'object', properties: { - required: { - description: 'Required parameter', + requiredParameter1: { + description: 'Required parameter 1', type: 'string', }, }, }, }, + { + title: 'Please enter the following information', + schema: { + type: 'object', + required: ['requiredParameter2'], + 'backstage:permissions': { + tags: ['parameters-tag'], + }, + properties: { + requiredParameter2: { + type: 'string', + description: 'Required parameter 2', + }, + }, + }, + }, ], }); }); @@ -866,7 +925,6 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{ '/v2/templates/default/Template/create-react-app-template/parameter-schema', ) .send(); - expect(response.status).toEqual(200); expect(response.body).toEqual({ title: 'Create React App Template', @@ -874,6 +932,54 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{ steps: [], }); }); + + it('filters parameters that the user is not authorized to see in case of conditional decision', async () => { + jest + .spyOn(permissionApi, 'authorizeConditional') + .mockImplementationOnce(async () => [ + { + conditions: { + resourceType: 'scaffolder-template', + rule: 'HAS_TAG', + params: { tag: 'parameters-tag' }, + }, + pluginId: 'scaffolder', + resourceType: 'scaffolder-template', + result: AuthorizeResult.CONDITIONAL, + }, + { + result: AuthorizeResult.ALLOW, + }, + ]); + const response = await request(app) + .get( + '/v2/templates/default/Template/create-react-app-template/parameter-schema', + ) + .send(); + expect(response.status).toEqual(200); + expect(response.body).toEqual({ + title: 'Create React App Template', + description: 'Create a new CRA website project', + steps: [ + { + title: 'Please enter the following information', + schema: { + type: 'object', + required: ['requiredParameter2'], + 'backstage:permissions': { + tags: ['parameters-tag'], + }, + properties: { + requiredParameter2: { + type: 'string', + description: 'Required parameter 2', + }, + }, + }, + }, + ], + }); + }); }); describe('POST /v2/tasks', () => { @@ -917,7 +1023,8 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{ name: 'create-react-app-template', }), values: { - required: 'required-value', + requiredParameter1: 'required-value-1', + requiredParameter2: 'required-value-2', }, }); expect(broker).toHaveBeenCalledWith( @@ -932,7 +1039,89 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{ steps: [], output: mockTemplate.spec.output ?? {}, parameters: { - required: 'required-value', + requiredParameter1: 'required-value-1', + requiredParameter2: 'required-value-2', + }, + 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('filters steps that the user is not authorized to see in case of conditional decision', async () => { + jest + .spyOn(permissionApi, 'authorizeConditional') + .mockImplementation(async () => [ + { + result: AuthorizeResult.ALLOW, + }, + { + conditions: { + resourceType: 'scaffolder-template', + rule: 'HAS_TAG', + params: { tag: 'steps-tag' }, + }, + pluginId: 'scaffolder', + resourceType: 'scaffolder-template', + result: AuthorizeResult.CONDITIONAL, + }, + ]); + + const broker = + taskBroker.dispatch as jest.Mocked['dispatch']; + const mockTemplate = getMockTemplate(); + 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: 'user:default/guest', + secrets: { + backstageToken: 'token', + }, + + spec: { + apiVersion: mockTemplate.apiVersion, + steps: [ + { + id: 'step-two', + name: 'Second log', + action: 'debug:log', + input: { + message: 'world', + }, + 'backstage:permissions': { + tags: ['steps-tag'], + }, + }, + ], + output: mockTemplate.spec.output ?? {}, + parameters: { + requiredParameter1: 'required-value-1', + requiredParameter2: 'required-value-2', }, user: { entity: mockUser, @@ -969,7 +1158,8 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{ name: 'create-react-app-template', }), values: { - required: 'required-value', + requiredParameter1: 'required-value-1', + requiredParameter2: 'required-value-2', }, }); @@ -990,7 +1180,8 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{ name: 'create-react-app-template', }), values: { - required: 'required-value', + requiredParameter1: 'required-value-1', + requiredParameter2: 'required-value-2', }, }); expect(broker).toHaveBeenCalledWith( @@ -1009,7 +1200,8 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{ })), output: mockTemplate.spec.output ?? {}, parameters: { - required: 'required-value', + requiredParameter1: 'required-value-1', + requiredParameter2: 'required-value-2', }, user: { entity: mockUser, @@ -1052,7 +1244,8 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{ name: 'create-react-app-template', }), values: { - required: 'required-value', + requiredParameter1: 'required-value-1', + requiredParameter2: 'required-value-2', }, }); expect(response.status).not.toEqual(201); @@ -1084,7 +1277,8 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{ name: 'create-react-app-template', }), values: { - required: 'required-value', + requiredParameter1: 'required-value-1', + requiredParameter2: 'required-value-2', }, }); @@ -1107,7 +1301,8 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{ name: 'create-react-app-template', }), values: { - required: 'required-value', + requiredParameter1: 'required-value-1', + requiredParameter2: 'required-value-2', }, }); @@ -1127,7 +1322,8 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{ name: 'create-react-app-template', }), values: { - required: 'required-value', + requiredParameter1: 'required-value-1', + requiredParameter2: 'required-value-2', }, });