Merge pull request #15798 from backstage/prfc/scaffolder-permissions
PRFC: Scaffolder permissions
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-common': patch
|
||||
---
|
||||
|
||||
Added permissions for authorizing parameters and steps
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/create-app': patch
|
||||
---
|
||||
|
||||
Add `permissionApi` as dependency of the scaffolder-backend plugin
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-permission-node': patch
|
||||
---
|
||||
|
||||
Added createConditionAuthorizer utility function, which takes some permission conditions and returns a function that returns a definitive authorization result given a decision and a resource.
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-backend': minor
|
||||
---
|
||||
|
||||
Added the possibility to authorize parameters and steps of a template
|
||||
|
||||
The scaffolder plugin is now integrated with the permission framework.
|
||||
It is possible to toggle parameters or actions within templates by marking each section with specific `tags`, inside a `backstage:permissions` property under each parameter or action. Each parameter or action can then be permissioned by using a conditional decision containing the `scaffolderTemplateRules.hasTag` rule.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-common': patch
|
||||
---
|
||||
|
||||
Define optional `backstage:permissions` property to parameters and steps used to authorize part of the template using the permission framework
|
||||
@@ -34,5 +34,6 @@ export default async function createPlugin(
|
||||
reader: env.reader,
|
||||
identity: env.identity,
|
||||
scheduler: env.scheduler,
|
||||
permissionApi: env.permissions,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -17,5 +17,6 @@ export default async function createPlugin(
|
||||
reader: env.reader,
|
||||
catalogClient,
|
||||
identity: env.identity,
|
||||
permissionApi: env.permissions,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -73,6 +73,11 @@ export type ConditionTransformer<TQuery> = (
|
||||
conditions: PermissionCriteria<PermissionCondition>,
|
||||
) => PermissionCriteria<TQuery>;
|
||||
|
||||
// @public
|
||||
export const createConditionAuthorizer: <TResource, TQuery>(
|
||||
rules: PermissionRule<TResource, TQuery, string, PermissionRuleParams>[],
|
||||
) => (decision: PolicyDecision, resource: TResource | undefined) => boolean;
|
||||
|
||||
// @public
|
||||
export const createConditionExports: <
|
||||
TResourceType extends string,
|
||||
|
||||
@@ -25,6 +25,7 @@ import { z } from 'zod';
|
||||
import {
|
||||
createPermissionIntegrationRouter,
|
||||
CreatePermissionIntegrationRouterResourceOptions,
|
||||
createConditionAuthorizer,
|
||||
} from './createPermissionIntegrationRouter';
|
||||
import { createPermissionRule } from './createPermissionRule';
|
||||
|
||||
@@ -33,6 +34,9 @@ const testPermission: Permission = createPermission({
|
||||
attributes: {},
|
||||
});
|
||||
|
||||
const mockTestRule1Apply = jest
|
||||
.fn()
|
||||
.mockImplementation((_resource: any, _params) => true);
|
||||
const testRule1 = createPermissionRule({
|
||||
name: 'test-rule-1',
|
||||
description: 'Test rule 1',
|
||||
@@ -41,15 +45,18 @@ const testRule1 = createPermissionRule({
|
||||
foo: z.string(),
|
||||
bar: z.number().describe('bar'),
|
||||
}),
|
||||
apply: (_resource: any, _params) => true,
|
||||
apply: mockTestRule1Apply,
|
||||
toQuery: _params => ({}),
|
||||
});
|
||||
|
||||
const mockTestRule2Apply = jest
|
||||
.fn()
|
||||
.mockImplementation((_resource: any) => false);
|
||||
const testRule2 = createPermissionRule({
|
||||
name: 'test-rule-2',
|
||||
description: 'Test rule 2',
|
||||
resourceType: 'test-resource',
|
||||
apply: (_resource: any) => false,
|
||||
apply: mockTestRule2Apply,
|
||||
toQuery: () => ({}),
|
||||
});
|
||||
|
||||
@@ -625,3 +632,72 @@ describe('createPermissionIntegrationRouter', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createConditionAuthorizer', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return true in case of allowed decision', () => {
|
||||
const isAuthorized = createConditionAuthorizer([testRule1, testRule2]);
|
||||
expect(
|
||||
isAuthorized(
|
||||
{
|
||||
result: AuthorizeResult.ALLOW,
|
||||
},
|
||||
{},
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
expect(mockTestRule1Apply).not.toHaveBeenCalled();
|
||||
expect(mockTestRule2Apply).not.toHaveBeenCalled();
|
||||
});
|
||||
it('should return false in case of denied decision', () => {
|
||||
const isAuthorized = createConditionAuthorizer([testRule1, testRule2]);
|
||||
expect(
|
||||
isAuthorized(
|
||||
{
|
||||
result: AuthorizeResult.DENY,
|
||||
},
|
||||
{},
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
expect(mockTestRule1Apply).not.toHaveBeenCalled();
|
||||
expect(mockTestRule2Apply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should apply conditions to a resource in case of conditional decision', () => {
|
||||
const isAuthorized = createConditionAuthorizer([testRule1, testRule2]);
|
||||
expect(
|
||||
isAuthorized(
|
||||
{
|
||||
pluginId: 'plugin',
|
||||
resourceType: 'test-resource',
|
||||
result: AuthorizeResult.CONDITIONAL,
|
||||
conditions: {
|
||||
allOf: [
|
||||
{
|
||||
rule: 'test-rule-1',
|
||||
resourceType: 'test-resource',
|
||||
params: {
|
||||
foo: 'a',
|
||||
bar: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
rule: 'test-rule-2',
|
||||
resourceType: 'test-resource',
|
||||
params: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{},
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
expect(mockTestRule1Apply).toHaveBeenCalledTimes(1);
|
||||
expect(mockTestRule2Apply).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
Permission,
|
||||
PermissionCondition,
|
||||
PermissionCriteria,
|
||||
PolicyDecision,
|
||||
} from '@backstage/plugin-permission-common';
|
||||
import { PermissionRule } from '../types';
|
||||
import {
|
||||
@@ -160,6 +161,30 @@ const applyConditions = <TResourceType extends string, TResource>(
|
||||
return rule.apply(resource, criteria.params ?? {});
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
* Takes some permission conditions and returns a definitive authorization result
|
||||
* on the resource to which they apply.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const createConditionAuthorizer = <TResource, TQuery>(
|
||||
rules: PermissionRule<TResource, TQuery, string>[],
|
||||
) => {
|
||||
const getRule = createGetRule(rules);
|
||||
|
||||
return (
|
||||
decision: PolicyDecision,
|
||||
resource: TResource | undefined,
|
||||
): boolean => {
|
||||
if (decision.result === AuthorizeResult.CONDITIONAL) {
|
||||
return applyConditions(decision.conditions, resource, getRule);
|
||||
}
|
||||
|
||||
return decision.result === AuthorizeResult.ALLOW;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Options for creating a permission integration router specific
|
||||
* for a particular resource type.
|
||||
|
||||
@@ -4,14 +4,43 @@
|
||||
|
||||
```ts
|
||||
import { BackendFeature } from '@backstage/backend-plugin-api';
|
||||
import { ConditionalPolicyDecision } from '@backstage/plugin-permission-common';
|
||||
import { Conditions } from '@backstage/plugin-permission-node';
|
||||
import { PermissionCondition } from '@backstage/plugin-permission-common';
|
||||
import { PermissionCriteria } from '@backstage/plugin-permission-common';
|
||||
import { PermissionRule } from '@backstage/plugin-permission-node';
|
||||
import { PermissionRuleParams } from '@backstage/plugin-permission-common';
|
||||
import { ResourcePermission } from '@backstage/plugin-permission-common';
|
||||
import { TaskBroker } from '@backstage/plugin-scaffolder-backend';
|
||||
import { TemplateAction } from '@backstage/plugin-scaffolder-node';
|
||||
import { TemplateEntityStepV1beta3 } from '@backstage/plugin-scaffolder-common';
|
||||
import { TemplateFilter } from '@backstage/plugin-scaffolder-backend';
|
||||
import { TemplateGlobal } from '@backstage/plugin-scaffolder-backend';
|
||||
import { TemplateParametersV1beta3 } from '@backstage/plugin-scaffolder-common';
|
||||
|
||||
// @alpha
|
||||
export const catalogModuleTemplateKind: () => BackendFeature;
|
||||
|
||||
// @alpha
|
||||
export const createScaffolderConditionalDecision: (
|
||||
permission: ResourcePermission<'scaffolder-template'>,
|
||||
conditions: PermissionCriteria<
|
||||
PermissionCondition<'scaffolder-template', PermissionRuleParams>
|
||||
>,
|
||||
) => ConditionalPolicyDecision;
|
||||
|
||||
// @alpha
|
||||
export const scaffolderConditions: Conditions<{
|
||||
hasTag: PermissionRule<
|
||||
TemplateParametersV1beta3 | TemplateEntityStepV1beta3,
|
||||
{},
|
||||
'scaffolder-template',
|
||||
{
|
||||
tag: string;
|
||||
}
|
||||
>;
|
||||
}>;
|
||||
|
||||
// @alpha
|
||||
export const scaffolderPlugin: (
|
||||
options: ScaffolderPluginOptions,
|
||||
|
||||
@@ -24,8 +24,12 @@ import { LocationSpec } from '@backstage/plugin-catalog-common';
|
||||
import { Logger } from 'winston';
|
||||
import { Observable } from '@backstage/types';
|
||||
import { Octokit } from 'octokit';
|
||||
import { PermissionEvaluator } from '@backstage/plugin-permission-common';
|
||||
import { PermissionRule } from '@backstage/plugin-permission-node';
|
||||
import { PermissionRuleParams } from '@backstage/plugin-permission-common';
|
||||
import { PluginDatabaseManager } from '@backstage/backend-common';
|
||||
import { PluginTaskScheduler } from '@backstage/backend-tasks';
|
||||
import { RESOURCE_TYPE_SCAFFOLDER_TEMPLATE } from '@backstage/plugin-scaffolder-common/alpha';
|
||||
import { Schema } from 'jsonschema';
|
||||
import { ScmIntegrationRegistry } from '@backstage/integration';
|
||||
import { ScmIntegrations } from '@backstage/integration';
|
||||
@@ -35,6 +39,8 @@ import { TaskSpec } from '@backstage/plugin-scaffolder-common';
|
||||
import { TaskSpecV1beta3 } from '@backstage/plugin-scaffolder-common';
|
||||
import { TemplateAction as TemplateAction_2 } from '@backstage/plugin-scaffolder-node';
|
||||
import { TemplateActionOptions } from '@backstage/plugin-scaffolder-node';
|
||||
import { TemplateEntityStepV1beta3 } from '@backstage/plugin-scaffolder-common';
|
||||
import { TemplateParametersV1beta3 } from '@backstage/plugin-scaffolder-common';
|
||||
import { UrlReader } from '@backstage/backend-common';
|
||||
import { Writable } from 'stream';
|
||||
import { ZodType } from 'zod';
|
||||
@@ -665,6 +671,10 @@ export interface RouterOptions {
|
||||
// (undocumented)
|
||||
logger: Logger;
|
||||
// (undocumented)
|
||||
permissionApi?: PermissionEvaluator;
|
||||
// (undocumented)
|
||||
permissionRules?: TemplatePermissionRuleInput[];
|
||||
// (undocumented)
|
||||
reader: UrlReader;
|
||||
// (undocumented)
|
||||
scheduler?: PluginTaskScheduler;
|
||||
@@ -920,4 +930,14 @@ export type TemplateFilter = (...args: JsonValue[]) => JsonValue | undefined;
|
||||
export type TemplateGlobal =
|
||||
| ((...args: JsonValue[]) => JsonValue | undefined)
|
||||
| JsonValue;
|
||||
|
||||
// @public (undocumented)
|
||||
export type TemplatePermissionRuleInput<
|
||||
TParams extends PermissionRuleParams = PermissionRuleParams,
|
||||
> = PermissionRule<
|
||||
TemplateEntityStepV1beta3 | TemplateParametersV1beta3,
|
||||
{},
|
||||
typeof RESOURCE_TYPE_SCAFFOLDER_TEMPLATE,
|
||||
TParams
|
||||
>;
|
||||
```
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -94,6 +94,7 @@ export const scaffolderPlugin = createBackendPlugin(
|
||||
database,
|
||||
httpRouter,
|
||||
catalogClient,
|
||||
permissions,
|
||||
}) {
|
||||
const {
|
||||
additionalTemplateFilters,
|
||||
@@ -131,6 +132,7 @@ export const scaffolderPlugin = createBackendPlugin(
|
||||
taskWorkers,
|
||||
additionalTemplateFilters,
|
||||
additionalTemplateGlobals,
|
||||
permissionApi: permissions,
|
||||
});
|
||||
httpRouter.use(router);
|
||||
},
|
||||
|
||||
@@ -15,5 +15,6 @@
|
||||
*/
|
||||
|
||||
export * from './modules';
|
||||
export * from './service';
|
||||
export { scaffolderPlugin } from './ScaffolderPlugin';
|
||||
export type { ScaffolderPluginOptions } from './ScaffolderPlugin';
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { RESOURCE_TYPE_SCAFFOLDER_TEMPLATE } from '@backstage/plugin-scaffolder-common/alpha';
|
||||
import { createConditionExports } from '@backstage/plugin-permission-node';
|
||||
import { scaffolderTemplateRules } from './rules';
|
||||
|
||||
const { conditions, createConditionalDecision } = 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;
|
||||
|
||||
/**
|
||||
* `createScaffolderConditionalDecision` 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
|
||||
* `isPermission` helper methods to narrow the type of the permission passed to
|
||||
* the handle method as shown below.
|
||||
*
|
||||
* ```
|
||||
* // MyAuthorizationPolicy.ts
|
||||
* ...
|
||||
* import { createScaffolderPolicyDecision } from '@backstage/plugin-scaffolder-backend';
|
||||
* import { RESOURCE_TYPE_SCAFFOLDER_TEMPLATE } from '@backstage/plugin-scaffolder-common';
|
||||
*
|
||||
* class MyAuthorizationPolicy implements PermissionPolicy {
|
||||
* async handle(request, user) {
|
||||
* ...
|
||||
*
|
||||
* if (isResourcePermission(request.permission, RESOURCE_TYPE_SCAFFOLDER_TEMPLATE)) {
|
||||
* return createScaffolderConditionalDecision(
|
||||
* request.permission,
|
||||
* { anyOf: [...insert conditions here...] }
|
||||
* );
|
||||
* }
|
||||
*
|
||||
* ...
|
||||
* }
|
||||
*
|
||||
* ```
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export const createScaffolderConditionalDecision = createConditionalDecision;
|
||||
@@ -107,3 +107,7 @@ export async function findTemplate(options: {
|
||||
|
||||
return template as TemplateEntityV1beta3;
|
||||
}
|
||||
|
||||
export type TemplateTransform = (
|
||||
template: TemplateEntityV1beta3,
|
||||
) => TemplateEntityV1beta3;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export * from './conditionExports';
|
||||
@@ -44,6 +44,10 @@ import {
|
||||
IdentityApiGetIdentityRequest,
|
||||
BackstageIdentityResponse,
|
||||
} from '@backstage/plugin-auth-node';
|
||||
import {
|
||||
AuthorizeResult,
|
||||
PermissionEvaluator,
|
||||
} from '@backstage/plugin-permission-common';
|
||||
|
||||
const mockAccess = jest.fn();
|
||||
|
||||
@@ -85,8 +89,12 @@ describe('createRouter', () => {
|
||||
let loggerSpy: jest.SpyInstance;
|
||||
let taskBroker: TaskBroker;
|
||||
const catalogClient = { getEntityByRef: jest.fn() } as unknown as CatalogApi;
|
||||
const permissionApi = {
|
||||
authorize: jest.fn(),
|
||||
authorizeConditional: jest.fn(),
|
||||
} as unknown as PermissionEvaluator;
|
||||
|
||||
const mockTemplate: TemplateEntityV1beta3 = {
|
||||
const getMockTemplate = (): TemplateEntityV1beta3 => ({
|
||||
apiVersion: 'scaffolder.backstage.io/v1beta3',
|
||||
kind: 'Template',
|
||||
metadata: {
|
||||
@@ -101,19 +109,54 @@ describe('createRouter', () => {
|
||||
spec: {
|
||||
owner: 'web@example.com',
|
||||
type: 'website',
|
||||
steps: [],
|
||||
parameters: {
|
||||
type: 'object',
|
||||
required: ['required'],
|
||||
properties: {
|
||||
required: {
|
||||
type: 'string',
|
||||
description: 'Required parameter',
|
||||
steps: [
|
||||
{
|
||||
id: 'step-one',
|
||||
name: 'First log',
|
||||
action: 'debug:log',
|
||||
input: {
|
||||
message: 'hello',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'step-two',
|
||||
name: 'Second log',
|
||||
action: 'debug:log',
|
||||
input: {
|
||||
message: 'world',
|
||||
},
|
||||
'backstage:permissions': {
|
||||
tags: ['steps-tag'],
|
||||
},
|
||||
},
|
||||
],
|
||||
parameters: [
|
||||
{
|
||||
type: 'object',
|
||||
required: ['requiredParameter1'],
|
||||
properties: {
|
||||
requiredParameter1: {
|
||||
type: 'string',
|
||||
description: 'Required parameter 1',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
required: ['requiredParameter2'],
|
||||
'backstage:permissions': {
|
||||
tags: ['parameters-tag'],
|
||||
},
|
||||
properties: {
|
||||
requiredParameter2: {
|
||||
type: 'string',
|
||||
description: 'Required parameter 2',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const mockUser: UserEntity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
@@ -152,6 +195,7 @@ describe('createRouter', () => {
|
||||
catalogClient,
|
||||
reader: mockUrlReader,
|
||||
taskBroker,
|
||||
permissionApi,
|
||||
});
|
||||
app = express().use(router);
|
||||
|
||||
@@ -160,15 +204,27 @@ describe('createRouter', () => {
|
||||
.mockImplementation(async ref => {
|
||||
const { kind } = parseEntityRef(ref);
|
||||
|
||||
if (kind === 'template') {
|
||||
return mockTemplate;
|
||||
if (kind.toLocaleLowerCase() === 'template') {
|
||||
return getMockTemplate();
|
||||
}
|
||||
|
||||
if (kind === 'user') {
|
||||
if (kind.toLocaleLowerCase() === 'user') {
|
||||
return mockUser;
|
||||
}
|
||||
|
||||
throw new Error(`no mock found for kind: ${kind}`);
|
||||
});
|
||||
|
||||
jest
|
||||
.spyOn(permissionApi, 'authorizeConditional')
|
||||
.mockImplementation(async () => [
|
||||
{
|
||||
result: AuthorizeResult.ALLOW,
|
||||
},
|
||||
{
|
||||
result: AuthorizeResult.ALLOW,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -216,12 +272,13 @@ describe('createRouter', () => {
|
||||
name: 'create-react-app-template',
|
||||
}),
|
||||
values: {
|
||||
required: 'required-value',
|
||||
requiredParameter1: 'required-value-1',
|
||||
requiredParameter2: 'required-value-2',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.body.id).toBe('a-random-id');
|
||||
expect(response.status).toEqual(201);
|
||||
expect(response.body.id).toBe('a-random-id');
|
||||
});
|
||||
|
||||
it('should call the broker with a correct spec', async () => {
|
||||
@@ -229,6 +286,7 @@ describe('createRouter', () => {
|
||||
taskBroker.dispatch as jest.Mocked<TaskBroker>['dispatch'];
|
||||
const mockToken =
|
||||
'blob.eyJzdWIiOiJ1c2VyOmRlZmF1bHQvZ3Vlc3QiLCJuYW1lIjoiSm9obiBEb2UifQ.blob';
|
||||
const mockTemplate = getMockTemplate();
|
||||
|
||||
await request(app)
|
||||
.post('/v2/tasks')
|
||||
@@ -239,7 +297,8 @@ describe('createRouter', () => {
|
||||
name: 'create-react-app-template',
|
||||
}),
|
||||
values: {
|
||||
required: 'required-value',
|
||||
requiredParameter1: 'required-value-1',
|
||||
requiredParameter2: 'required-value-2',
|
||||
},
|
||||
});
|
||||
expect(broker).toHaveBeenCalledWith(
|
||||
@@ -258,7 +317,8 @@ describe('createRouter', () => {
|
||||
})),
|
||||
output: mockTemplate.spec.output ?? {},
|
||||
parameters: {
|
||||
required: 'required-value',
|
||||
requiredParameter1: 'required-value-1',
|
||||
requiredParameter2: 'required-value-2',
|
||||
},
|
||||
user: {
|
||||
entity: mockUser,
|
||||
@@ -284,6 +344,7 @@ describe('createRouter', () => {
|
||||
const broker =
|
||||
taskBroker.dispatch as jest.Mocked<TaskBroker>['dispatch'];
|
||||
const mockToken = 'blob.eyJzdWIiOiIiLCJuYW1lIjoiSm9obiBEb2UifQ.blob';
|
||||
const mockTemplate = getMockTemplate();
|
||||
|
||||
await request(app)
|
||||
.post('/v2/tasks')
|
||||
@@ -294,7 +355,8 @@ describe('createRouter', () => {
|
||||
name: 'create-react-app-template',
|
||||
}),
|
||||
values: {
|
||||
required: 'required-value',
|
||||
requiredParameter1: 'required-value-1',
|
||||
requiredParameter2: 'required-value-2',
|
||||
},
|
||||
});
|
||||
expect(broker).toHaveBeenCalledWith(
|
||||
@@ -313,7 +375,8 @@ describe('createRouter', () => {
|
||||
})),
|
||||
output: mockTemplate.spec.output ?? {},
|
||||
parameters: {
|
||||
required: 'required-value',
|
||||
requiredParameter1: 'required-value-1',
|
||||
requiredParameter2: 'required-value-2',
|
||||
},
|
||||
user: {
|
||||
entity: undefined,
|
||||
@@ -347,7 +410,8 @@ describe('createRouter', () => {
|
||||
name: 'create-react-app-template',
|
||||
}),
|
||||
values: {
|
||||
required: 'required-value',
|
||||
requiredParameter1: 'required-value-1',
|
||||
requiredParameter2: 'required-value-2',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -370,7 +434,8 @@ describe('createRouter', () => {
|
||||
name: 'create-react-app-template',
|
||||
}),
|
||||
values: {
|
||||
required: 'required-value',
|
||||
requiredParameter1: 'required-value-1',
|
||||
requiredParameter2: 'required-value-2',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -393,7 +458,8 @@ describe('createRouter', () => {
|
||||
name: 'create-react-app-template',
|
||||
}),
|
||||
values: {
|
||||
required: 'required-value',
|
||||
requiredParameter1: 'required-value-1',
|
||||
requiredParameter2: 'required-value-2',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -713,6 +779,7 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('providing an identity api', () => {
|
||||
beforeEach(async () => {
|
||||
const logger = getVoidLogger();
|
||||
@@ -752,6 +819,7 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{
|
||||
reader: mockUrlReader,
|
||||
taskBroker,
|
||||
identity: { getIdentity },
|
||||
permissionApi,
|
||||
});
|
||||
app = express().use(router);
|
||||
|
||||
@@ -760,15 +828,26 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{
|
||||
.mockImplementation(async ref => {
|
||||
const { kind } = parseEntityRef(ref);
|
||||
|
||||
if (kind === 'template') {
|
||||
return mockTemplate;
|
||||
if (kind.toLocaleLowerCase() === 'template') {
|
||||
return getMockTemplate();
|
||||
}
|
||||
|
||||
if (kind === 'user') {
|
||||
if (kind.toLocaleLowerCase() === 'user') {
|
||||
return mockUser;
|
||||
}
|
||||
throw new Error(`no mock found for kind: ${kind}`);
|
||||
});
|
||||
|
||||
jest
|
||||
.spyOn(permissionApi, 'authorizeConditional')
|
||||
.mockImplementation(async () => [
|
||||
{
|
||||
result: AuthorizeResult.ALLOW,
|
||||
},
|
||||
{
|
||||
result: AuthorizeResult.ALLOW,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -784,6 +863,125 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /v2/templates/:namespace/:kind/:name/parameter-schema', () => {
|
||||
it('returns the parameter schema', async () => {
|
||||
const response = await request(app)
|
||||
.get(
|
||||
'/v2/templates/default/Template/create-react-app-template/parameter-schema',
|
||||
)
|
||||
.send();
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual({
|
||||
title: 'Create React App Template',
|
||||
description: 'Create a new CRA website project',
|
||||
steps: [
|
||||
{
|
||||
title: 'Please enter the following information',
|
||||
schema: {
|
||||
required: ['requiredParameter1'],
|
||||
type: 'object',
|
||||
properties: {
|
||||
requiredParameter1: {
|
||||
description: 'Required parameter 1',
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Please enter the following information',
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['requiredParameter2'],
|
||||
'backstage:permissions': {
|
||||
tags: ['parameters-tag'],
|
||||
},
|
||||
properties: {
|
||||
requiredParameter2: {
|
||||
type: 'string',
|
||||
description: 'Required parameter 2',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('filters parameters that the user is not authorized to see', async () => {
|
||||
jest
|
||||
.spyOn(permissionApi, 'authorizeConditional')
|
||||
.mockImplementationOnce(async () => [
|
||||
{
|
||||
result: AuthorizeResult.DENY,
|
||||
},
|
||||
{
|
||||
result: AuthorizeResult.ALLOW,
|
||||
},
|
||||
]);
|
||||
const response = await request(app)
|
||||
.get(
|
||||
'/v2/templates/default/Template/create-react-app-template/parameter-schema',
|
||||
)
|
||||
.send();
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual({
|
||||
title: 'Create React App Template',
|
||||
description: 'Create a new CRA website project',
|
||||
steps: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('filters parameters that the user is not authorized to see in case of conditional decision', async () => {
|
||||
jest
|
||||
.spyOn(permissionApi, 'authorizeConditional')
|
||||
.mockImplementationOnce(async () => [
|
||||
{
|
||||
conditions: {
|
||||
resourceType: 'scaffolder-template',
|
||||
rule: 'HAS_TAG',
|
||||
params: { tag: 'parameters-tag' },
|
||||
},
|
||||
pluginId: 'scaffolder',
|
||||
resourceType: 'scaffolder-template',
|
||||
result: AuthorizeResult.CONDITIONAL,
|
||||
},
|
||||
{
|
||||
result: AuthorizeResult.ALLOW,
|
||||
},
|
||||
]);
|
||||
const response = await request(app)
|
||||
.get(
|
||||
'/v2/templates/default/Template/create-react-app-template/parameter-schema',
|
||||
)
|
||||
.send();
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual({
|
||||
title: 'Create React App Template',
|
||||
description: 'Create a new CRA website project',
|
||||
steps: [
|
||||
{
|
||||
title: 'Please enter the following information',
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['requiredParameter2'],
|
||||
'backstage:permissions': {
|
||||
tags: ['parameters-tag'],
|
||||
},
|
||||
properties: {
|
||||
requiredParameter2: {
|
||||
type: 'string',
|
||||
description: 'Required parameter 2',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /v2/tasks', () => {
|
||||
it('rejects template values which do not match the template schema definition', async () => {
|
||||
const response = await request(app)
|
||||
@@ -801,6 +999,150 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{
|
||||
expect(response.status).toEqual(400);
|
||||
});
|
||||
|
||||
it('filters steps that the user is not authorized to see', async () => {
|
||||
jest
|
||||
.spyOn(permissionApi, 'authorizeConditional')
|
||||
.mockImplementation(async () => [
|
||||
{
|
||||
result: AuthorizeResult.ALLOW,
|
||||
},
|
||||
{
|
||||
result: AuthorizeResult.DENY,
|
||||
},
|
||||
]);
|
||||
|
||||
const broker =
|
||||
taskBroker.dispatch as jest.Mocked<TaskBroker>['dispatch'];
|
||||
const mockTemplate = getMockTemplate();
|
||||
|
||||
await request(app)
|
||||
.post('/v2/tasks')
|
||||
.send({
|
||||
templateRef: stringifyEntityRef({
|
||||
kind: 'template',
|
||||
name: 'create-react-app-template',
|
||||
}),
|
||||
values: {
|
||||
requiredParameter1: 'required-value-1',
|
||||
requiredParameter2: 'required-value-2',
|
||||
},
|
||||
});
|
||||
expect(broker).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
createdBy: 'user:default/guest',
|
||||
secrets: {
|
||||
backstageToken: 'token',
|
||||
},
|
||||
|
||||
spec: {
|
||||
apiVersion: mockTemplate.apiVersion,
|
||||
steps: [],
|
||||
output: mockTemplate.spec.output ?? {},
|
||||
parameters: {
|
||||
requiredParameter1: 'required-value-1',
|
||||
requiredParameter2: 'required-value-2',
|
||||
},
|
||||
user: {
|
||||
entity: mockUser,
|
||||
ref: 'user:default/guest',
|
||||
},
|
||||
templateInfo: {
|
||||
entityRef: stringifyEntityRef({
|
||||
kind: 'Template',
|
||||
namespace: 'Default',
|
||||
name: mockTemplate.metadata?.name,
|
||||
}),
|
||||
baseUrl: 'https://dev.azure.com',
|
||||
entity: {
|
||||
metadata: mockTemplate.metadata,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('filters steps that the user is not authorized to see in case of conditional decision', async () => {
|
||||
jest
|
||||
.spyOn(permissionApi, 'authorizeConditional')
|
||||
.mockImplementation(async () => [
|
||||
{
|
||||
result: AuthorizeResult.ALLOW,
|
||||
},
|
||||
{
|
||||
conditions: {
|
||||
resourceType: 'scaffolder-template',
|
||||
rule: 'HAS_TAG',
|
||||
params: { tag: 'steps-tag' },
|
||||
},
|
||||
pluginId: 'scaffolder',
|
||||
resourceType: 'scaffolder-template',
|
||||
result: AuthorizeResult.CONDITIONAL,
|
||||
},
|
||||
]);
|
||||
|
||||
const broker =
|
||||
taskBroker.dispatch as jest.Mocked<TaskBroker>['dispatch'];
|
||||
const mockTemplate = getMockTemplate();
|
||||
await request(app)
|
||||
.post('/v2/tasks')
|
||||
.send({
|
||||
templateRef: stringifyEntityRef({
|
||||
kind: 'template',
|
||||
name: 'create-react-app-template',
|
||||
}),
|
||||
values: {
|
||||
requiredParameter1: 'required-value-1',
|
||||
requiredParameter2: 'required-value-2',
|
||||
},
|
||||
});
|
||||
expect(broker).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
createdBy: 'user:default/guest',
|
||||
secrets: {
|
||||
backstageToken: 'token',
|
||||
},
|
||||
|
||||
spec: {
|
||||
apiVersion: mockTemplate.apiVersion,
|
||||
steps: [
|
||||
{
|
||||
id: 'step-two',
|
||||
name: 'Second log',
|
||||
action: 'debug:log',
|
||||
input: {
|
||||
message: 'world',
|
||||
},
|
||||
'backstage:permissions': {
|
||||
tags: ['steps-tag'],
|
||||
},
|
||||
},
|
||||
],
|
||||
output: mockTemplate.spec.output ?? {},
|
||||
parameters: {
|
||||
requiredParameter1: 'required-value-1',
|
||||
requiredParameter2: 'required-value-2',
|
||||
},
|
||||
user: {
|
||||
entity: mockUser,
|
||||
ref: 'user:default/guest',
|
||||
},
|
||||
templateInfo: {
|
||||
entityRef: stringifyEntityRef({
|
||||
kind: 'Template',
|
||||
namespace: 'Default',
|
||||
name: mockTemplate.metadata?.name,
|
||||
}),
|
||||
baseUrl: 'https://dev.azure.com',
|
||||
entity: {
|
||||
metadata: mockTemplate.metadata,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('return the template id', async () => {
|
||||
const broker =
|
||||
taskBroker.dispatch as jest.Mocked<TaskBroker>['dispatch'];
|
||||
@@ -816,7 +1158,8 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{
|
||||
name: 'create-react-app-template',
|
||||
}),
|
||||
values: {
|
||||
required: 'required-value',
|
||||
requiredParameter1: 'required-value-1',
|
||||
requiredParameter2: 'required-value-2',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -827,6 +1170,7 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{
|
||||
it('should call the broker with a correct spec', async () => {
|
||||
const broker =
|
||||
taskBroker.dispatch as jest.Mocked<TaskBroker>['dispatch'];
|
||||
const mockTemplate = getMockTemplate();
|
||||
|
||||
await request(app)
|
||||
.post('/v2/tasks')
|
||||
@@ -836,7 +1180,8 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{
|
||||
name: 'create-react-app-template',
|
||||
}),
|
||||
values: {
|
||||
required: 'required-value',
|
||||
requiredParameter1: 'required-value-1',
|
||||
requiredParameter2: 'required-value-2',
|
||||
},
|
||||
});
|
||||
expect(broker).toHaveBeenCalledWith(
|
||||
@@ -855,7 +1200,8 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{
|
||||
})),
|
||||
output: mockTemplate.spec.output ?? {},
|
||||
parameters: {
|
||||
required: 'required-value',
|
||||
requiredParameter1: 'required-value-1',
|
||||
requiredParameter2: 'required-value-2',
|
||||
},
|
||||
user: {
|
||||
entity: mockUser,
|
||||
@@ -898,7 +1244,8 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{
|
||||
name: 'create-react-app-template',
|
||||
}),
|
||||
values: {
|
||||
required: 'required-value',
|
||||
requiredParameter1: 'required-value-1',
|
||||
requiredParameter2: 'required-value-2',
|
||||
},
|
||||
});
|
||||
expect(response.status).not.toEqual(201);
|
||||
@@ -930,7 +1277,8 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{
|
||||
name: 'create-react-app-template',
|
||||
}),
|
||||
values: {
|
||||
required: 'required-value',
|
||||
requiredParameter1: 'required-value-1',
|
||||
requiredParameter2: 'required-value-2',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -953,7 +1301,8 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{
|
||||
name: 'create-react-app-template',
|
||||
}),
|
||||
values: {
|
||||
required: 'required-value',
|
||||
requiredParameter1: 'required-value-1',
|
||||
requiredParameter2: 'required-value-2',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -973,7 +1322,8 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{
|
||||
name: 'create-react-app-template',
|
||||
}),
|
||||
values: {
|
||||
required: 'required-value',
|
||||
requiredParameter1: 'required-value-1',
|
||||
requiredParameter2: 'required-value-2',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { PluginDatabaseManager, UrlReader } from '@backstage/backend-common';
|
||||
import { PluginTaskScheduler } from '@backstage/backend-tasks';
|
||||
import { CatalogApi } from '@backstage/catalog-client';
|
||||
import {
|
||||
CompoundEntityRef,
|
||||
Entity,
|
||||
parseEntityRef,
|
||||
stringifyEntityRef,
|
||||
@@ -31,7 +32,15 @@ import {
|
||||
TaskSpec,
|
||||
TemplateEntityV1beta3,
|
||||
templateEntityV1beta3Validator,
|
||||
TemplateParametersV1beta3,
|
||||
TemplateEntityStepV1beta3,
|
||||
} from '@backstage/plugin-scaffolder-common';
|
||||
import {
|
||||
RESOURCE_TYPE_SCAFFOLDER_TEMPLATE,
|
||||
scaffolderPermissions,
|
||||
templateParameterReadPermission,
|
||||
templateStepReadPermission,
|
||||
} from '@backstage/plugin-scaffolder-common/alpha';
|
||||
import express from 'express';
|
||||
import Router from 'express-promise-router';
|
||||
import { validate } from 'jsonschema';
|
||||
@@ -53,6 +62,29 @@ import {
|
||||
IdentityApiGetIdentityRequest,
|
||||
} from '@backstage/plugin-auth-node';
|
||||
import { TemplateAction } from '@backstage/plugin-scaffolder-node';
|
||||
import {
|
||||
PermissionEvaluator,
|
||||
PermissionRuleParams,
|
||||
} from '@backstage/plugin-permission-common';
|
||||
import {
|
||||
createConditionAuthorizer,
|
||||
createPermissionIntegrationRouter,
|
||||
PermissionRule,
|
||||
} from '@backstage/plugin-permission-node';
|
||||
import { scaffolderTemplateRules } from './rules';
|
||||
|
||||
/**
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type TemplatePermissionRuleInput<
|
||||
TParams extends PermissionRuleParams = PermissionRuleParams,
|
||||
> = PermissionRule<
|
||||
TemplateEntityStepV1beta3 | TemplateParametersV1beta3,
|
||||
{},
|
||||
typeof RESOURCE_TYPE_SCAFFOLDER_TEMPLATE,
|
||||
TParams
|
||||
>;
|
||||
|
||||
/**
|
||||
* RouterOptions
|
||||
@@ -80,6 +112,8 @@ export interface RouterOptions {
|
||||
taskBroker?: TaskBroker;
|
||||
additionalTemplateFilters?: Record<string, TemplateFilter>;
|
||||
additionalTemplateGlobals?: Record<string, TemplateGlobal>;
|
||||
permissionApi?: PermissionEvaluator;
|
||||
permissionRules?: TemplatePermissionRuleInput[];
|
||||
identity?: IdentityApi;
|
||||
}
|
||||
|
||||
@@ -174,6 +208,8 @@ export async function createRouter(
|
||||
scheduler,
|
||||
additionalTemplateFilters,
|
||||
additionalTemplateGlobals,
|
||||
permissionApi,
|
||||
permissionRules,
|
||||
} = options;
|
||||
|
||||
const logger = parentLogger.child({ plugin: 'scaffolder' });
|
||||
@@ -249,41 +285,46 @@ export async function createRouter(
|
||||
additionalTemplateGlobals,
|
||||
});
|
||||
|
||||
const templateRules: TemplatePermissionRuleInput[] = Object.values(
|
||||
scaffolderTemplateRules,
|
||||
);
|
||||
|
||||
if (permissionRules) {
|
||||
templateRules.push(...permissionRules);
|
||||
}
|
||||
|
||||
const isAuthorized = createConditionAuthorizer(Object.values(templateRules));
|
||||
|
||||
const permissionIntegrationRouter = createPermissionIntegrationRouter({
|
||||
resourceType: RESOURCE_TYPE_SCAFFOLDER_TEMPLATE,
|
||||
permissions: scaffolderPermissions,
|
||||
rules: templateRules,
|
||||
});
|
||||
|
||||
router.use(permissionIntegrationRouter);
|
||||
|
||||
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,22 +362,14 @@ 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);
|
||||
|
||||
if (!result.valid) {
|
||||
res.status(400).json({ errors: result.errors });
|
||||
return;
|
||||
@@ -567,5 +600,56 @@ export async function createRouter(
|
||||
app.set('logger', logger);
|
||||
app.use('/', router);
|
||||
|
||||
async function authorizeTemplate(
|
||||
entityRef: CompoundEntityRef,
|
||||
token: string | undefined,
|
||||
) {
|
||||
const template = await findTemplate({
|
||||
catalogApi: catalogClient,
|
||||
entityRef,
|
||||
token,
|
||||
});
|
||||
|
||||
if (!isSupportedTemplate(template)) {
|
||||
throw new InputError(
|
||||
`Unsupported apiVersion field in schema entity, ${
|
||||
(template as Entity).apiVersion
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!permissionApi) {
|
||||
return template;
|
||||
}
|
||||
|
||||
const [parameterDecision, stepDecision] =
|
||||
await permissionApi.authorizeConditional(
|
||||
[
|
||||
{ permission: templateParameterReadPermission },
|
||||
{ permission: templateStepReadPermission },
|
||||
],
|
||||
{ token },
|
||||
);
|
||||
|
||||
// Authorize parameters
|
||||
if (Array.isArray(template.spec.parameters)) {
|
||||
template.spec.parameters = template.spec.parameters.filter(step =>
|
||||
isAuthorized(parameterDecision, step),
|
||||
);
|
||||
} else if (
|
||||
template.spec.parameters &&
|
||||
!isAuthorized(parameterDecision, template.spec.parameters)
|
||||
) {
|
||||
template.spec.parameters = undefined;
|
||||
}
|
||||
|
||||
// Authorize steps
|
||||
template.spec.steps = template.spec.steps.filter(step =>
|
||||
isAuthorized(stepDecision, step),
|
||||
);
|
||||
|
||||
return template;
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { hasTag } from './rules';
|
||||
|
||||
describe('hasTag', () => {
|
||||
describe('apply', () => {
|
||||
it('returns false when the tag is not present', () => {
|
||||
expect(
|
||||
hasTag.apply(
|
||||
{
|
||||
'backstage:permissions': {
|
||||
tags: ['foo', 'bar'],
|
||||
},
|
||||
},
|
||||
{
|
||||
tag: 'baz',
|
||||
},
|
||||
),
|
||||
).toEqual(false);
|
||||
});
|
||||
|
||||
it('returns false when backstage:permissions is missing', () => {
|
||||
expect(
|
||||
hasTag.apply(
|
||||
{},
|
||||
{
|
||||
tag: 'baz',
|
||||
},
|
||||
),
|
||||
).toEqual(false);
|
||||
});
|
||||
|
||||
it('returns false when tags is an empty array', () => {
|
||||
expect(
|
||||
hasTag.apply(
|
||||
{
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
'backstage:permissions': {
|
||||
tags: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
tag: 'baz',
|
||||
},
|
||||
),
|
||||
).toEqual(false);
|
||||
});
|
||||
|
||||
it('returns true when the tag is present', () => {
|
||||
expect(
|
||||
hasTag.apply(
|
||||
{
|
||||
'backstage:permissions': {
|
||||
tags: ['foo', 'bar'],
|
||||
},
|
||||
},
|
||||
{
|
||||
tag: 'bar',
|
||||
},
|
||||
),
|
||||
).toEqual(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { makeCreatePermissionRule } from '@backstage/plugin-permission-node';
|
||||
import {
|
||||
TemplateEntityStepV1beta3,
|
||||
TemplateParametersV1beta3,
|
||||
} from '@backstage/plugin-scaffolder-common';
|
||||
import { RESOURCE_TYPE_SCAFFOLDER_TEMPLATE } from '@backstage/plugin-scaffolder-common/alpha';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
export const createScaffolderPermissionRule = makeCreatePermissionRule<
|
||||
TemplateEntityStepV1beta3 | TemplateParametersV1beta3,
|
||||
{},
|
||||
typeof RESOURCE_TYPE_SCAFFOLDER_TEMPLATE
|
||||
>();
|
||||
|
||||
export const hasTag = createScaffolderPermissionRule({
|
||||
name: 'HAS_TAG',
|
||||
resourceType: RESOURCE_TYPE_SCAFFOLDER_TEMPLATE,
|
||||
description: `Match parameters or steps with the given tag`,
|
||||
paramsSchema: z.object({
|
||||
tag: z.string().describe('Name of the tag to match on'),
|
||||
}),
|
||||
apply: (resource, { tag }) => {
|
||||
return resource['backstage:permissions']?.tags?.includes(tag) ?? false;
|
||||
},
|
||||
toQuery: () => ({}),
|
||||
});
|
||||
|
||||
export const scaffolderTemplateRules = { hasTag };
|
||||
@@ -0,0 +1,21 @@
|
||||
## API Report File for "@backstage/plugin-scaffolder-common"
|
||||
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
import { ResourcePermission } from '@backstage/plugin-permission-common';
|
||||
|
||||
// @alpha
|
||||
export const RESOURCE_TYPE_SCAFFOLDER_TEMPLATE = 'scaffolder-template';
|
||||
|
||||
// @alpha
|
||||
export const scaffolderPermissions: ResourcePermission<'scaffolder-template'>[];
|
||||
|
||||
// @alpha
|
||||
export const templateParameterReadPermission: ResourcePermission<'scaffolder-template'>;
|
||||
|
||||
// @alpha
|
||||
export const templateStepReadPermission: ResourcePermission<'scaffolder-template'>;
|
||||
|
||||
// (No @packageDocumentation comment for this package)
|
||||
```
|
||||
@@ -42,20 +42,30 @@ export interface TaskStep {
|
||||
name: string;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface TemplateEntityStepV1beta3 extends JsonObject {
|
||||
// (undocumented)
|
||||
'backstage:permissions'?: TemplatePermissionsV1beta3;
|
||||
// (undocumented)
|
||||
action: string;
|
||||
// (undocumented)
|
||||
id?: string;
|
||||
// (undocumented)
|
||||
if?: string | boolean;
|
||||
// (undocumented)
|
||||
input?: JsonObject;
|
||||
// (undocumented)
|
||||
name?: string;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface TemplateEntityV1beta3 extends Entity {
|
||||
apiVersion: 'scaffolder.backstage.io/v1beta3';
|
||||
kind: 'Template';
|
||||
spec: {
|
||||
type: string;
|
||||
parameters?: JsonObject | JsonObject[];
|
||||
steps: Array<{
|
||||
id?: string;
|
||||
name?: string;
|
||||
action: string;
|
||||
input?: JsonObject;
|
||||
if?: string | boolean;
|
||||
}>;
|
||||
parameters?: TemplateParametersV1beta3 | TemplateParametersV1beta3[];
|
||||
steps: Array<TemplateEntityStepV1beta3>;
|
||||
output?: {
|
||||
[name: string]: string;
|
||||
};
|
||||
@@ -74,4 +84,16 @@ export type TemplateInfo = {
|
||||
metadata: EntityMeta;
|
||||
};
|
||||
};
|
||||
|
||||
// @public
|
||||
export interface TemplateParametersV1beta3 extends JsonObject {
|
||||
// (undocumented)
|
||||
'backstage:permissions'?: TemplatePermissionsV1beta3;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface TemplatePermissionsV1beta3 extends JsonObject {
|
||||
// (undocumented)
|
||||
tags?: string[];
|
||||
}
|
||||
```
|
||||
|
||||
@@ -11,6 +11,21 @@
|
||||
"module": "dist/index.esm.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./alpha": "./src/alpha.ts",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"alpha": [
|
||||
"src/alpha.ts"
|
||||
],
|
||||
"package.json": [
|
||||
"package.json"
|
||||
]
|
||||
}
|
||||
},
|
||||
"backstage": {
|
||||
"role": "common-library"
|
||||
},
|
||||
@@ -39,6 +54,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/catalog-model": "workspace:^",
|
||||
"@backstage/plugin-permission-common": "workspace:^",
|
||||
"@backstage/types": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
"owner": "artist-relations-team",
|
||||
"parameters": {
|
||||
"required": ["name", "description", "repoUrl"],
|
||||
"backstage:permissions": {
|
||||
"tags": ["one", "two"]
|
||||
},
|
||||
"properties": {
|
||||
"name": {
|
||||
"title": "Name",
|
||||
@@ -41,6 +44,9 @@
|
||||
"action": "fetch:plain",
|
||||
"parameters": {
|
||||
"url": "./template"
|
||||
},
|
||||
"backstage:permissions": {
|
||||
"tags": ["one", "two"]
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -92,14 +98,42 @@
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"description": "The JSONSchema describing the inputs for the template."
|
||||
"description": "The JSONSchema describing the inputs for the template.",
|
||||
"properties": {
|
||||
"backstage:permissions": {
|
||||
"type": "object",
|
||||
"description": "Object used for authorizing the parameter",
|
||||
"properties": {
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "array",
|
||||
"description": "A list of separate forms to collect parameters.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"description": "The JSONSchema describing the inputs for the template."
|
||||
"description": "The JSONSchema describing the inputs for the template.",
|
||||
"properties": {
|
||||
"backstage:permissions": {
|
||||
"type": "object",
|
||||
"description": "Object used for authorizing the parameter",
|
||||
"properties": {
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -131,6 +165,18 @@
|
||||
"if": {
|
||||
"type": ["string", "boolean"],
|
||||
"description": "A templated condition that skips the step when evaluated to false. If the condition is true or not defined, the step is executed. The condition is true, if the input is not `false`, `undefined`, `null`, `\"\"`, `0`, or `[]`."
|
||||
},
|
||||
"backstage:permissions": {
|
||||
"type": "object",
|
||||
"description": "Object used for authorizing the step",
|
||||
"properties": {
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,10 @@
|
||||
*/
|
||||
|
||||
import { entityKindSchemaValidator } from '@backstage/catalog-model';
|
||||
import type { TemplateEntityV1beta3 } from './TemplateEntityV1beta3';
|
||||
import type {
|
||||
TemplateEntityV1beta3,
|
||||
TemplateParametersV1beta3,
|
||||
} from './TemplateEntityV1beta3';
|
||||
import schema from './Template.v1beta3.schema.json';
|
||||
|
||||
const validator = entityKindSchemaValidator(schema);
|
||||
@@ -35,6 +38,7 @@ describe('templateEntityV1beta3Validator', () => {
|
||||
owner: 'team-b',
|
||||
parameters: {
|
||||
required: ['owner'],
|
||||
'backstage:permissions': { tags: ['one', 'two'] },
|
||||
properties: {
|
||||
owner: {
|
||||
type: 'string',
|
||||
@@ -52,6 +56,9 @@ describe('templateEntityV1beta3Validator', () => {
|
||||
url: './template',
|
||||
},
|
||||
if: '${{ parameters.owner }}',
|
||||
'backstage:permissions': {
|
||||
tags: ['one', 'two'],
|
||||
},
|
||||
},
|
||||
],
|
||||
output: {
|
||||
@@ -154,4 +161,24 @@ describe('templateEntityV1beta3Validator', () => {
|
||||
(entity as any).spec.steps[0].if = 5;
|
||||
expect(() => validator(entity)).toThrow(/if/);
|
||||
});
|
||||
|
||||
it('rejects parameters with wrong backstage:permissions', async () => {
|
||||
(entity.spec.parameters as TemplateParametersV1beta3)[
|
||||
'backstage:permissions'
|
||||
]!.tags = true as unknown as [];
|
||||
expect(() => validator(entity)).toThrow(/must be array/);
|
||||
|
||||
(entity.spec.parameters as TemplateParametersV1beta3)[
|
||||
'backstage:permissions'
|
||||
] = true as {};
|
||||
expect(() => validator(entity)).toThrow(/must be object/);
|
||||
});
|
||||
|
||||
it('rejects steps with wrong backstage:permissions', async () => {
|
||||
entity.spec.steps[0]['backstage:permissions']!.tags = true as unknown as [];
|
||||
expect(() => validator(entity)).toThrow(/must be array/);
|
||||
|
||||
entity.spec.steps[0]['backstage:permissions'] = true as {};
|
||||
expect(() => validator(entity)).toThrow(/must be object/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,18 +50,12 @@ export interface TemplateEntityV1beta3 extends Entity {
|
||||
* to collect user input and validate it against that schema. This can then be used in the `steps` part below to template
|
||||
* variables passed from the user into each action in the template.
|
||||
*/
|
||||
parameters?: JsonObject | JsonObject[];
|
||||
parameters?: TemplateParametersV1beta3 | TemplateParametersV1beta3[];
|
||||
/**
|
||||
* A list of steps to be executed in sequence which are defined by the template. These steps are a list of the underlying
|
||||
* javascript action and some optional input parameters that may or may not have been collected from the end user.
|
||||
*/
|
||||
steps: Array<{
|
||||
id?: string;
|
||||
name?: string;
|
||||
action: string;
|
||||
input?: JsonObject;
|
||||
if?: string | boolean;
|
||||
}>;
|
||||
steps: Array<TemplateEntityStepV1beta3>;
|
||||
/**
|
||||
* The output is an object where template authors can pull out information from template actions and return them in a known standard way.
|
||||
*/
|
||||
@@ -73,6 +67,38 @@ export interface TemplateEntityV1beta3 extends Entity {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Step that is part of a Template Entity.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface TemplateEntityStepV1beta3 extends JsonObject {
|
||||
id?: string;
|
||||
name?: string;
|
||||
action: string;
|
||||
input?: JsonObject;
|
||||
if?: string | boolean;
|
||||
'backstage:permissions'?: TemplatePermissionsV1beta3;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameter that is part of a Template Entity.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface TemplateParametersV1beta3 extends JsonObject {
|
||||
'backstage:permissions'?: TemplatePermissionsV1beta3;
|
||||
}
|
||||
|
||||
/**
|
||||
* Access control properties for parts of a template.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface TemplatePermissionsV1beta3 extends JsonObject {
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
const validator = entityKindSchemaValidator(schema);
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export * from './permissions';
|
||||
@@ -21,8 +21,14 @@
|
||||
*/
|
||||
|
||||
export * from './TaskSpec';
|
||||
|
||||
export {
|
||||
templateEntityV1beta3Validator,
|
||||
isTemplateEntityV1beta3,
|
||||
} from './TemplateEntityV1beta3';
|
||||
export type { TemplateEntityV1beta3 } from './TemplateEntityV1beta3';
|
||||
export type {
|
||||
TemplateEntityV1beta3,
|
||||
TemplateEntityStepV1beta3,
|
||||
TemplateParametersV1beta3,
|
||||
TemplatePermissionsV1beta3,
|
||||
} from './TemplateEntityV1beta3';
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { createPermission } from '@backstage/plugin-permission-common';
|
||||
|
||||
/**
|
||||
* Permission resource type which corresponds to a scaffolder templates.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export const RESOURCE_TYPE_SCAFFOLDER_TEMPLATE = 'scaffolder-template';
|
||||
|
||||
/**
|
||||
* This permission is used to authorize actions that involve reading
|
||||
* one or more parameters from a template.
|
||||
*
|
||||
* If this permission is not authorized, it will appear that the
|
||||
* parameter does not exist in the template — both in the frontend
|
||||
* and in API responses.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export const templateParameterReadPermission = createPermission({
|
||||
name: 'scaffolder.template.parameter.read',
|
||||
attributes: {
|
||||
action: 'read',
|
||||
},
|
||||
resourceType: RESOURCE_TYPE_SCAFFOLDER_TEMPLATE,
|
||||
});
|
||||
|
||||
/**
|
||||
* This permission is used to authorize actions that involve reading
|
||||
* one or more steps from a template.
|
||||
*
|
||||
* If this permission is not authorized, it will appear that the
|
||||
* step does not exist in the template — both in the frontend
|
||||
* and in API responses. Steps will also not be executed.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export const templateStepReadPermission = createPermission({
|
||||
name: 'scaffolder.template.step.read',
|
||||
attributes: {
|
||||
action: 'read',
|
||||
},
|
||||
resourceType: RESOURCE_TYPE_SCAFFOLDER_TEMPLATE,
|
||||
});
|
||||
|
||||
/**
|
||||
* List of all the scaffolder permissions
|
||||
* @alpha
|
||||
*/
|
||||
export const scaffolderPermissions = [
|
||||
templateParameterReadPermission,
|
||||
templateStepReadPermission,
|
||||
];
|
||||
@@ -7714,6 +7714,8 @@ __metadata:
|
||||
"@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:^"
|
||||
@@ -7773,6 +7775,7 @@ __metadata:
|
||||
dependencies:
|
||||
"@backstage/catalog-model": "workspace:^"
|
||||
"@backstage/cli": "workspace:^"
|
||||
"@backstage/plugin-permission-common": "workspace:^"
|
||||
"@backstage/types": "workspace:^"
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
Reference in New Issue
Block a user