Merge pull request #17241 from backstage/scaffolder-action-permissions
Scaffolder: permissioning actions
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/create-app': minor
|
||||
---
|
||||
|
||||
Renamed `permissionApi` to `permissions` when passed as dependency of the scaffolder-backend plugin
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-backend': minor
|
||||
---
|
||||
|
||||
Renamed permissionApi router option to permissions
|
||||
@@ -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`
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-common': patch
|
||||
---
|
||||
|
||||
Added permissions for authorizing actions
|
||||
@@ -34,6 +34,6 @@ export default async function createPlugin(
|
||||
reader: env.reader,
|
||||
identity: env.identity,
|
||||
scheduler: env.scheduler,
|
||||
permissionApi: env.permissions,
|
||||
permissions: env.permissions,
|
||||
});
|
||||
}
|
||||
|
||||
+1
-1
@@ -17,6 +17,6 @@ export default async function createPlugin(
|
||||
reader: env.reader,
|
||||
catalogClient,
|
||||
identity: env.identity,
|
||||
permissionApi: env.permissions,
|
||||
permissions: env.permissions,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<PermissionCondition<'scaffolder-action'>>,
|
||||
) => ConditionalPolicyDecision;
|
||||
|
||||
// @alpha
|
||||
export const createScaffolderConditionalDecision: (
|
||||
export const createScaffolderTemplateConditionalDecision: (
|
||||
permission: ResourcePermission<'scaffolder-template'>,
|
||||
conditions: PermissionCriteria<PermissionCondition<'scaffolder-template'>>,
|
||||
) => 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<string, TemplateGlobal>;
|
||||
};
|
||||
|
||||
// @alpha
|
||||
export const scaffolderTemplateConditions: Conditions<{
|
||||
hasTag: PermissionRule<
|
||||
TemplateParametersV1beta3 | TemplateEntityStepV1beta3,
|
||||
{},
|
||||
'scaffolder-template',
|
||||
{
|
||||
tag: string;
|
||||
}
|
||||
>;
|
||||
}>;
|
||||
|
||||
// (No @packageDocumentation comment for this package)
|
||||
```
|
||||
|
||||
@@ -651,6 +651,7 @@ export type CreateWorkerOptions = {
|
||||
additionalTemplateFilters?: Record<string, TemplateFilter>;
|
||||
concurrentTasksLimit?: number;
|
||||
additionalTemplateGlobals?: Record<string, TemplateGlobal>;
|
||||
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;
|
||||
|
||||
@@ -132,7 +132,7 @@ export const scaffolderPlugin = createBackendPlugin(
|
||||
taskWorkers,
|
||||
additionalTemplateFilters,
|
||||
additionalTemplateGlobals,
|
||||
permissionApi: permissions,
|
||||
permissions,
|
||||
});
|
||||
httpRouter.use(router);
|
||||
},
|
||||
|
||||
@@ -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<string, TemplateFilter>;
|
||||
additionalTemplateGlobals?: Record<string, TemplateGlobal>;
|
||||
permissions?: PermissionEvaluator;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<PermissionEvaluator> = {
|
||||
authorizeConditional: jest.fn(),
|
||||
} as unknown as jest.Mocked<PermissionEvaluator>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, TemplateFilter>;
|
||||
additionalTemplateGlobals?: Record<string, TemplateGlobal>;
|
||||
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<string, TemplateFilter>;
|
||||
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<string>();
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, TemplateGlobal>;
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -112,7 +112,7 @@ export interface RouterOptions {
|
||||
taskBroker?: TaskBroker;
|
||||
additionalTemplateFilters?: Record<string, TemplateFilter>;
|
||||
additionalTemplateGlobals?: Record<string, TemplateGlobal>;
|
||||
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 },
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<Schema extends z.ZodType<JsonPrimitive>>({
|
||||
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<Schema> }>,
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -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'>;
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user