Merge pull request #17241 from backstage/scaffolder-action-permissions

Scaffolder: permissioning actions
This commit is contained in:
Johan Haals
2023-04-18 09:55:56 +02:00
committed by GitHub
20 changed files with 770 additions and 42 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/create-app': minor
---
Renamed `permissionApi` to `permissions` when passed as dependency of the scaffolder-backend plugin
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend': minor
---
Renamed permissionApi router option to permissions
+12
View File
@@ -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`
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-common': patch
---
Added permissions for authorizing actions
+1 -1
View File
@@ -34,6 +34,6 @@ export default async function createPlugin(
reader: env.reader,
identity: env.identity,
scheduler: env.scheduler,
permissionApi: env.permissions,
permissions: env.permissions,
});
}
@@ -17,6 +17,6 @@ export default async function createPlugin(
reader: env.reader,
catalogClient,
identity: env.identity,
permissionApi: env.permissions,
permissions: env.permissions,
});
}
+65 -7
View File
@@ -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)
```
+3 -2
View File
@@ -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,
};
+10 -1
View File
@@ -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,
];