diff --git a/.changeset/clean-dolls-search.md b/.changeset/clean-dolls-search.md new file mode 100644 index 0000000000..a8f0da3c4d --- /dev/null +++ b/.changeset/clean-dolls-search.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-common': patch +--- + +Added permissions for authorizing parameters and steps diff --git a/.changeset/funny-rivers-grin.md b/.changeset/funny-rivers-grin.md new file mode 100644 index 0000000000..55463e40a7 --- /dev/null +++ b/.changeset/funny-rivers-grin.md @@ -0,0 +1,5 @@ +--- +'@backstage/create-app': patch +--- + +Add `permissionApi` as dependency of the scaffolder-backend plugin diff --git a/.changeset/odd-grapes-double.md b/.changeset/odd-grapes-double.md new file mode 100644 index 0000000000..7ef7486b60 --- /dev/null +++ b/.changeset/odd-grapes-double.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-permission-node': patch +--- + +Added createConditionAuthorizer utility function, which takes some permission conditions and returns a function that returns a definitive authorization result given a decision and a resource. diff --git a/.changeset/olive-months-talk.md b/.changeset/olive-months-talk.md new file mode 100644 index 0000000000..b3a89d1f9e --- /dev/null +++ b/.changeset/olive-months-talk.md @@ -0,0 +1,8 @@ +--- +'@backstage/plugin-scaffolder-backend': minor +--- + +Added the possibility to authorize parameters and steps of a template + +The scaffolder plugin is now integrated with the permission framework. +It is possible to toggle parameters or actions within templates by marking each section with specific `tags`, inside a `backstage:permissions` property under each parameter or action. Each parameter or action can then be permissioned by using a conditional decision containing the `scaffolderTemplateRules.hasTag` rule. diff --git a/.changeset/tiny-apes-scream.md b/.changeset/tiny-apes-scream.md new file mode 100644 index 0000000000..b4a28e0745 --- /dev/null +++ b/.changeset/tiny-apes-scream.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-common': patch +--- + +Define optional `backstage:permissions` property to parameters and steps used to authorize part of the template using the permission framework diff --git a/packages/backend/src/plugins/scaffolder.ts b/packages/backend/src/plugins/scaffolder.ts index d079b64c28..813092c9b9 100644 --- a/packages/backend/src/plugins/scaffolder.ts +++ b/packages/backend/src/plugins/scaffolder.ts @@ -34,5 +34,6 @@ export default async function createPlugin( reader: env.reader, identity: env.identity, scheduler: env.scheduler, + permissionApi: env.permissions, }); } diff --git a/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts b/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts index ef46f07870..fd424a3257 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts @@ -17,5 +17,6 @@ export default async function createPlugin( reader: env.reader, catalogClient, identity: env.identity, + permissionApi: env.permissions, }); } diff --git a/plugins/permission-node/api-report.md b/plugins/permission-node/api-report.md index f89ff0a8d5..b3d06efdc7 100644 --- a/plugins/permission-node/api-report.md +++ b/plugins/permission-node/api-report.md @@ -73,6 +73,11 @@ export type ConditionTransformer = ( conditions: PermissionCriteria, ) => PermissionCriteria; +// @public +export const createConditionAuthorizer: ( + rules: PermissionRule[], +) => (decision: PolicyDecision, resource: TResource | undefined) => boolean; + // @public export const createConditionExports: < TResourceType extends string, diff --git a/plugins/permission-node/src/integration/createPermissionIntegrationRouter.test.ts b/plugins/permission-node/src/integration/createPermissionIntegrationRouter.test.ts index 1a7462665e..6e28c5ddc4 100644 --- a/plugins/permission-node/src/integration/createPermissionIntegrationRouter.test.ts +++ b/plugins/permission-node/src/integration/createPermissionIntegrationRouter.test.ts @@ -25,6 +25,7 @@ import { z } from 'zod'; import { createPermissionIntegrationRouter, CreatePermissionIntegrationRouterResourceOptions, + createConditionAuthorizer, } from './createPermissionIntegrationRouter'; import { createPermissionRule } from './createPermissionRule'; @@ -33,6 +34,9 @@ const testPermission: Permission = createPermission({ attributes: {}, }); +const mockTestRule1Apply = jest + .fn() + .mockImplementation((_resource: any, _params) => true); const testRule1 = createPermissionRule({ name: 'test-rule-1', description: 'Test rule 1', @@ -41,15 +45,18 @@ const testRule1 = createPermissionRule({ foo: z.string(), bar: z.number().describe('bar'), }), - apply: (_resource: any, _params) => true, + apply: mockTestRule1Apply, toQuery: _params => ({}), }); +const mockTestRule2Apply = jest + .fn() + .mockImplementation((_resource: any) => false); const testRule2 = createPermissionRule({ name: 'test-rule-2', description: 'Test rule 2', resourceType: 'test-resource', - apply: (_resource: any) => false, + apply: mockTestRule2Apply, toQuery: () => ({}), }); @@ -625,3 +632,72 @@ describe('createPermissionIntegrationRouter', () => { }); }); }); + +describe('createConditionAuthorizer', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should return true in case of allowed decision', () => { + const isAuthorized = createConditionAuthorizer([testRule1, testRule2]); + expect( + isAuthorized( + { + result: AuthorizeResult.ALLOW, + }, + {}, + ), + ).toBe(true); + + expect(mockTestRule1Apply).not.toHaveBeenCalled(); + expect(mockTestRule2Apply).not.toHaveBeenCalled(); + }); + it('should return false in case of denied decision', () => { + const isAuthorized = createConditionAuthorizer([testRule1, testRule2]); + expect( + isAuthorized( + { + result: AuthorizeResult.DENY, + }, + {}, + ), + ).toBe(false); + + expect(mockTestRule1Apply).not.toHaveBeenCalled(); + expect(mockTestRule2Apply).not.toHaveBeenCalled(); + }); + + it('should apply conditions to a resource in case of conditional decision', () => { + const isAuthorized = createConditionAuthorizer([testRule1, testRule2]); + expect( + isAuthorized( + { + pluginId: 'plugin', + resourceType: 'test-resource', + result: AuthorizeResult.CONDITIONAL, + conditions: { + allOf: [ + { + rule: 'test-rule-1', + resourceType: 'test-resource', + params: { + foo: 'a', + bar: 1, + }, + }, + { + rule: 'test-rule-2', + resourceType: 'test-resource', + params: {}, + }, + ], + }, + }, + {}, + ), + ).toBe(false); + + expect(mockTestRule1Apply).toHaveBeenCalledTimes(1); + expect(mockTestRule2Apply).toHaveBeenCalledTimes(1); + }); +}); diff --git a/plugins/permission-node/src/integration/createPermissionIntegrationRouter.ts b/plugins/permission-node/src/integration/createPermissionIntegrationRouter.ts index 087de23198..850123d55e 100644 --- a/plugins/permission-node/src/integration/createPermissionIntegrationRouter.ts +++ b/plugins/permission-node/src/integration/createPermissionIntegrationRouter.ts @@ -27,6 +27,7 @@ import { Permission, PermissionCondition, PermissionCriteria, + PolicyDecision, } from '@backstage/plugin-permission-common'; import { PermissionRule } from '../types'; import { @@ -160,6 +161,30 @@ const applyConditions = ( return rule.apply(resource, criteria.params ?? {}); }; +/** + + * Takes some permission conditions and returns a definitive authorization result + * on the resource to which they apply. + * + * @public + */ +export const createConditionAuthorizer = ( + rules: PermissionRule[], +) => { + const getRule = createGetRule(rules); + + return ( + decision: PolicyDecision, + resource: TResource | undefined, + ): boolean => { + if (decision.result === AuthorizeResult.CONDITIONAL) { + return applyConditions(decision.conditions, resource, getRule); + } + + return decision.result === AuthorizeResult.ALLOW; + }; +}; + /** * Options for creating a permission integration router specific * for a particular resource type. diff --git a/plugins/scaffolder-backend/alpha-api-report.md b/plugins/scaffolder-backend/alpha-api-report.md index 3199444c6b..2ecd2cc800 100644 --- a/plugins/scaffolder-backend/alpha-api-report.md +++ b/plugins/scaffolder-backend/alpha-api-report.md @@ -4,14 +4,43 @@ ```ts import { BackendFeature } from '@backstage/backend-plugin-api'; +import { ConditionalPolicyDecision } from '@backstage/plugin-permission-common'; +import { Conditions } from '@backstage/plugin-permission-node'; +import { PermissionCondition } from '@backstage/plugin-permission-common'; +import { PermissionCriteria } from '@backstage/plugin-permission-common'; +import { PermissionRule } from '@backstage/plugin-permission-node'; +import { PermissionRuleParams } from '@backstage/plugin-permission-common'; +import { ResourcePermission } from '@backstage/plugin-permission-common'; import { TaskBroker } from '@backstage/plugin-scaffolder-backend'; import { TemplateAction } from '@backstage/plugin-scaffolder-node'; +import { TemplateEntityStepV1beta3 } from '@backstage/plugin-scaffolder-common'; import { TemplateFilter } from '@backstage/plugin-scaffolder-backend'; import { TemplateGlobal } from '@backstage/plugin-scaffolder-backend'; +import { TemplateParametersV1beta3 } from '@backstage/plugin-scaffolder-common'; // @alpha export const catalogModuleTemplateKind: () => BackendFeature; +// @alpha +export const createScaffolderConditionalDecision: ( + permission: ResourcePermission<'scaffolder-template'>, + conditions: PermissionCriteria< + PermissionCondition<'scaffolder-template', PermissionRuleParams> + >, +) => ConditionalPolicyDecision; + +// @alpha +export const scaffolderConditions: Conditions<{ + hasTag: PermissionRule< + TemplateParametersV1beta3 | TemplateEntityStepV1beta3, + {}, + 'scaffolder-template', + { + tag: string; + } + >; +}>; + // @alpha export const scaffolderPlugin: ( options: ScaffolderPluginOptions, diff --git a/plugins/scaffolder-backend/api-report.md b/plugins/scaffolder-backend/api-report.md index a569ff8232..be24be46cc 100644 --- a/plugins/scaffolder-backend/api-report.md +++ b/plugins/scaffolder-backend/api-report.md @@ -24,8 +24,12 @@ import { LocationSpec } from '@backstage/plugin-catalog-common'; 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'; @@ -35,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 { TemplateParametersV1beta3 } from '@backstage/plugin-scaffolder-common'; import { UrlReader } from '@backstage/backend-common'; import { Writable } from 'stream'; import { ZodType } from 'zod'; @@ -665,6 +671,10 @@ export interface RouterOptions { // (undocumented) logger: Logger; // (undocumented) + permissionApi?: PermissionEvaluator; + // (undocumented) + permissionRules?: TemplatePermissionRuleInput[]; + // (undocumented) reader: UrlReader; // (undocumented) scheduler?: PluginTaskScheduler; @@ -920,4 +930,14 @@ export type TemplateFilter = (...args: JsonValue[]) => JsonValue | undefined; export type TemplateGlobal = | ((...args: JsonValue[]) => JsonValue | undefined) | JsonValue; + +// @public (undocumented) +export type TemplatePermissionRuleInput< + TParams extends PermissionRuleParams = PermissionRuleParams, +> = PermissionRule< + TemplateEntityStepV1beta3 | TemplateParametersV1beta3, + {}, + typeof RESOURCE_TYPE_SCAFFOLDER_TEMPLATE, + TParams +>; ``` diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index c061d0258f..93506eb01c 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -58,6 +58,8 @@ "@backstage/plugin-catalog-backend": "workspace:^", "@backstage/plugin-catalog-common": "workspace:^", "@backstage/plugin-catalog-node": "workspace:^", + "@backstage/plugin-permission-common": "workspace:^", + "@backstage/plugin-permission-node": "workspace:^", "@backstage/plugin-scaffolder-common": "workspace:^", "@backstage/plugin-scaffolder-node": "workspace:^", "@backstage/types": "workspace:^", diff --git a/plugins/scaffolder-backend/src/ScaffolderPlugin.ts b/plugins/scaffolder-backend/src/ScaffolderPlugin.ts index 182bb60223..97abf41828 100644 --- a/plugins/scaffolder-backend/src/ScaffolderPlugin.ts +++ b/plugins/scaffolder-backend/src/ScaffolderPlugin.ts @@ -94,6 +94,7 @@ export const scaffolderPlugin = createBackendPlugin( database, httpRouter, catalogClient, + permissions, }) { const { additionalTemplateFilters, @@ -131,6 +132,7 @@ export const scaffolderPlugin = createBackendPlugin( taskWorkers, additionalTemplateFilters, additionalTemplateGlobals, + permissionApi: permissions, }); httpRouter.use(router); }, diff --git a/plugins/scaffolder-backend/src/alpha.ts b/plugins/scaffolder-backend/src/alpha.ts index ac96f081ad..713f2569aa 100644 --- a/plugins/scaffolder-backend/src/alpha.ts +++ b/plugins/scaffolder-backend/src/alpha.ts @@ -15,5 +15,6 @@ */ export * from './modules'; +export * from './service'; export { scaffolderPlugin } from './ScaffolderPlugin'; export type { ScaffolderPluginOptions } from './ScaffolderPlugin'; diff --git a/plugins/scaffolder-backend/src/service/conditionExports.ts b/plugins/scaffolder-backend/src/service/conditionExports.ts new file mode 100644 index 0000000000..0c502c6075 --- /dev/null +++ b/plugins/scaffolder-backend/src/service/conditionExports.ts @@ -0,0 +1,67 @@ +/* + * Copyright 2022 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. + */ + +import { RESOURCE_TYPE_SCAFFOLDER_TEMPLATE } from '@backstage/plugin-scaffolder-common/alpha'; +import { createConditionExports } from '@backstage/plugin-permission-node'; +import { scaffolderTemplateRules } from './rules'; + +const { conditions, createConditionalDecision } = createConditionExports({ + pluginId: 'scaffolder', + resourceType: RESOURCE_TYPE_SCAFFOLDER_TEMPLATE, + rules: scaffolderTemplateRules, +}); + +/** + * These conditions are used when creating conditional decisions for scaffolder + * templates that are returned by authorization policies. + * + * @alpha + */ +export const scaffolderConditions = conditions; + +/** + * `createScaffolderConditionalDecision` can be used when authoring policies to + * create conditional decisions. It requires a permission of type + * `ResourcePermission<'scaffolder-template'>` to be passed as the first parameter. + * It's recommended that you use the provided `isResourcePermission` and + * `isPermission` helper methods to narrow the type of the permission passed to + * the handle method as shown below. + * + * ``` + * // MyAuthorizationPolicy.ts + * ... + * import { createScaffolderPolicyDecision } from '@backstage/plugin-scaffolder-backend'; + * import { RESOURCE_TYPE_SCAFFOLDER_TEMPLATE } from '@backstage/plugin-scaffolder-common'; + * + * class MyAuthorizationPolicy implements PermissionPolicy { + * async handle(request, user) { + * ... + * + * if (isResourcePermission(request.permission, RESOURCE_TYPE_SCAFFOLDER_TEMPLATE)) { + * return createScaffolderConditionalDecision( + * request.permission, + * { anyOf: [...insert conditions here...] } + * ); + * } + * + * ... + * } + * + * ``` + * + * @alpha + */ +export const createScaffolderConditionalDecision = createConditionalDecision; diff --git a/plugins/scaffolder-backend/src/service/helpers.ts b/plugins/scaffolder-backend/src/service/helpers.ts index 3412fec2d3..97d4ab93aa 100644 --- a/plugins/scaffolder-backend/src/service/helpers.ts +++ b/plugins/scaffolder-backend/src/service/helpers.ts @@ -107,3 +107,7 @@ export async function findTemplate(options: { return template as TemplateEntityV1beta3; } + +export type TemplateTransform = ( + template: TemplateEntityV1beta3, +) => TemplateEntityV1beta3; diff --git a/plugins/scaffolder-backend/src/service/index.ts b/plugins/scaffolder-backend/src/service/index.ts new file mode 100644 index 0000000000..dc5508d47f --- /dev/null +++ b/plugins/scaffolder-backend/src/service/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2023 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. + */ + +export * from './conditionExports'; diff --git a/plugins/scaffolder-backend/src/service/router.test.ts b/plugins/scaffolder-backend/src/service/router.test.ts index 5e3c1c7278..4f5ed0fe22 100644 --- a/plugins/scaffolder-backend/src/service/router.test.ts +++ b/plugins/scaffolder-backend/src/service/router.test.ts @@ -44,6 +44,10 @@ import { IdentityApiGetIdentityRequest, BackstageIdentityResponse, } from '@backstage/plugin-auth-node'; +import { + AuthorizeResult, + PermissionEvaluator, +} from '@backstage/plugin-permission-common'; const mockAccess = jest.fn(); @@ -85,8 +89,12 @@ describe('createRouter', () => { let loggerSpy: jest.SpyInstance; let taskBroker: TaskBroker; const catalogClient = { getEntityByRef: jest.fn() } as unknown as CatalogApi; + const permissionApi = { + authorize: jest.fn(), + authorizeConditional: jest.fn(), + } as unknown as PermissionEvaluator; - const mockTemplate: TemplateEntityV1beta3 = { + const getMockTemplate = (): TemplateEntityV1beta3 => ({ apiVersion: 'scaffolder.backstage.io/v1beta3', kind: 'Template', metadata: { @@ -101,19 +109,54 @@ 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', + }, + }, + }, + ], }, - }; + }); const mockUser: UserEntity = { apiVersion: 'backstage.io/v1alpha1', @@ -152,6 +195,7 @@ describe('createRouter', () => { catalogClient, reader: mockUrlReader, taskBroker, + permissionApi, }); app = express().use(router); @@ -160,15 +204,27 @@ describe('createRouter', () => { .mockImplementation(async ref => { const { kind } = parseEntityRef(ref); - if (kind === 'template') { - return mockTemplate; + if (kind.toLocaleLowerCase() === 'template') { + return getMockTemplate(); } - if (kind === 'user') { + if (kind.toLocaleLowerCase() === 'user') { return mockUser; } + throw new Error(`no mock found for kind: ${kind}`); }); + + jest + .spyOn(permissionApi, 'authorizeConditional') + .mockImplementation(async () => [ + { + result: AuthorizeResult.ALLOW, + }, + { + result: AuthorizeResult.ALLOW, + }, + ]); }); afterEach(() => { @@ -216,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 () => { @@ -229,6 +286,7 @@ describe('createRouter', () => { taskBroker.dispatch as jest.Mocked['dispatch']; const mockToken = 'blob.eyJzdWIiOiJ1c2VyOmRlZmF1bHQvZ3Vlc3QiLCJuYW1lIjoiSm9obiBEb2UifQ.blob'; + const mockTemplate = getMockTemplate(); await request(app) .post('/v2/tasks') @@ -239,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( @@ -258,7 +317,8 @@ describe('createRouter', () => { })), output: mockTemplate.spec.output ?? {}, parameters: { - required: 'required-value', + requiredParameter1: 'required-value-1', + requiredParameter2: 'required-value-2', }, user: { entity: mockUser, @@ -284,6 +344,7 @@ describe('createRouter', () => { const broker = taskBroker.dispatch as jest.Mocked['dispatch']; const mockToken = 'blob.eyJzdWIiOiIiLCJuYW1lIjoiSm9obiBEb2UifQ.blob'; + const mockTemplate = getMockTemplate(); await request(app) .post('/v2/tasks') @@ -294,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( @@ -313,7 +375,8 @@ describe('createRouter', () => { })), output: mockTemplate.spec.output ?? {}, parameters: { - required: 'required-value', + requiredParameter1: 'required-value-1', + requiredParameter2: 'required-value-2', }, user: { entity: undefined, @@ -347,7 +410,8 @@ describe('createRouter', () => { name: 'create-react-app-template', }), values: { - required: 'required-value', + requiredParameter1: 'required-value-1', + requiredParameter2: 'required-value-2', }, }); @@ -370,7 +434,8 @@ describe('createRouter', () => { name: 'create-react-app-template', }), values: { - required: 'required-value', + requiredParameter1: 'required-value-1', + requiredParameter2: 'required-value-2', }, }); @@ -393,7 +458,8 @@ describe('createRouter', () => { name: 'create-react-app-template', }), values: { - required: 'required-value', + requiredParameter1: 'required-value-1', + requiredParameter2: 'required-value-2', }, }); @@ -713,6 +779,7 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{ }); }); }); + describe('providing an identity api', () => { beforeEach(async () => { const logger = getVoidLogger(); @@ -752,6 +819,7 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{ reader: mockUrlReader, taskBroker, identity: { getIdentity }, + permissionApi, }); app = express().use(router); @@ -760,15 +828,26 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{ .mockImplementation(async ref => { const { kind } = parseEntityRef(ref); - if (kind === 'template') { - return mockTemplate; + if (kind.toLocaleLowerCase() === 'template') { + return getMockTemplate(); } - if (kind === 'user') { + if (kind.toLocaleLowerCase() === 'user') { return mockUser; } throw new Error(`no mock found for kind: ${kind}`); }); + + jest + .spyOn(permissionApi, 'authorizeConditional') + .mockImplementation(async () => [ + { + result: AuthorizeResult.ALLOW, + }, + { + result: AuthorizeResult.ALLOW, + }, + ]); }); afterEach(() => { @@ -784,6 +863,125 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{ }); }); + describe('GET /v2/templates/:namespace/:kind/:name/parameter-schema', () => { + it('returns the parameter schema', async () => { + 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: { + required: ['requiredParameter1'], + type: 'object', + properties: { + 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', + }, + }, + }, + }, + ], + }); + }); + + it('filters parameters that the user is not authorized to see', async () => { + jest + .spyOn(permissionApi, 'authorizeConditional') + .mockImplementationOnce(async () => [ + { + result: AuthorizeResult.DENY, + }, + { + 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: [], + }); + }); + + 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', () => { it('rejects template values which do not match the template schema definition', async () => { const response = await request(app) @@ -801,6 +999,150 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{ expect(response.status).toEqual(400); }); + it('filters steps that the user is not authorized to see', async () => { + jest + .spyOn(permissionApi, 'authorizeConditional') + .mockImplementation(async () => [ + { + result: AuthorizeResult.ALLOW, + }, + { + result: AuthorizeResult.DENY, + }, + ]); + + 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: [], + output: mockTemplate.spec.output ?? {}, + parameters: { + 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, + 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('return the template id', async () => { const broker = taskBroker.dispatch as jest.Mocked['dispatch']; @@ -816,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', }, }); @@ -827,6 +1170,7 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{ it('should call the broker with a correct spec', async () => { const broker = taskBroker.dispatch as jest.Mocked['dispatch']; + const mockTemplate = getMockTemplate(); await request(app) .post('/v2/tasks') @@ -836,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( @@ -855,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, @@ -898,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); @@ -930,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', }, }); @@ -953,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', }, }); @@ -973,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', }, }); diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index b2be3e2749..1f2d390f9f 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -18,6 +18,7 @@ import { PluginDatabaseManager, UrlReader } from '@backstage/backend-common'; import { PluginTaskScheduler } from '@backstage/backend-tasks'; import { CatalogApi } from '@backstage/catalog-client'; import { + CompoundEntityRef, Entity, parseEntityRef, stringifyEntityRef, @@ -31,7 +32,15 @@ import { TaskSpec, TemplateEntityV1beta3, templateEntityV1beta3Validator, + TemplateParametersV1beta3, + TemplateEntityStepV1beta3, } from '@backstage/plugin-scaffolder-common'; +import { + RESOURCE_TYPE_SCAFFOLDER_TEMPLATE, + scaffolderPermissions, + templateParameterReadPermission, + templateStepReadPermission, +} from '@backstage/plugin-scaffolder-common/alpha'; import express from 'express'; import Router from 'express-promise-router'; import { validate } from 'jsonschema'; @@ -53,6 +62,29 @@ import { IdentityApiGetIdentityRequest, } from '@backstage/plugin-auth-node'; import { TemplateAction } from '@backstage/plugin-scaffolder-node'; +import { + PermissionEvaluator, + PermissionRuleParams, +} from '@backstage/plugin-permission-common'; +import { + createConditionAuthorizer, + createPermissionIntegrationRouter, + PermissionRule, +} from '@backstage/plugin-permission-node'; +import { scaffolderTemplateRules } from './rules'; + +/** + * + * @public + */ +export type TemplatePermissionRuleInput< + TParams extends PermissionRuleParams = PermissionRuleParams, +> = PermissionRule< + TemplateEntityStepV1beta3 | TemplateParametersV1beta3, + {}, + typeof RESOURCE_TYPE_SCAFFOLDER_TEMPLATE, + TParams +>; /** * RouterOptions @@ -80,6 +112,8 @@ export interface RouterOptions { taskBroker?: TaskBroker; additionalTemplateFilters?: Record; additionalTemplateGlobals?: Record; + permissionApi?: PermissionEvaluator; + permissionRules?: TemplatePermissionRuleInput[]; identity?: IdentityApi; } @@ -174,6 +208,8 @@ export async function createRouter( scheduler, additionalTemplateFilters, additionalTemplateGlobals, + permissionApi, + permissionRules, } = options; const logger = parentLogger.child({ plugin: 'scaffolder' }); @@ -249,41 +285,46 @@ export async function createRouter( additionalTemplateGlobals, }); + const templateRules: TemplatePermissionRuleInput[] = Object.values( + scaffolderTemplateRules, + ); + + if (permissionRules) { + templateRules.push(...permissionRules); + } + + const isAuthorized = createConditionAuthorizer(Object.values(templateRules)); + + const permissionIntegrationRouter = createPermissionIntegrationRouter({ + resourceType: RESOURCE_TYPE_SCAFFOLDER_TEMPLATE, + permissions: scaffolderPermissions, + rules: templateRules, + }); + + router.use(permissionIntegrationRouter); + router .get( '/v2/templates/:namespace/:kind/:name/parameter-schema', async (req, res) => { - const { namespace, kind, name } = req.params; - const userIdentity = await identity.getIdentity({ request: req, }); const token = userIdentity?.token; - const template = await findTemplate({ - catalogApi: catalogClient, - entityRef: { kind, namespace, name }, - token, + const template = await authorizeTemplate(req.params, token); + + const parameters = [template.spec.parameters ?? []].flat(); + res.json({ + title: template.metadata.title ?? template.metadata.name, + description: template.metadata.description, + 'ui:options': template.metadata['ui:options'], + steps: parameters.map(schema => ({ + title: schema.title ?? 'Please enter the following information', + description: schema.description, + schema, + })), }); - if (isSupportedTemplate(template)) { - const parameters = [template.spec.parameters ?? []].flat(); - res.json({ - title: template.metadata.title ?? template.metadata.name, - description: template.metadata.description, - 'ui:options': template.metadata['ui:options'], - steps: parameters.map(schema => ({ - title: schema.title ?? 'Please enter the following information', - description: schema.description, - schema, - })), - }); - } else { - throw new InputError( - `Unsupported apiVersion field in schema entity, ${ - (template as Entity).apiVersion - }`, - ); - } }, ) .get('/v2/actions', async (_req, res) => { @@ -321,22 +362,14 @@ export async function createRouter( const values = req.body.values; - const template = await findTemplate({ - catalogApi: catalogClient, - entityRef: { kind, namespace, name }, + const template = await authorizeTemplate( + { kind, namespace, name }, token, - }); - - if (!isSupportedTemplate(template)) { - throw new InputError( - `Unsupported apiVersion field in schema entity, ${ - (template as Entity).apiVersion - }`, - ); - } + ); for (const parameters of [template.spec.parameters ?? []].flat()) { const result = validate(values, parameters); + if (!result.valid) { res.status(400).json({ errors: result.errors }); return; @@ -567,5 +600,56 @@ export async function createRouter( app.set('logger', logger); app.use('/', router); + async function authorizeTemplate( + entityRef: CompoundEntityRef, + token: string | undefined, + ) { + const template = await findTemplate({ + catalogApi: catalogClient, + entityRef, + token, + }); + + if (!isSupportedTemplate(template)) { + throw new InputError( + `Unsupported apiVersion field in schema entity, ${ + (template as Entity).apiVersion + }`, + ); + } + + if (!permissionApi) { + return template; + } + + const [parameterDecision, stepDecision] = + await permissionApi.authorizeConditional( + [ + { permission: templateParameterReadPermission }, + { permission: templateStepReadPermission }, + ], + { token }, + ); + + // Authorize parameters + if (Array.isArray(template.spec.parameters)) { + template.spec.parameters = template.spec.parameters.filter(step => + isAuthorized(parameterDecision, step), + ); + } else if ( + template.spec.parameters && + !isAuthorized(parameterDecision, template.spec.parameters) + ) { + template.spec.parameters = undefined; + } + + // Authorize steps + template.spec.steps = template.spec.steps.filter(step => + isAuthorized(stepDecision, step), + ); + + return template; + } + return app; } diff --git a/plugins/scaffolder-backend/src/service/rules.test.ts b/plugins/scaffolder-backend/src/service/rules.test.ts new file mode 100644 index 0000000000..7d8e0eb445 --- /dev/null +++ b/plugins/scaffolder-backend/src/service/rules.test.ts @@ -0,0 +1,81 @@ +/* + * Copyright 2023 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. + */ + +import { hasTag } from './rules'; + +describe('hasTag', () => { + describe('apply', () => { + it('returns false when the tag is not present', () => { + expect( + hasTag.apply( + { + 'backstage:permissions': { + tags: ['foo', 'bar'], + }, + }, + { + tag: 'baz', + }, + ), + ).toEqual(false); + }); + + it('returns false when backstage:permissions is missing', () => { + expect( + hasTag.apply( + {}, + { + tag: 'baz', + }, + ), + ).toEqual(false); + }); + + it('returns false when tags is an empty array', () => { + expect( + hasTag.apply( + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + 'backstage:permissions': { + tags: [], + }, + }, + }, + { + tag: 'baz', + }, + ), + ).toEqual(false); + }); + + it('returns true when the tag is present', () => { + expect( + hasTag.apply( + { + 'backstage:permissions': { + tags: ['foo', 'bar'], + }, + }, + { + tag: 'bar', + }, + ), + ).toEqual(true); + }); + }); +}); diff --git a/plugins/scaffolder-backend/src/service/rules.ts b/plugins/scaffolder-backend/src/service/rules.ts new file mode 100644 index 0000000000..d94f7dd4f4 --- /dev/null +++ b/plugins/scaffolder-backend/src/service/rules.ts @@ -0,0 +1,45 @@ +/* + * Copyright 2022 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. + */ + +import { makeCreatePermissionRule } from '@backstage/plugin-permission-node'; +import { + TemplateEntityStepV1beta3, + TemplateParametersV1beta3, +} from '@backstage/plugin-scaffolder-common'; +import { RESOURCE_TYPE_SCAFFOLDER_TEMPLATE } from '@backstage/plugin-scaffolder-common/alpha'; + +import { z } from 'zod'; + +export const createScaffolderPermissionRule = makeCreatePermissionRule< + TemplateEntityStepV1beta3 | TemplateParametersV1beta3, + {}, + typeof RESOURCE_TYPE_SCAFFOLDER_TEMPLATE +>(); + +export const hasTag = createScaffolderPermissionRule({ + name: 'HAS_TAG', + resourceType: RESOURCE_TYPE_SCAFFOLDER_TEMPLATE, + description: `Match parameters or steps with the given tag`, + paramsSchema: z.object({ + tag: z.string().describe('Name of the tag to match on'), + }), + apply: (resource, { tag }) => { + return resource['backstage:permissions']?.tags?.includes(tag) ?? false; + }, + toQuery: () => ({}), +}); + +export const scaffolderTemplateRules = { hasTag }; diff --git a/plugins/scaffolder-common/alpha-api-report.md b/plugins/scaffolder-common/alpha-api-report.md new file mode 100644 index 0000000000..e89999ad85 --- /dev/null +++ b/plugins/scaffolder-common/alpha-api-report.md @@ -0,0 +1,21 @@ +## API Report File for "@backstage/plugin-scaffolder-common" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { ResourcePermission } from '@backstage/plugin-permission-common'; + +// @alpha +export const RESOURCE_TYPE_SCAFFOLDER_TEMPLATE = 'scaffolder-template'; + +// @alpha +export const scaffolderPermissions: ResourcePermission<'scaffolder-template'>[]; + +// @alpha +export const templateParameterReadPermission: ResourcePermission<'scaffolder-template'>; + +// @alpha +export const templateStepReadPermission: ResourcePermission<'scaffolder-template'>; + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/scaffolder-common/api-report.md b/plugins/scaffolder-common/api-report.md index fb21ac450f..7c40780b57 100644 --- a/plugins/scaffolder-common/api-report.md +++ b/plugins/scaffolder-common/api-report.md @@ -42,20 +42,30 @@ export interface TaskStep { name: string; } +// @public +export interface TemplateEntityStepV1beta3 extends JsonObject { + // (undocumented) + 'backstage:permissions'?: TemplatePermissionsV1beta3; + // (undocumented) + action: string; + // (undocumented) + id?: string; + // (undocumented) + if?: string | boolean; + // (undocumented) + input?: JsonObject; + // (undocumented) + name?: string; +} + // @public export interface TemplateEntityV1beta3 extends Entity { apiVersion: 'scaffolder.backstage.io/v1beta3'; kind: 'Template'; spec: { type: string; - parameters?: JsonObject | JsonObject[]; - steps: Array<{ - id?: string; - name?: string; - action: string; - input?: JsonObject; - if?: string | boolean; - }>; + parameters?: TemplateParametersV1beta3 | TemplateParametersV1beta3[]; + steps: Array; output?: { [name: string]: string; }; @@ -74,4 +84,16 @@ export type TemplateInfo = { metadata: EntityMeta; }; }; + +// @public +export interface TemplateParametersV1beta3 extends JsonObject { + // (undocumented) + 'backstage:permissions'?: TemplatePermissionsV1beta3; +} + +// @public +export interface TemplatePermissionsV1beta3 extends JsonObject { + // (undocumented) + tags?: string[]; +} ``` diff --git a/plugins/scaffolder-common/package.json b/plugins/scaffolder-common/package.json index 3d71f7b3e1..41c1b28fc1 100644 --- a/plugins/scaffolder-common/package.json +++ b/plugins/scaffolder-common/package.json @@ -11,6 +11,21 @@ "module": "dist/index.esm.js", "types": "dist/index.d.ts" }, + "exports": { + ".": "./src/index.ts", + "./alpha": "./src/alpha.ts", + "./package.json": "./package.json" + }, + "typesVersions": { + "*": { + "alpha": [ + "src/alpha.ts" + ], + "package.json": [ + "package.json" + ] + } + }, "backstage": { "role": "common-library" }, @@ -39,6 +54,7 @@ }, "dependencies": { "@backstage/catalog-model": "workspace:^", + "@backstage/plugin-permission-common": "workspace:^", "@backstage/types": "workspace:^" }, "devDependencies": { diff --git a/plugins/scaffolder-common/src/Template.v1beta3.schema.json b/plugins/scaffolder-common/src/Template.v1beta3.schema.json index e45bfb788d..beb0ca5e31 100644 --- a/plugins/scaffolder-common/src/Template.v1beta3.schema.json +++ b/plugins/scaffolder-common/src/Template.v1beta3.schema.json @@ -16,6 +16,9 @@ "owner": "artist-relations-team", "parameters": { "required": ["name", "description", "repoUrl"], + "backstage:permissions": { + "tags": ["one", "two"] + }, "properties": { "name": { "title": "Name", @@ -41,6 +44,9 @@ "action": "fetch:plain", "parameters": { "url": "./template" + }, + "backstage:permissions": { + "tags": ["one", "two"] } }, { @@ -92,14 +98,42 @@ "oneOf": [ { "type": "object", - "description": "The JSONSchema describing the inputs for the template." + "description": "The JSONSchema describing the inputs for the template.", + "properties": { + "backstage:permissions": { + "type": "object", + "description": "Object used for authorizing the parameter", + "properties": { + "tags": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } }, { "type": "array", "description": "A list of separate forms to collect parameters.", "items": { "type": "object", - "description": "The JSONSchema describing the inputs for the template." + "description": "The JSONSchema describing the inputs for the template.", + "properties": { + "backstage:permissions": { + "type": "object", + "description": "Object used for authorizing the parameter", + "properties": { + "tags": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } } } ] @@ -131,6 +165,18 @@ "if": { "type": ["string", "boolean"], "description": "A templated condition that skips the step when evaluated to false. If the condition is true or not defined, the step is executed. The condition is true, if the input is not `false`, `undefined`, `null`, `\"\"`, `0`, or `[]`." + }, + "backstage:permissions": { + "type": "object", + "description": "Object used for authorizing the step", + "properties": { + "tags": { + "type": "array", + "items": { + "type": "string" + } + } + } } } } diff --git a/plugins/scaffolder-common/src/TemplateEntityV1beta3.test.ts b/plugins/scaffolder-common/src/TemplateEntityV1beta3.test.ts index ab5900ab32..432292548e 100644 --- a/plugins/scaffolder-common/src/TemplateEntityV1beta3.test.ts +++ b/plugins/scaffolder-common/src/TemplateEntityV1beta3.test.ts @@ -15,7 +15,10 @@ */ import { entityKindSchemaValidator } from '@backstage/catalog-model'; -import type { TemplateEntityV1beta3 } from './TemplateEntityV1beta3'; +import type { + TemplateEntityV1beta3, + TemplateParametersV1beta3, +} from './TemplateEntityV1beta3'; import schema from './Template.v1beta3.schema.json'; const validator = entityKindSchemaValidator(schema); @@ -35,6 +38,7 @@ describe('templateEntityV1beta3Validator', () => { owner: 'team-b', parameters: { required: ['owner'], + 'backstage:permissions': { tags: ['one', 'two'] }, properties: { owner: { type: 'string', @@ -52,6 +56,9 @@ describe('templateEntityV1beta3Validator', () => { url: './template', }, if: '${{ parameters.owner }}', + 'backstage:permissions': { + tags: ['one', 'two'], + }, }, ], output: { @@ -154,4 +161,24 @@ describe('templateEntityV1beta3Validator', () => { (entity as any).spec.steps[0].if = 5; expect(() => validator(entity)).toThrow(/if/); }); + + it('rejects parameters with wrong backstage:permissions', async () => { + (entity.spec.parameters as TemplateParametersV1beta3)[ + 'backstage:permissions' + ]!.tags = true as unknown as []; + expect(() => validator(entity)).toThrow(/must be array/); + + (entity.spec.parameters as TemplateParametersV1beta3)[ + 'backstage:permissions' + ] = true as {}; + expect(() => validator(entity)).toThrow(/must be object/); + }); + + it('rejects steps with wrong backstage:permissions', async () => { + entity.spec.steps[0]['backstage:permissions']!.tags = true as unknown as []; + expect(() => validator(entity)).toThrow(/must be array/); + + entity.spec.steps[0]['backstage:permissions'] = true as {}; + expect(() => validator(entity)).toThrow(/must be object/); + }); }); diff --git a/plugins/scaffolder-common/src/TemplateEntityV1beta3.ts b/plugins/scaffolder-common/src/TemplateEntityV1beta3.ts index aaba35911e..ebabb5cad4 100644 --- a/plugins/scaffolder-common/src/TemplateEntityV1beta3.ts +++ b/plugins/scaffolder-common/src/TemplateEntityV1beta3.ts @@ -50,18 +50,12 @@ export interface TemplateEntityV1beta3 extends Entity { * to collect user input and validate it against that schema. This can then be used in the `steps` part below to template * variables passed from the user into each action in the template. */ - parameters?: JsonObject | JsonObject[]; + parameters?: TemplateParametersV1beta3 | TemplateParametersV1beta3[]; /** * A list of steps to be executed in sequence which are defined by the template. These steps are a list of the underlying * javascript action and some optional input parameters that may or may not have been collected from the end user. */ - steps: Array<{ - id?: string; - name?: string; - action: string; - input?: JsonObject; - if?: string | boolean; - }>; + steps: Array; /** * The output is an object where template authors can pull out information from template actions and return them in a known standard way. */ @@ -73,6 +67,38 @@ export interface TemplateEntityV1beta3 extends Entity { }; } +/** + * Step that is part of a Template Entity. + * + * @public + */ +export interface TemplateEntityStepV1beta3 extends JsonObject { + id?: string; + name?: string; + action: string; + input?: JsonObject; + if?: string | boolean; + 'backstage:permissions'?: TemplatePermissionsV1beta3; +} + +/** + * Parameter that is part of a Template Entity. + * + * @public + */ +export interface TemplateParametersV1beta3 extends JsonObject { + 'backstage:permissions'?: TemplatePermissionsV1beta3; +} + +/** + * Access control properties for parts of a template. + * + * @public + */ +export interface TemplatePermissionsV1beta3 extends JsonObject { + tags?: string[]; +} + const validator = entityKindSchemaValidator(schema); /** diff --git a/plugins/scaffolder-common/src/alpha.ts b/plugins/scaffolder-common/src/alpha.ts new file mode 100644 index 0000000000..9a8c021d15 --- /dev/null +++ b/plugins/scaffolder-common/src/alpha.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2023 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. + */ +export * from './permissions'; diff --git a/plugins/scaffolder-common/src/index.ts b/plugins/scaffolder-common/src/index.ts index 7f9792a1ba..fd34fe6f41 100644 --- a/plugins/scaffolder-common/src/index.ts +++ b/plugins/scaffolder-common/src/index.ts @@ -21,8 +21,14 @@ */ export * from './TaskSpec'; + export { templateEntityV1beta3Validator, isTemplateEntityV1beta3, } from './TemplateEntityV1beta3'; -export type { TemplateEntityV1beta3 } from './TemplateEntityV1beta3'; +export type { + TemplateEntityV1beta3, + TemplateEntityStepV1beta3, + TemplateParametersV1beta3, + TemplatePermissionsV1beta3, +} from './TemplateEntityV1beta3'; diff --git a/plugins/scaffolder-common/src/permissions.ts b/plugins/scaffolder-common/src/permissions.ts new file mode 100644 index 0000000000..858430eaf3 --- /dev/null +++ b/plugins/scaffolder-common/src/permissions.ts @@ -0,0 +1,69 @@ +/* + * Copyright 2022 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. + */ + +import { createPermission } from '@backstage/plugin-permission-common'; + +/** + * Permission resource type which corresponds to a scaffolder templates. + * + * @alpha + */ +export const RESOURCE_TYPE_SCAFFOLDER_TEMPLATE = 'scaffolder-template'; + +/** + * This permission is used to authorize actions that involve reading + * one or more parameters from a template. + * + * If this permission is not authorized, it will appear that the + * parameter does not exist in the template — both in the frontend + * and in API responses. + * + * @alpha + */ +export const templateParameterReadPermission = createPermission({ + name: 'scaffolder.template.parameter.read', + attributes: { + action: 'read', + }, + resourceType: RESOURCE_TYPE_SCAFFOLDER_TEMPLATE, +}); + +/** + * This permission is used to authorize actions that involve reading + * one or more steps from a template. + * + * If this permission is not authorized, it will appear that the + * step does not exist in the template — both in the frontend + * and in API responses. Steps will also not be executed. + * + * @alpha + */ +export const templateStepReadPermission = createPermission({ + name: 'scaffolder.template.step.read', + attributes: { + action: 'read', + }, + resourceType: RESOURCE_TYPE_SCAFFOLDER_TEMPLATE, +}); + +/** + * List of all the scaffolder permissions + * @alpha + */ +export const scaffolderPermissions = [ + templateParameterReadPermission, + templateStepReadPermission, +]; diff --git a/yarn.lock b/yarn.lock index 1f8f7fa330..4a95062753 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7714,6 +7714,8 @@ __metadata: "@backstage/plugin-catalog-backend": "workspace:^" "@backstage/plugin-catalog-common": "workspace:^" "@backstage/plugin-catalog-node": "workspace:^" + "@backstage/plugin-permission-common": "workspace:^" + "@backstage/plugin-permission-node": "workspace:^" "@backstage/plugin-scaffolder-common": "workspace:^" "@backstage/plugin-scaffolder-node": "workspace:^" "@backstage/types": "workspace:^" @@ -7773,6 +7775,7 @@ __metadata: dependencies: "@backstage/catalog-model": "workspace:^" "@backstage/cli": "workspace:^" + "@backstage/plugin-permission-common": "workspace:^" "@backstage/types": "workspace:^" languageName: unknown linkType: soft