diff --git a/.changeset/pretty-yaks-carry.md b/.changeset/pretty-yaks-carry.md new file mode 100644 index 0000000000..4c160156b5 --- /dev/null +++ b/.changeset/pretty-yaks-carry.md @@ -0,0 +1,5 @@ +--- +'@backstage/create-app': minor +--- + +Renamed `permissionApi` to `permissions` when passed as dependency of the scaffolder-backend plugin diff --git a/.changeset/seven-oranges-act.md b/.changeset/seven-oranges-act.md new file mode 100644 index 0000000000..c1b3bc61c0 --- /dev/null +++ b/.changeset/seven-oranges-act.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': minor +--- + +Renamed permissionApi router option to permissions diff --git a/.changeset/silly-eels-admire.md b/.changeset/silly-eels-admire.md new file mode 100644 index 0000000000..0d39027518 --- /dev/null +++ b/.changeset/silly-eels-admire.md @@ -0,0 +1,12 @@ +--- +'@backstage/plugin-scaffolder-backend': minor +--- + +Added the possibility to authorize actions + +It is now possible to decide who should be able to execute certain actions or who should be able to pass specific input to specified actions. + +Some of the existing utility functions for creating conditional decisions have been renamed: + +- `createScaffolderConditionalDecision` has been renamed to `createScaffolderActionConditionalDecision` +- `scaffolderConditions` has been renamed to `scaffolderTemplateConditions` diff --git a/.changeset/ten-games-rush.md b/.changeset/ten-games-rush.md new file mode 100644 index 0000000000..ceb8604080 --- /dev/null +++ b/.changeset/ten-games-rush.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-common': patch +--- + +Added permissions for authorizing actions diff --git a/packages/backend/src/plugins/scaffolder.ts b/packages/backend/src/plugins/scaffolder.ts index 813092c9b9..821e5c1adf 100644 --- a/packages/backend/src/plugins/scaffolder.ts +++ b/packages/backend/src/plugins/scaffolder.ts @@ -34,6 +34,6 @@ export default async function createPlugin( reader: env.reader, identity: env.identity, scheduler: env.scheduler, - permissionApi: env.permissions, + permissions: 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 fd424a3257..a12fee2295 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,6 +17,6 @@ export default async function createPlugin( reader: env.reader, catalogClient, identity: env.identity, - permissionApi: env.permissions, + permissions: env.permissions, }); } diff --git a/plugins/scaffolder-backend/alpha-api-report.md b/plugins/scaffolder-backend/alpha-api-report.md index f636ecbb37..814540f028 100644 --- a/plugins/scaffolder-backend/alpha-api-report.md +++ b/plugins/scaffolder-backend/alpha-api-report.md @@ -6,6 +6,7 @@ import { BackendFeature } from '@backstage/backend-plugin-api'; import { ConditionalPolicyDecision } from '@backstage/plugin-permission-common'; import { Conditions } from '@backstage/plugin-permission-node'; +import { JsonObject } from '@backstage/types'; import { PermissionCondition } from '@backstage/plugin-permission-common'; import { PermissionCriteria } from '@backstage/plugin-permission-common'; import { PermissionRule } from '@backstage/plugin-permission-node'; @@ -20,20 +21,65 @@ import { TemplateParametersV1beta3 } from '@backstage/plugin-scaffolder-common'; // @alpha export const catalogModuleTemplateKind: () => BackendFeature; +// @alpha (undocumented) +export const createScaffolderActionConditionalDecision: ( + permission: ResourcePermission<'scaffolder-action'>, + conditions: PermissionCriteria>, +) => ConditionalPolicyDecision; + // @alpha -export const createScaffolderConditionalDecision: ( +export const createScaffolderTemplateConditionalDecision: ( permission: ResourcePermission<'scaffolder-template'>, conditions: PermissionCriteria>, ) => ConditionalPolicyDecision; // @alpha -export const scaffolderConditions: Conditions<{ - hasTag: PermissionRule< - TemplateParametersV1beta3 | TemplateEntityStepV1beta3, - {}, - 'scaffolder-template', +export const scaffolderActionConditions: Conditions<{ + hasActionId: PermissionRule< { - tag: string; + action: string; + input: JsonObject | undefined; + }, + {}, + 'scaffolder-action', + { + actionId: string; + } + >; + hasBooleanProperty: PermissionRule< + { + action: string; + input: JsonObject | undefined; + }, + {}, + 'scaffolder-action', + { + key: string; + value?: boolean | undefined; + } + >; + hasNumberProperty: PermissionRule< + { + action: string; + input: JsonObject | undefined; + }, + {}, + 'scaffolder-action', + { + key: string; + value?: number | undefined; + } + >; + hasStringProperty: PermissionRule< + { + action: string; + input: JsonObject | undefined; + }, + {}, + 'scaffolder-action', + { + key: string; + value?: string | undefined; } >; }>; @@ -52,5 +98,17 @@ export type ScaffolderPluginOptions = { additionalTemplateGlobals?: Record; }; +// @alpha +export const scaffolderTemplateConditions: Conditions<{ + hasTag: PermissionRule< + TemplateParametersV1beta3 | TemplateEntityStepV1beta3, + {}, + 'scaffolder-template', + { + tag: string; + } + >; +}>; + // (No @packageDocumentation comment for this package) ``` diff --git a/plugins/scaffolder-backend/api-report.md b/plugins/scaffolder-backend/api-report.md index 5b12455d0f..00a8553e30 100644 --- a/plugins/scaffolder-backend/api-report.md +++ b/plugins/scaffolder-backend/api-report.md @@ -651,6 +651,7 @@ export type CreateWorkerOptions = { additionalTemplateFilters?: Record; concurrentTasksLimit?: number; additionalTemplateGlobals?: Record; + permissions?: PermissionEvaluator; }; // @public @@ -762,10 +763,10 @@ export interface RouterOptions { // (undocumented) logger: Logger; // (undocumented) - permissionApi?: PermissionEvaluator; - // (undocumented) permissionRules?: TemplatePermissionRuleInput[]; // (undocumented) + permissions?: PermissionEvaluator; + // (undocumented) reader: UrlReader; // (undocumented) scheduler?: PluginTaskScheduler; diff --git a/plugins/scaffolder-backend/src/ScaffolderPlugin.ts b/plugins/scaffolder-backend/src/ScaffolderPlugin.ts index 379a7394be..b7975e167e 100644 --- a/plugins/scaffolder-backend/src/ScaffolderPlugin.ts +++ b/plugins/scaffolder-backend/src/ScaffolderPlugin.ts @@ -132,7 +132,7 @@ export const scaffolderPlugin = createBackendPlugin( taskWorkers, additionalTemplateFilters, additionalTemplateGlobals, - permissionApi: permissions, + permissions, }); httpRouter.use(router); }, diff --git a/plugins/scaffolder-backend/src/scaffolder/dryrun/createDryRunner.ts b/plugins/scaffolder-backend/src/scaffolder/dryrun/createDryRunner.ts index d9302f4d74..655c485a6d 100644 --- a/plugins/scaffolder-backend/src/scaffolder/dryrun/createDryRunner.ts +++ b/plugins/scaffolder-backend/src/scaffolder/dryrun/createDryRunner.ts @@ -35,6 +35,7 @@ import { createTemplateAction, TaskSecrets, } from '@backstage/plugin-scaffolder-node'; +import { PermissionEvaluator } from '@backstage/plugin-permission-common'; interface DryRunInput { spec: TaskSpec; @@ -56,6 +57,7 @@ export type TemplateTesterCreateOptions = { workingDirectory: string; additionalTemplateFilters?: Record; additionalTemplateGlobals?: Record; + permissions?: PermissionEvaluator; }; /** diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts index b8c9edaae7..5d4f7b73cc 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts @@ -31,6 +31,11 @@ import { } from '@backstage/plugin-scaffolder-node'; import { UserEntity } from '@backstage/catalog-model'; import { z } from 'zod'; +import { + AuthorizeResult, + PermissionEvaluator, +} from '@backstage/plugin-permission-common'; +import { RESOURCE_TYPE_SCAFFOLDER_ACTION } from '@backstage/plugin-scaffolder-common/alpha'; // The Stream module is lazy loaded, so make sure it's in the module cache before mocking fs void winston.transports.Stream; @@ -51,6 +56,10 @@ describe('DefaultWorkflowRunner', () => { let runner: NunjucksWorkflowRunner; let fakeActionHandler: jest.Mock; + const mockedPermissionApi: jest.Mocked = { + authorizeConditional: jest.fn(), + } as unknown as jest.Mocked; + const integrations = ScmIntegrations.fromConfig( new ConfigReader({ integrations: { @@ -132,11 +141,16 @@ describe('DefaultWorkflowRunner', () => { }, }); + mockedPermissionApi.authorizeConditional.mockResolvedValue([ + { result: AuthorizeResult.ALLOW }, + ]); + runner = new NunjucksWorkflowRunner({ actionRegistry, integrations, workingDirectory: '/tmp', logger, + permissions: mockedPermissionApi, }); }); @@ -809,4 +823,83 @@ describe('DefaultWorkflowRunner', () => { expect(fakeActionHandler.mock.calls[0][0].isDryRun).toEqual(true); }); }); + + describe('permissions', () => { + it('should throw an error if an actions is not authorized', async () => { + mockedPermissionApi.authorizeConditional.mockResolvedValueOnce([ + { result: AuthorizeResult.DENY }, + ]); + + const task = createMockTaskWithSpec({ + apiVersion: 'scaffolder.backstage.io/v1beta3', + parameters: {}, + output: {}, + steps: [ + { + id: 'test', + name: 'name', + action: 'jest-validated-action', + input: { foo: 1 }, + }, + ], + }); + + await expect(runner.execute(task)).rejects.toThrow( + /Unauthorized action: jest-validated-action. The action is not allowed/, + ); + expect(fakeActionHandler).not.toHaveBeenCalled(); + }); + + it(`shouldn't execute actions who aren't authorized`, async () => { + mockedPermissionApi.authorizeConditional.mockResolvedValueOnce([ + { + result: AuthorizeResult.CONDITIONAL, + pluginId: 'scaffolder', + resourceType: RESOURCE_TYPE_SCAFFOLDER_ACTION, + conditions: { + anyOf: [ + { + resourceType: RESOURCE_TYPE_SCAFFOLDER_ACTION, + rule: 'HAS_NUMBER_PROPERTY', + params: { + key: 'foo', + value: 1, + }, + }, + ], + }, + }, + ]); + + const task = createMockTaskWithSpec({ + apiVersion: 'scaffolder.backstage.io/v1beta3', + parameters: {}, + output: {}, + steps: [ + { + id: 'test1', + name: 'valid action', + action: 'jest-validated-action', + input: { foo: 1 }, + }, + { + id: 'test2', + name: 'invalid action', + action: 'jest-validated-action', + input: { foo: 2 }, + }, + ], + }); + + await expect(runner.execute(task)).rejects.toThrow( + `Unauthorized action: jest-validated-action. The action is not allowed. Input: ${JSON.stringify( + { foo: 2 }, + null, + 2, + )}`, + ); + expect(fakeActionHandler).toHaveBeenCalled(); + expect(mockedPermissionApi.authorizeConditional).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts index 9358484642..da690bcb82 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts @@ -26,7 +26,7 @@ import fs from 'fs-extra'; import path from 'path'; import nunjucks from 'nunjucks'; import { JsonObject, JsonValue } from '@backstage/types'; -import { InputError } from '@backstage/errors'; +import { InputError, NotAllowedError } from '@backstage/errors'; import { PassThrough } from 'stream'; import { generateExampleOutput, isTruthy } from './helper'; import { validate as validateJsonSchema } from 'jsonschema'; @@ -42,10 +42,19 @@ import { TaskSpecV1beta3, TaskStep, } from '@backstage/plugin-scaffolder-common'; + import { TemplateAction } from '@backstage/plugin-scaffolder-node'; +import { createConditionAuthorizer } from '@backstage/plugin-permission-node'; import { UserEntity } from '@backstage/catalog-model'; import { createCounterMetric, createHistogramMetric } from '../../util/metrics'; import { createDefaultFilters } from '../../lib/templating/filters'; +import { + AuthorizeResult, + PermissionEvaluator, + PolicyDecision, +} from '@backstage/plugin-permission-common'; +import { scaffolderActionRules } from '../../service/rules'; +import { actionExecutePermission } from '@backstage/plugin-scaffolder-common/alpha'; type NunjucksWorkflowRunnerOptions = { workingDirectory: string; @@ -54,6 +63,7 @@ type NunjucksWorkflowRunnerOptions = { logger: winston.Logger; additionalTemplateFilters?: Record; additionalTemplateGlobals?: Record; + permissions?: PermissionEvaluator; }; type TemplateContext = { @@ -102,6 +112,10 @@ const createStepLogger = ({ return { taskLogger, streamLogger }; }; +const isActionAuthorized = createConditionAuthorizer( + Object.values(scaffolderActionRules), +); + export class NunjucksWorkflowRunner implements WorkflowRunner { private readonly defaultTemplateFilters: Record; constructor(private readonly options: NunjucksWorkflowRunnerOptions) { @@ -198,6 +212,7 @@ export class NunjucksWorkflowRunner implements WorkflowRunner { renderTemplate: (template: string, values: unknown) => string, taskTrack: TaskTrackType, workspacePath: string, + decision: PolicyDecision, ) { const stepTrack = await this.tracker.stepStart(task, step); @@ -281,6 +296,18 @@ export class NunjucksWorkflowRunner implements WorkflowRunner { } } + if (!isActionAuthorized(decision, { action: action.id, input })) { + throw new NotAllowedError( + `Unauthorized action: ${ + action.id + }. The action is not allowed. Input: ${JSON.stringify( + input, + null, + 2, + )}`, + ); + } + const tmpDirs = new Array(); const stepOutput: { [outputName: string]: JsonValue } = {}; @@ -355,6 +382,14 @@ export class NunjucksWorkflowRunner implements WorkflowRunner { user: task.spec.user, }; + const [decision]: PolicyDecision[] = + this.options.permissions && task.spec.steps.length + ? await this.options.permissions.authorizeConditional( + [{ permission: actionExecutePermission }], + { token: task.secrets?.backstageToken }, + ) + : [{ result: AuthorizeResult.ALLOW }]; + for (const step of task.spec.steps) { await this.executeStep( task, @@ -363,6 +398,7 @@ export class NunjucksWorkflowRunner implements WorkflowRunner { renderTemplate, taskTrack, workspacePath, + decision, ); } diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts index 1a2d02736c..9f9b86b723 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts @@ -22,7 +22,7 @@ import { TemplateActionRegistry } from '../actions'; import { ScmIntegrations } from '@backstage/integration'; import { assertError } from '@backstage/errors'; import { TemplateFilter, TemplateGlobal } from '../../lib'; - +import { PermissionEvaluator } from '@backstage/plugin-permission-common'; /** * TaskWorkerOptions * @@ -34,6 +34,7 @@ export type TaskWorkerOptions = { workflowRunner: WorkflowRunner; }; concurrentTasksLimit: number; + permissions?: PermissionEvaluator; }; /** @@ -62,6 +63,7 @@ export type CreateWorkerOptions = { */ concurrentTasksLimit?: number; additionalTemplateGlobals?: Record; + permissions?: PermissionEvaluator; }; /** @@ -88,6 +90,7 @@ export class TaskWorker { additionalTemplateFilters, concurrentTasksLimit = 10, // from 1 to Infinity additionalTemplateGlobals, + permissions, } = options; const workflowRunner = new NunjucksWorkflowRunner({ @@ -97,12 +100,14 @@ export class TaskWorker { workingDirectory, additionalTemplateFilters, additionalTemplateGlobals, + permissions, }); return new TaskWorker({ taskBroker: taskBroker, runners: { workflowRunner }, concurrentTasksLimit, + permissions, }); } diff --git a/plugins/scaffolder-backend/src/service/conditionExports.ts b/plugins/scaffolder-backend/src/service/conditionExports.ts index 0c502c6075..7d22c9d262 100644 --- a/plugins/scaffolder-backend/src/service/conditionExports.ts +++ b/plugins/scaffolder-backend/src/service/conditionExports.ts @@ -14,26 +14,27 @@ * limitations under the License. */ -import { RESOURCE_TYPE_SCAFFOLDER_TEMPLATE } from '@backstage/plugin-scaffolder-common/alpha'; +import { + RESOURCE_TYPE_SCAFFOLDER_TEMPLATE, + RESOURCE_TYPE_SCAFFOLDER_ACTION, +} from '@backstage/plugin-scaffolder-common/alpha'; import { createConditionExports } from '@backstage/plugin-permission-node'; -import { scaffolderTemplateRules } from './rules'; +import { scaffolderTemplateRules, scaffolderActionRules } from './rules'; -const { conditions, createConditionalDecision } = createConditionExports({ +const templateConditionExports = 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; +const actionsConditionExports = createConditionExports({ + pluginId: 'scaffolder', + resourceType: RESOURCE_TYPE_SCAFFOLDER_ACTION, + rules: scaffolderActionRules, +}); /** - * `createScaffolderConditionalDecision` can be used when authoring policies to + * `createScaffolderTemplateConditionalDecision` 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 @@ -64,4 +65,28 @@ export const scaffolderConditions = conditions; * * @alpha */ -export const createScaffolderConditionalDecision = createConditionalDecision; +export const createScaffolderTemplateConditionalDecision = + templateConditionExports.createConditionalDecision; + +/** + * These conditions are used when creating conditional decisions for scaffolder + * templates that are returned by authorization policies. + * + * @alpha + */ +export const scaffolderTemplateConditions = templateConditionExports.conditions; + +/** + * @alpha + */ +export const createScaffolderActionConditionalDecision = + actionsConditionExports.createConditionalDecision; + +/** + * + * These conditions are used when creating conditional decisions for scaffolder + * actions that are returned by authorization policies. + * + * @alpha + */ +export const scaffolderActionConditions = actionsConditionExports.conditions; diff --git a/plugins/scaffolder-backend/src/service/router.test.ts b/plugins/scaffolder-backend/src/service/router.test.ts index 4f5ed0fe22..f5aa043ce0 100644 --- a/plugins/scaffolder-backend/src/service/router.test.ts +++ b/plugins/scaffolder-backend/src/service/router.test.ts @@ -195,7 +195,7 @@ describe('createRouter', () => { catalogClient, reader: mockUrlReader, taskBroker, - permissionApi, + permissions: permissionApi, }); app = express().use(router); @@ -819,7 +819,7 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{ reader: mockUrlReader, taskBroker, identity: { getIdentity }, - permissionApi, + permissions: permissionApi, }); app = express().use(router); diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index 1f2d390f9f..0a45f2017d 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -112,7 +112,7 @@ export interface RouterOptions { taskBroker?: TaskBroker; additionalTemplateFilters?: Record; additionalTemplateGlobals?: Record; - permissionApi?: PermissionEvaluator; + permissions?: PermissionEvaluator; permissionRules?: TemplatePermissionRuleInput[]; identity?: IdentityApi; } @@ -208,7 +208,7 @@ export async function createRouter( scheduler, additionalTemplateFilters, additionalTemplateGlobals, - permissionApi, + permissions, permissionRules, } = options; @@ -258,6 +258,7 @@ export async function createRouter( additionalTemplateFilters, additionalTemplateGlobals, concurrentTasksLimit, + permissions, }); workers.push(worker); } @@ -283,6 +284,7 @@ export async function createRouter( workingDirectory, additionalTemplateFilters, additionalTemplateGlobals, + permissions, }); const templateRules: TemplatePermissionRuleInput[] = Object.values( @@ -392,11 +394,7 @@ export async function createRouter( ref: userEntityRef, }, templateInfo: { - entityRef: stringifyEntityRef({ - kind, - namespace, - name: template.metadata?.name, - }), + entityRef: stringifyEntityRef({ kind, name, namespace }), baseUrl, entity: { metadata: template.metadata, @@ -618,12 +616,12 @@ export async function createRouter( ); } - if (!permissionApi) { + if (!permissions) { return template; } const [parameterDecision, stepDecision] = - await permissionApi.authorizeConditional( + await permissions.authorizeConditional( [ { permission: templateParameterReadPermission }, { permission: templateStepReadPermission }, diff --git a/plugins/scaffolder-backend/src/service/rules.test.ts b/plugins/scaffolder-backend/src/service/rules.test.ts index 7d8e0eb445..e6032dde15 100644 --- a/plugins/scaffolder-backend/src/service/rules.test.ts +++ b/plugins/scaffolder-backend/src/service/rules.test.ts @@ -14,7 +14,15 @@ * limitations under the License. */ -import { hasTag } from './rules'; +import { JsonObject, JsonPrimitive } from '@backstage/types'; +import { + hasActionId, + hasBooleanProperty, + hasNumberProperty, + hasProperty, + hasStringProperty, + hasTag, +} from './rules'; describe('hasTag', () => { describe('apply', () => { @@ -79,3 +87,358 @@ describe('hasTag', () => { }); }); }); + +describe('hasActionId', () => { + describe('apply', () => { + it('returns false when actionId is not matched', () => { + expect( + hasActionId.apply( + { + action: 'action', + input: {}, + }, + { + actionId: 'not-matched', + }, + ), + ).toEqual(false); + }); + + it('returns true when actionId is matched', () => { + expect( + hasActionId.apply( + { + action: 'action', + input: {}, + }, + { + actionId: 'action', + }, + ), + ).toEqual(true); + }); + }); +}); + +const input: JsonObject = { + propwithstring: '1', + propwithnumber: 2, + propwithobject: {}, + propwithnull: null, + propwithfalse: false, + propwithtrue: true, + propwitharray: ['item', 0, true, false], + nested: { propwithstring: '1', nested: { propwithnumber: 1 } }, +}; + +describe('hasProperty', () => { + describe('apply', () => { + it.each([ + 'foo', + 'bar', + 'prop.prop', + 'nested.nonexisting', + '', + 'propwitharray.100', + ])(`returns false when a property doesn't exist in the input`, key => { + expect(hasProperty.apply({ action: 'action', input }, { key })).toEqual( + false, + ); + }); + + it.each([ + 'propwithstring', + 'propwithnumber', + 'propwithobject', + 'propwithnull', + 'propwithfalse', + 'propwithtrue', + 'propwitharray', + 'propwitharray.1', + 'nested.propwithstring', + 'nested.nested', + 'nested.nested.propwithnumber', + ])(`returns true when a property exists, property=%s`, key => { + expect(hasProperty.apply({ action: 'action', input }, { key })).toEqual( + true, + ); + }); + + it.each([ + ['propwithstring', 1], + ['propwithnumber', '2'], + ['propwithnumber', true], + ['propwithobject', [{}]], + ['propwithnull', false], + ['propwithfalse', true], + ['propwithtrue', null], + ['propwitharray', 'nonexistingitem'], + ['propwitharray.0', 'nonmatchingitem'], + ['nested.propwithstring', 'x'], + ['nested.nested', '1'], + ['nested.nested.propwithnumber', 'ops'], + ])( + `returns false when a property exists but the value doesn't match, key=%s value=%o`, + (key, value) => { + expect( + hasProperty.apply( + { action: 'action', input }, + { key, value: value as JsonPrimitive }, + ), + ).toEqual(false); + }, + ); + + it.each([ + ['propwithstring', '1'], + ['propwithnumber', 2], + ['propwithnull', null], + ['propwithfalse', false], + ['propwithtrue', true], + ['propwitharray.0', 'item'], + ['nested.propwithstring', '1'], + ['nested.nested.propwithnumber', 1], + ])( + `returns true when a property exists and the value matches, key=%s value=%o`, + (key, value) => { + expect( + hasProperty.apply({ action: 'action', input }, { key, value }), + ).toEqual(true); + }, + ); + }); +}); + +describe('hasBooleanProperty', () => { + describe('apply', () => { + it.each(['foo', 'bar', 'prop.prop', 'nested.nonexisting', ''])( + `returns false when a property doesn't exist in the input`, + key => { + expect( + hasBooleanProperty.apply({ action: 'action', input }, { key }), + ).toEqual(false); + }, + ); + + it.each([ + 'propwithstring', + 'propwithnumber', + 'propwithobject', + 'propwithnull', + 'propwitharray', + 'propwitharray.0', + 'nested.propwithstring', + 'nested.nested', + 'nested.nested.propwithnumber', + ])( + `returns false when a property exists and is not a boolean, property=%s`, + key => { + expect( + hasBooleanProperty.apply({ action: 'action', input }, { key }), + ).toEqual(false); + }, + ); + + it.each([ + 'propwithfalse', + 'propwithtrue', + 'propwitharray.2', + 'propwitharray.3', + ])(`returns true when a property exists, property=%s`, key => { + expect( + hasBooleanProperty.apply({ action: 'action', input }, { key }), + ).toEqual(true); + }); + + it.each([ + ['propwithstring', true], + ['propwithnumber', true], + ['propwithnumber', true], + ['propwithobject', true], + ['propwithnull', true], + ['propwithfalse', true], + ['propwithtrue', false], + ['propwitharray', true], + ['propwitharray.2', false], + ['propwitharray.3', true], + ['nested.propwithstring', true], + ['nested.nested', true], + ['nested.nested.propwithnumber', true], + ])( + `returns false when a property exists but the value doesn't match, key=%s value=%o`, + (key, value) => { + expect( + hasBooleanProperty.apply({ action: 'action', input }, { key, value }), + ).toEqual(false); + }, + ); + + it.each([ + ['propwithfalse', false], + ['propwithtrue', true], + ['propwitharray.2', true], + ['propwitharray.3', false], + ])( + `returns true when a property exists and the value matches, key=%s value=%o`, + (key, value) => { + expect( + hasBooleanProperty.apply({ action: 'action', input }, { key, value }), + ).toEqual(true); + }, + ); + }); +}); + +describe('hasNumberProperty', () => { + describe('apply', () => { + it.each(['foo', 'bar', 'prop.prop', 'nested.nonexisting', ''])( + `returns false when a property doesn't exist in the input`, + key => { + expect( + hasNumberProperty.apply({ action: 'action', input }, { key }), + ).toEqual(false); + }, + ); + + it.each([ + 'propwithstring', + 'propwithobject', + 'propwithnull', + 'propwithfalse', + 'propwithtrue', + 'propwitharray', + 'propwitharray.0', + 'nested.propwithstring', + 'nested.nested', + ])( + `returns false when a property exists and is not a number, property=%s`, + key => { + expect( + hasNumberProperty.apply({ action: 'action', input }, { key }), + ).toEqual(false); + }, + ); + + it.each([ + 'propwithnumber', + 'nested.nested.propwithnumber', + 'propwitharray.1', + ])(`returns true when a property exists, property=%s`, key => { + expect( + hasNumberProperty.apply({ action: 'action', input }, { key }), + ).toEqual(true); + }); + + it.each([ + ['propwithstring', 1], + ['propwithnumber', 1000], + ['propwithnumber', 101], + ['propwithobject', 1], + ['propwithnull', 1], + ['propwithfalse', 1], + ['propwithtrue', 1], + ['propwitharray', 1], + ['propwitharray.2', 1], + ['propwitharray.3', 1], + ['nested.propwithstring', 1], + ['nested.nested', 1], + ['nested.nested.propwithnumber', 100], + ])( + `returns false when a property exists but the value doesn't match, key=%s value=%o`, + (key, value) => { + expect( + hasNumberProperty.apply({ action: 'action', input }, { key, value }), + ).toEqual(false); + }, + ); + + it.each([ + ['propwithnumber', 2], + ['nested.nested.propwithnumber', 1], + ['propwitharray.1', 0], + ])( + `returns true when a property exists and the value matches, key=%s value=%o`, + (key, value) => { + expect( + hasNumberProperty.apply({ action: 'action', input }, { key, value }), + ).toEqual(true); + }, + ); + }); +}); + +describe('hasStringProperty', () => { + describe('apply', () => { + it.each(['foo', 'bar', 'prop.prop', 'nested.nonexisting', ''])( + `returns false when a property doesn't exist in the input`, + key => { + expect( + hasStringProperty.apply({ action: 'action', input }, { key }), + ).toEqual(false); + }, + ); + + it.each([ + 'propwithnumber', + 'propwithobject', + 'propwithnull', + 'propwithfalse', + 'propwithtrue', + 'propwitharray', + 'propwitharray.1', + 'nested.nested.propwithnumber', + 'nested.nested', + ])( + `returns false when a property exists and is not a string, property=%s`, + key => { + expect( + hasStringProperty.apply({ action: 'action', input }, { key }), + ).toEqual(false); + }, + ); + + it.each(['propwithstring', 'nested.propwithstring', 'propwitharray.0'])( + `returns true when a property exists, property=%s`, + key => { + expect( + hasStringProperty.apply({ action: 'action', input }, { key }), + ).toEqual(true); + }, + ); + + it.each([ + ['propwithstring', 'nonmatchingstring'], + ['propwithnumber', 's'], + ['propwithnumber', 's'], + ['propwithobject', 's'], + ['propwithnull', 's'], + ['propwithfalse', 's'], + ['propwithtrue', 's'], + ['propwitharray', 's'], + ['propwitharray.2', 's'], + ['propwitharray.3', 's'], + ['nested.nested', 's'], + ['nested.nested.propwithnumber', 's'], + ])( + `returns false when a property exists but the value doesn't match, key=%s value=%o`, + (key, value) => { + expect( + hasStringProperty.apply({ action: 'action', input }, { key, value }), + ).toEqual(false); + }, + ); + + it.each([ + ['propwithstring', '1'], + ['nested.propwithstring', '1'], + ['propwitharray.0', 'item'], + ])( + `returns true when a property exists and the value matches, key=%s value=%o`, + (key, value) => { + expect( + hasStringProperty.apply({ action: 'action', input }, { key, value }), + ).toEqual(true); + }, + ); + }); +}); diff --git a/plugins/scaffolder-backend/src/service/rules.ts b/plugins/scaffolder-backend/src/service/rules.ts index d94f7dd4f4..19b34b9f7c 100644 --- a/plugins/scaffolder-backend/src/service/rules.ts +++ b/plugins/scaffolder-backend/src/service/rules.ts @@ -15,21 +15,27 @@ */ import { makeCreatePermissionRule } from '@backstage/plugin-permission-node'; +import { + RESOURCE_TYPE_SCAFFOLDER_TEMPLATE, + RESOURCE_TYPE_SCAFFOLDER_ACTION, +} from '@backstage/plugin-scaffolder-common/alpha'; + import { TemplateEntityStepV1beta3, TemplateParametersV1beta3, } from '@backstage/plugin-scaffolder-common'; -import { RESOURCE_TYPE_SCAFFOLDER_TEMPLATE } from '@backstage/plugin-scaffolder-common/alpha'; import { z } from 'zod'; +import { JsonObject, JsonPrimitive } from '@backstage/types'; +import { get } from 'lodash'; -export const createScaffolderPermissionRule = makeCreatePermissionRule< +export const createTemplatePermissionRule = makeCreatePermissionRule< TemplateEntityStepV1beta3 | TemplateParametersV1beta3, {}, typeof RESOURCE_TYPE_SCAFFOLDER_TEMPLATE >(); -export const hasTag = createScaffolderPermissionRule({ +export const hasTag = createTemplatePermissionRule({ name: 'HAS_TAG', resourceType: RESOURCE_TYPE_SCAFFOLDER_TEMPLATE, description: `Match parameters or steps with the given tag`, @@ -42,4 +48,89 @@ export const hasTag = createScaffolderPermissionRule({ toQuery: () => ({}), }); +export const createActionPermissionRule = makeCreatePermissionRule< + { + action: string; + input: JsonObject | undefined; + }, + {}, + typeof RESOURCE_TYPE_SCAFFOLDER_ACTION +>(); + +export const hasActionId = createActionPermissionRule({ + name: 'HAS_ACTION_ID', + resourceType: RESOURCE_TYPE_SCAFFOLDER_ACTION, + description: `Match actions with the given actionId`, + paramsSchema: z.object({ + actionId: z.string().describe('Name of the actionId to match on'), + }), + apply: (resource, { actionId }) => { + return resource.action === actionId; + }, + toQuery: () => ({}), +}); + +export const hasProperty = buildHasProperty({ + name: 'HAS_PROPERTY', + valueSchema: z.union([z.string(), z.number(), z.boolean(), z.null()]), + validateProperty: false, +}); + +export const hasBooleanProperty = buildHasProperty({ + name: 'HAS_BOOLEAN_PROPERTY', + valueSchema: z.boolean(), +}); +export const hasNumberProperty = buildHasProperty({ + name: 'HAS_NUMBER_PROPERTY', + valueSchema: z.number(), +}); +export const hasStringProperty = buildHasProperty({ + name: 'HAS_STRING_PROPERTY', + valueSchema: z.string(), +}); + +function buildHasProperty>({ + name, + valueSchema, + validateProperty = true, +}: { + name: string; + valueSchema: Schema; + validateProperty?: boolean; +}) { + return createActionPermissionRule({ + name, + description: `Allow actions with the specified property`, + resourceType: RESOURCE_TYPE_SCAFFOLDER_ACTION, + paramsSchema: z.object({ + key: z + .string() + .describe(`Property within the action parameters to match on`), + value: valueSchema.describe(`Value of the given property to match on`), + }) as unknown as z.ZodType<{ key: string; value?: z.infer }>, + apply: (resource, { key, value }) => { + const foundValue = get(resource.input, key); + + if (validateProperty && !valueSchema.safeParse(foundValue).success) { + return false; + } + if (value !== undefined) { + if (valueSchema.safeParse(value).success) { + return value === foundValue; + } + return false; + } + + return foundValue !== undefined; + }, + toQuery: () => ({}), + }); +} + export const scaffolderTemplateRules = { hasTag }; +export const scaffolderActionRules = { + hasActionId, + hasBooleanProperty, + hasNumberProperty, + hasStringProperty, +}; diff --git a/plugins/scaffolder-common/alpha-api-report.md b/plugins/scaffolder-common/alpha-api-report.md index e89999ad85..d6e746026a 100644 --- a/plugins/scaffolder-common/alpha-api-report.md +++ b/plugins/scaffolder-common/alpha-api-report.md @@ -5,11 +5,20 @@ ```ts import { ResourcePermission } from '@backstage/plugin-permission-common'; +// @alpha +export const actionExecutePermission: ResourcePermission<'scaffolder-action'>; + +// @alpha +export const RESOURCE_TYPE_SCAFFOLDER_ACTION = 'scaffolder-action'; + // @alpha export const RESOURCE_TYPE_SCAFFOLDER_TEMPLATE = 'scaffolder-template'; // @alpha -export const scaffolderPermissions: ResourcePermission<'scaffolder-template'>[]; +export const scaffolderPermissions: ( + | ResourcePermission<'scaffolder-action'> + | ResourcePermission<'scaffolder-template'> +)[]; // @alpha export const templateParameterReadPermission: ResourcePermission<'scaffolder-template'>; diff --git a/plugins/scaffolder-common/src/permissions.ts b/plugins/scaffolder-common/src/permissions.ts index 858430eaf3..f06d7984f2 100644 --- a/plugins/scaffolder-common/src/permissions.ts +++ b/plugins/scaffolder-common/src/permissions.ts @@ -23,6 +23,25 @@ import { createPermission } from '@backstage/plugin-permission-common'; */ export const RESOURCE_TYPE_SCAFFOLDER_TEMPLATE = 'scaffolder-template'; +/** + * Permission resource type which corresponds to a scaffolder action. + * + * @alpha + */ +export const RESOURCE_TYPE_SCAFFOLDER_ACTION = 'scaffolder-action'; + +/** + * This permission is used to authorize actions that involve executing + * an action from a template. + * + * @alpha + */ +export const actionExecutePermission = createPermission({ + name: 'scaffolder.action.execute', + attributes: {}, + resourceType: RESOURCE_TYPE_SCAFFOLDER_ACTION, +}); + /** * This permission is used to authorize actions that involve reading * one or more parameters from a template. @@ -64,6 +83,7 @@ export const templateStepReadPermission = createPermission({ * @alpha */ export const scaffolderPermissions = [ + actionExecutePermission, templateParameterReadPermission, templateStepReadPermission, ];