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/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index 336f5d1286..ba72d9e071 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/index.ts b/plugins/scaffolder-backend/src/index.ts index ba2c25f80a..3dc91f18b9 100644 --- a/plugins/scaffolder-backend/src/index.ts +++ b/plugins/scaffolder-backend/src/index.ts @@ -21,6 +21,7 @@ */ export * from './scaffolder'; +export * from './service/conditionExports'; export * from './service/router'; export * from './lib'; export * from './processor'; 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/router.test.ts b/plugins/scaffolder-backend/src/service/router.test.ts index 5e3c1c7278..6ebe988e9f 100644 --- a/plugins/scaffolder-backend/src/service/router.test.ts +++ b/plugins/scaffolder-backend/src/service/router.test.ts @@ -44,6 +44,7 @@ import { IdentityApiGetIdentityRequest, BackstageIdentityResponse, } from '@backstage/plugin-auth-node'; +import { PermissionEvaluator } from '@backstage/plugin-permission-common'; const mockAccess = jest.fn(); @@ -139,6 +140,11 @@ describe('createRouter', () => { }); taskBroker = new StorageTaskBroker(databaseTaskStore, logger); + const permissionApi: PermissionEvaluator = { + authorize: jest.fn(), + authorizeConditional: jest.fn(), + }; + jest.spyOn(taskBroker, 'dispatch'); jest.spyOn(taskBroker, 'get'); jest.spyOn(taskBroker, 'list'); @@ -152,6 +158,7 @@ describe('createRouter', () => { catalogClient, reader: mockUrlReader, taskBroker, + permissionApi, }); app = express().use(router); @@ -744,6 +751,11 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{ }, ); + const permissionApi: PermissionEvaluator = { + authorize: jest.fn(), + authorizeConditional: jest.fn(), + }; + const router = await createRouter({ logger: logger, config: new ConfigReader({}), @@ -752,6 +764,7 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{ reader: mockUrlReader, taskBroker, identity: { getIdentity }, + permissionApi, }); app = express().use(router); diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index b2be3e2749..9fe7a98a8a 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -18,19 +18,26 @@ import { PluginDatabaseManager, UrlReader } from '@backstage/backend-common'; import { PluginTaskScheduler } from '@backstage/backend-tasks'; import { CatalogApi } from '@backstage/catalog-client'; import { + CompoundEntityRef, Entity, parseEntityRef, stringifyEntityRef, UserEntity, } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; -import { InputError, NotFoundError, stringifyError } from '@backstage/errors'; +import { + InputError, + NotAllowedError, + NotFoundError, + stringifyError, +} from '@backstage/errors'; import { ScmIntegrations } from '@backstage/integration'; import { JsonObject, JsonValue } from '@backstage/types'; import { TaskSpec, TemplateEntityV1beta3, templateEntityV1beta3Validator, + templateSchemaExecutePermission, } from '@backstage/plugin-scaffolder-common'; import express from 'express'; import Router from 'express-promise-router'; @@ -47,12 +54,27 @@ import { } from '../scaffolder'; import { createDryRunner } from '../scaffolder/dryrun'; import { StorageTaskBroker } from '../scaffolder/tasks/StorageTaskBroker'; -import { findTemplate, getEntityBaseUrl, getWorkingDirectory } from './helpers'; +import { + findTemplate, + getEntityBaseUrl, + getWorkingDirectory, + TemplateTransform, +} from './helpers'; import { IdentityApi, IdentityApiGetIdentityRequest, } from '@backstage/plugin-auth-node'; import { TemplateAction } from '@backstage/plugin-scaffolder-node'; +import { + AuthorizeResult, + PermissionEvaluator, +} from '@backstage/plugin-permission-common'; +import { + ConditionTransformer, + createConditionTransformer, + isAndCriteria, +} from '@backstage/plugin-permission-node'; +import { scaffolderRules } from './rules'; /** * RouterOptions @@ -80,6 +102,7 @@ export interface RouterOptions { taskBroker?: TaskBroker; additionalTemplateFilters?: Record; additionalTemplateGlobals?: Record; + permissionApi: PermissionEvaluator; identity?: IdentityApi; } @@ -174,6 +197,7 @@ export async function createRouter( scheduler, additionalTemplateFilters, additionalTemplateGlobals, + permissionApi, } = options; const logger = parentLogger.child({ plugin: 'scaffolder' }); @@ -249,41 +273,31 @@ export async function createRouter( additionalTemplateGlobals, }); + const transformConditions: ConditionTransformer = + createConditionTransformer(Object.values(scaffolderRules)); + 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,19 +335,10 @@ 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); @@ -567,5 +572,48 @@ export async function createRouter( app.set('logger', logger); app.use('/', router); + async function authorizeTemplate( + entityRef: CompoundEntityRef, + token: string | undefined, + ) { + let template = await findTemplate({ + catalogApi: catalogClient, + entityRef, + token, + }); + if (!isSupportedTemplate(template)) { + throw new InputError( + `Unsupported apiVersion field in schema entity, ${ + (template as Entity).apiVersion + }`, + ); + } + const authorizeDecision = ( + await permissionApi.authorizeConditional( + [{ permission: templateSchemaExecutePermission }], + { + token, + }, + ) + )[0]; + + if (authorizeDecision.result === AuthorizeResult.DENY) { + throw new NotAllowedError( + `Not allowed to execute template ${entityRef.kind}:${entityRef.namespace}/${entityRef.name}`, + ); + } + if (authorizeDecision.result === AuthorizeResult.CONDITIONAL) { + const scaffolderFilter = transformConditions( + authorizeDecision.conditions, + ); + if (isAndCriteria(scaffolderFilter)) { + template = scaffolderFilter.allOf.reduce( + (acc, filter) => (filter as TemplateTransform)(acc), + template, + ); + } + } + return template; + } return app; }