scaffolder: perform actions authorization in the runners

Signed-off-by: Vincenzo Scamporlino <vincenzos@spotify.com>
This commit is contained in:
Vincenzo Scamporlino
2023-02-17 10:18:45 +01:00
parent 85ed7d94ef
commit faeb730c9f
7 changed files with 112 additions and 25 deletions
+7 -2
View File
@@ -57,10 +57,15 @@ class ExamplePermissionPolicy implements PermissionPolicy {
if (isPermission(request.permission, actionExecutePermission)) {
return createScaffolderActionConditionalDecision(
request.permission,
scaffolderActionConditions.matchesInput({
scaffolderActionConditions.hasInputProperty({
action: 'debug:log',
schema: inputSchema,
key: 'message',
value: 'Test',
}),
// scaffolderActionConditions.matchesInput({
// action: 'debug:log',
// schema: inputSchema,
// }),
);
}
@@ -35,6 +35,7 @@ import {
createTemplateAction,
TaskSecrets,
} from '@backstage/plugin-scaffolder-node';
import { PermissionEvaluator } from '@backstage/plugin-permission-common';
interface DryRunInput {
spec: TaskSpec;
@@ -53,6 +54,7 @@ export type TemplateTesterCreateOptions = {
logger: Logger;
integrations: ScmIntegrations;
actionRegistry: TemplateActionRegistry;
permissionApi: PermissionEvaluator;
workingDirectory: string;
additionalTemplateFilters?: Record<string, TemplateFilter>;
additionalTemplateGlobals?: Record<string, TemplateGlobal>;
@@ -42,10 +42,15 @@ 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 { PermissionEvaluator } from '@backstage/plugin-permission-common';
import { scaffolderActionRules } from '../../service/rules';
import { actionExecutePermission } from '@backstage/plugin-scaffolder-common/alpha';
type NunjucksWorkflowRunnerOptions = {
workingDirectory: string;
@@ -54,6 +59,7 @@ type NunjucksWorkflowRunnerOptions = {
logger: winston.Logger;
additionalTemplateFilters?: Record<string, TemplateFilter>;
additionalTemplateGlobals?: Record<string, TemplateGlobal>;
permissionApi: PermissionEvaluator;
};
type TemplateContext = {
@@ -103,6 +109,10 @@ const createStepLogger = ({
return { taskLogger, streamLogger };
};
const isActionAuthorized = createConditionAuthorizer(
Object.values(scaffolderActionRules),
);
export class NunjucksWorkflowRunner implements WorkflowRunner {
private readonly defaultTemplateFilters: Record<string, TemplateFilter>;
constructor(private readonly options: NunjucksWorkflowRunnerOptions) {
@@ -282,6 +292,23 @@ export class NunjucksWorkflowRunner implements WorkflowRunner {
}
}
const [decision] = await this.options.permissionApi.authorizeConditional(
[{ permission: actionExecutePermission }],
{ token: task.secrets?.backstageToken },
);
if (!isActionAuthorized(decision, { action: action.id, input })) {
throw new InputError(
`Unauthorized action: ${
action.id
}. The input is not allowed. Input: ${JSON.stringify(
input,
null,
2,
)}`,
);
}
const tmpDirs = new Array<string>();
const stepOutput: { [outputName: string]: JsonValue } = {};
@@ -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
*
@@ -30,6 +30,7 @@ import { TemplateFilter, TemplateGlobal } from '../../lib';
*/
export type TaskWorkerOptions = {
taskBroker: TaskBroker;
permissionApi: PermissionEvaluator;
runners: {
workflowRunner: WorkflowRunner;
};
@@ -43,6 +44,7 @@ export type TaskWorkerOptions = {
*/
export type CreateWorkerOptions = {
taskBroker: TaskBroker;
permissionApi: PermissionEvaluator;
actionRegistry: TemplateActionRegistry;
integrations: ScmIntegrations;
workingDirectory: string;
@@ -86,6 +88,7 @@ export class TaskWorker {
additionalTemplateFilters,
concurrentTasksLimit = 10, // from 1 to Infinity
additionalTemplateGlobals,
permissionApi,
} = options;
const workflowRunner = new NunjucksWorkflowRunner({
@@ -95,12 +98,14 @@ export class TaskWorker {
workingDirectory,
additionalTemplateFilters,
additionalTemplateGlobals,
permissionApi,
});
return new TaskWorker({
taskBroker: taskBroker,
runners: { workflowRunner },
concurrentTasksLimit,
permissionApi,
});
}
@@ -263,6 +263,7 @@ export async function createRouter(
additionalTemplateFilters,
additionalTemplateGlobals,
concurrentTasksLimit,
permissionApi,
});
workers.push(worker);
}
@@ -288,6 +289,7 @@ export async function createRouter(
workingDirectory,
additionalTemplateFilters,
additionalTemplateGlobals,
permissionApi,
});
const templateRules: TemplatePermissionRuleInput[] = Object.values(
@@ -381,16 +383,27 @@ export async function createRouter(
}
}
const taskSpec = await authorizeActions(
{
parameters: values,
template,
templateRef: { kind, name, namespace },
user: userEntity as UserEntity,
userEntityRef,
const taskSpec: TaskSpec = {
apiVersion: template.apiVersion,
steps: template.spec.steps.map((step, index) => ({
...step,
id: step.id ?? `step-${index + 1}`,
name: step.name ?? step.action,
})),
output: template.spec.output ?? {},
parameters: values,
user: {
entity: userEntity as UserEntity,
ref: userEntityRef,
},
{ token },
);
templateInfo: {
entityRef: stringifyEntityRef({ kind, name, namespace }),
baseUrl: getEntityBaseUrl(template),
entity: {
metadata: template.metadata,
},
},
};
const result = await taskBroker.dispatch({
spec: taskSpec,
@@ -28,6 +28,7 @@ import {
import { z } from 'zod';
import { JsonObject } from '@backstage/types';
import { get } from 'lodash';
export const createTemplatePermissionRule = makeCreatePermissionRule<
TemplateEntityStepV1beta3 | TemplateParametersV1beta3,
@@ -91,6 +92,36 @@ export const matchesInput = createActionPermissionRule({
toQuery: () => ({}),
});
export const hasInputProperty = createActionPermissionRule({
name: 'HAS_INPUT',
resourceType: RESOURCE_TYPE_SCAFFOLDER_ACTION,
description: `Matches the key and value of the input of an action`,
paramsSchema: z.object({
action: z.string().describe('Name of the actionId to match on'),
key: z.string().describe('Name of the property to match on'),
value: z.string().describe('Value of the property to match on').optional(),
}),
apply: (resource, { action, key, value }) => {
if (resource.action !== action) {
return true;
}
const foundValue = get(resource.input, key);
if (Array.isArray(foundValue)) {
if (value !== undefined) {
return foundValue.includes(value);
}
return foundValue.length > 0;
}
if (value !== undefined) {
return value === foundValue;
}
return !!foundValue;
},
toQuery: () => ({}),
});
export const hasTag = createTemplatePermissionRule({
name: 'HAS_TAG',
resourceType: RESOURCE_TYPE_SCAFFOLDER_TEMPLATE,
@@ -105,4 +136,4 @@ export const hasTag = createTemplatePermissionRule({
});
export const scaffolderTemplateRules = { hasTag };
export const scaffolderActionRules = { matchesInput };
export const scaffolderActionRules = { matchesInput, hasInputProperty };
+16 -12
View File
@@ -2,29 +2,33 @@ apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
name: my-custom-template
spec:
type: service
parameters:
- title: Provide some simple information
metadata:
tags:
- bang
- title: Basic information
properties:
description:
title: Description
type: string
metadata:
tags:
- bar
- title: Extra information
backstage:accessControl:
tags:
- foo
properties:
name:
title: Your name
type: string
steps:
- id: step-one
name: First log
action: debug:log
input:
message: hello
metadata:
tags:
- foo
message: Test
- id: step-two
name: Second log
action: debug:log
input:
message: world
message: Hello ${{ parameters.name }}
backstage:accessControl:
tags:
- foo