scaffolder: perform actions authorization in the runners
Signed-off-by: Vincenzo Scamporlino <vincenzos@spotify.com>
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user