Merge pull request #8119 from backstage/permission-node

Add @backstage/plugin-permission-node
This commit is contained in:
Mike Lewis
2021-11-24 17:19:35 +00:00
committed by GitHub
21 changed files with 1437 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-permission-node': patch
---
New package containing common permission and authorization utilities for backend plugins. For more information, see the [authorization PRFC](https://github.com/backstage/backstage/pull/7761).
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint.backend')],
};
+7
View File
@@ -0,0 +1,7 @@
# @backstage/plugin-permission-node
> NOTE: THIS PACKAGE IS EXPERIMENTAL, HERE BE DRAGONS
Common permission and authorization utilities for backend plugins. For more
information, see the [authorization
PRFC](https://github.com/backstage/backstage/pull/7761).
+130
View File
@@ -0,0 +1,130 @@
## API Report File for "@backstage/plugin-permission-node"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { AuthorizeRequest } from '@backstage/plugin-permission-common';
import { AuthorizeResult } from '@backstage/plugin-permission-common';
import { BackstageIdentity } from '@backstage/plugin-auth-backend';
import { PermissionCondition } from '@backstage/plugin-permission-common';
import { PermissionCriteria } from '@backstage/plugin-permission-common';
import { Router } from 'express';
// @public
export type ApplyConditionsRequest = {
resourceRef: string;
resourceType: string;
conditions: PermissionCriteria<PermissionCondition>;
};
// @public
export type ApplyConditionsResponse = {
result: AuthorizeResult.ALLOW | AuthorizeResult.DENY;
};
// @public
export type Condition<TRule> = TRule extends PermissionRule<
any,
any,
infer TParams
>
? (...params: TParams) => PermissionCondition<TParams>
: never;
// @public
export type ConditionalPolicyResult = {
result: AuthorizeResult.CONDITIONAL;
conditions: {
pluginId: string;
resourceType: string;
conditions: PermissionCriteria<PermissionCondition>;
};
};
// @public
export type Conditions<
TRules extends Record<string, PermissionRule<any, any>>,
> = {
[Name in keyof TRules]: Condition<TRules[Name]>;
};
// @public
export type ConditionTransformer<TQuery> = (
conditions: PermissionCriteria<PermissionCondition>,
) => PermissionCriteria<TQuery>;
// @public
export const createConditionExports: <
TResource,
TRules extends Record<string, PermissionRule<TResource, any, unknown[]>>,
>(options: {
pluginId: string;
resourceType: string;
rules: TRules;
}) => {
conditions: Conditions<TRules>;
createConditions: (conditions: PermissionCriteria<PermissionCondition>) => {
pluginId: string;
resourceType: string;
conditions: PermissionCriteria<PermissionCondition>;
};
};
// @public
export const createConditionFactory: <TParams extends any[]>(
rule: PermissionRule<unknown, unknown, TParams>,
) => (...params: TParams) => {
rule: string;
params: TParams;
};
// @public
export const createConditionTransformer: <
TQuery,
TRules extends PermissionRule<any, TQuery, unknown[]>[],
>(
permissionRules: [...TRules],
) => ConditionTransformer<TQuery>;
// @public
export const createPermissionIntegrationRouter: <TResource>({
resourceType,
rules,
getResource,
}: {
resourceType: string;
rules: PermissionRule<TResource, any, unknown[]>[];
getResource: (resourceRef: string) => Promise<TResource | undefined>;
}) => Router;
// @public
export interface PermissionPolicy {
// (undocumented)
handle(
request: PolicyAuthorizeRequest,
user?: BackstageIdentity,
): Promise<PolicyResult>;
}
// @public
export type PermissionRule<
TResource,
TQuery,
TParams extends unknown[] = unknown[],
> = {
name: string;
description: string;
apply(resource: TResource, ...params: TParams): boolean;
toQuery(...params: TParams): PermissionCriteria<TQuery>;
};
// @public
export type PolicyAuthorizeRequest = Omit<AuthorizeRequest, 'resourceRef'>;
// @public
export type PolicyResult =
| {
result: AuthorizeResult.ALLOW | AuthorizeResult.DENY;
}
| ConditionalPolicyResult;
```
+46
View File
@@ -0,0 +1,46 @@
{
"name": "@backstage/plugin-permission-node",
"description": "Common permission and authorization utilities for backend plugins",
"version": "0.0.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/permission-node"
},
"keywords": [
"backstage",
"permissions"
],
"scripts": {
"build": "backstage-cli backend:build",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/plugin-auth-backend": "^0.4.6",
"@backstage/plugin-permission-common": "^0.1.0",
"@types/express": "^4.17.6",
"express": "^4.17.1",
"zod": "^3.11.6"
},
"devDependencies": {
"@backstage/cli": "^0.9.0",
"@types/supertest": "^2.0.8",
"supertest": "^6.1.3"
},
"files": [
"dist"
]
}
+24
View File
@@ -0,0 +1,24 @@
/*
* Copyright 2021 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.
*/
/**
* Common permission and authorization utilities for backend plugins
*
* @packageDocumentation
*/
export * from './integration';
export * from './policy';
export * from './types';
@@ -0,0 +1,79 @@
/*
* Copyright 2021 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 { createConditionExports } from './createConditionExports';
const testIntegration = () =>
createConditionExports({
pluginId: 'test-plugin',
resourceType: 'test-resource',
rules: {
testRule1: {
name: 'testRule1',
description: 'Test rule 1',
apply: jest.fn(
(_resource: any, _firstParam: string, _secondParam: number) => true,
),
toQuery: jest.fn((firstParam: string, secondParam: number) => ({
query: 'testRule1',
params: [firstParam, secondParam],
})),
},
testRule2: {
name: 'testRule2',
description: 'Test rule 2',
apply: jest.fn((_firstParam: object) => false),
toQuery: jest.fn((firstParam: object) => ({
query: 'testRule2',
params: [firstParam],
})),
},
},
});
describe('createConditionExports', () => {
describe('conditions', () => {
it('creates condition factories for the supplied rules', () => {
const { conditions } = testIntegration();
expect(conditions.testRule1('a', 1)).toEqual({
rule: 'testRule1',
params: ['a', 1],
});
expect(conditions.testRule2({ baz: 'quux' })).toEqual({
rule: 'testRule2',
params: [{ baz: 'quux' }],
});
});
});
describe('createConditions', () => {
it('wraps conditions in an object with resourceType and pluginId', () => {
const { createConditions } = testIntegration();
expect(
createConditions({ allOf: [{ rule: 'testRule1', params: ['a', 1] }] }),
).toEqual({
pluginId: 'test-plugin',
resourceType: 'test-resource',
conditions: {
allOf: [{ rule: 'testRule1', params: ['a', 1] }],
},
});
});
});
});
@@ -0,0 +1,100 @@
/*
* Copyright 2021 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 {
PermissionCondition,
PermissionCriteria,
} from '@backstage/plugin-permission-common';
import { PermissionRule } from '../types';
import { createConditionFactory } from './createConditionFactory';
/**
* A utility type for mapping a single {@link PermissionRule} to its
* corresponding {@link @backstage/plugin-permission-common#PermissionCondition}.
*
* @public
*/
export type Condition<TRule> = TRule extends PermissionRule<
any,
any,
infer TParams
>
? (...params: TParams) => PermissionCondition<TParams>
: never;
/**
* A utility type for mapping {@link PermissionRule}s to their corresponding
* {@link @backstage/plugin-permission-common#PermissionCondition}s.
*
* @public
*/
export type Conditions<
TRules extends Record<string, PermissionRule<any, any>>,
> = {
[Name in keyof TRules]: Condition<TRules[Name]>;
};
/**
* Creates the recommended condition-related exports for a given plugin based on the built-in
* {@link PermissionRule}s it supports.
*
* @remarks
*
* The function returns a `conditions` object containing a
* {@link @backstage/plugin-permission-common#PermissionCondition} factory for each of the
* supplied {@link PermissionRule}s, along with a `createConditions` function which builds the
* wrapper object needed to enclose conditions when authoring {@link PermissionPolicy} implementations.
*
* Plugin authors should generally call this method with all the built-in {@link PermissionRule}s
* the plugin supports, and export the resulting `conditions` object and `createConditions`
* function so that they can be used by {@link PermissionPolicy} authors.
*
* @public
*/
export const createConditionExports = <
TResource,
TRules extends Record<string, PermissionRule<TResource, any>>,
>(options: {
pluginId: string;
resourceType: string;
rules: TRules;
}): {
conditions: Conditions<TRules>;
createConditions: (conditions: PermissionCriteria<PermissionCondition>) => {
pluginId: string;
resourceType: string;
conditions: PermissionCriteria<PermissionCondition>;
};
} => {
const { pluginId, resourceType, rules } = options;
return {
conditions: Object.entries(rules).reduce(
(acc, [key, rule]) => ({
...acc,
[key]: createConditionFactory(rule),
}),
{} as Conditions<TRules>,
),
createConditions: (
conditions: PermissionCriteria<PermissionCondition>,
) => ({
pluginId,
resourceType,
conditions,
}),
};
};
@@ -0,0 +1,40 @@
/*
* Copyright 2021 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 { createConditionFactory } from './createConditionFactory';
describe('createConditionFactory', () => {
const testRule = {
name: 'test-rule',
description: 'test-description',
apply: jest.fn(),
toQuery: jest.fn(),
};
it('returns a function', () => {
expect(createConditionFactory(testRule)).toEqual(expect.any(Function));
});
describe('return value', () => {
it('constructs a condition with the rule name and supplied params', () => {
const conditionFactory = createConditionFactory(testRule);
expect(conditionFactory('a', 'b', 1, 2)).toEqual({
rule: 'test-rule',
params: ['a', 'b', 1, 2],
});
});
});
});
@@ -0,0 +1,40 @@
/*
* Copyright 2021 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 { PermissionRule } from '../types';
/**
* Creates a condition factory function for a given authorization rule and parameter types.
*
* @remarks
*
* For example, an isEntityOwner rule for catalog entities might take an array of entityRef strings.
* The rule itself defines _how_ to check a given resource, whereas a condition also includes _what_
* to verify.
*
* Plugin authors should generally use the {@link createConditionExports} in order to efficiently
* create multiple condition factories. This helper should generally only be used to construct
* condition factories for third-party rules that aren't part of the backend plugin with which
* they're intended to integrate.
*
* @public
*/
export const createConditionFactory =
<TParams extends any[]>(rule: PermissionRule<unknown, unknown, TParams>) =>
(...params: TParams) => ({
rule: rule.name,
params,
});
@@ -0,0 +1,158 @@
/*
* Copyright 2021 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 {
PermissionCondition,
PermissionCriteria,
} from '@backstage/plugin-permission-common';
import { createConditionTransformer } from './createConditionTransformer';
const transformConditions = createConditionTransformer([
{
name: 'test-rule-1',
description: 'Test rule 1',
apply: jest.fn(),
toQuery: jest.fn(
(firstParam: string, secondParam: number) =>
`test-rule-1:${firstParam}/${secondParam}`,
),
},
{
name: 'test-rule-2',
description: 'Test rule 2',
apply: jest.fn(),
toQuery: jest.fn(
(firstParam: object) => `test-rule-2:${JSON.stringify(firstParam)}`,
),
},
]);
describe('createConditionTransformer', () => {
const testCases: {
conditions: PermissionCriteria<PermissionCondition>;
expectedResult: PermissionCriteria<string>;
}[] = [
{
conditions: { rule: 'test-rule-1', params: ['abc', 123] },
expectedResult: 'test-rule-1:abc/123',
},
{
conditions: { rule: 'test-rule-2', params: [{ foo: 0 }] },
expectedResult: 'test-rule-2:{"foo":0}',
},
{
conditions: {
anyOf: [
{ rule: 'test-rule-1', params: ['a', 1] },
{ rule: 'test-rule-2', params: [{}] },
],
},
expectedResult: {
anyOf: ['test-rule-1:a/1', 'test-rule-2:{}'],
},
},
{
conditions: {
allOf: [
{ rule: 'test-rule-1', params: ['a', 1] },
{ rule: 'test-rule-2', params: [{}] },
],
},
expectedResult: {
allOf: ['test-rule-1:a/1', 'test-rule-2:{}'],
},
},
{
conditions: {
not: { rule: 'test-rule-2', params: [{}] },
},
expectedResult: {
not: 'test-rule-2:{}',
},
},
{
conditions: {
allOf: [
{
anyOf: [
{ rule: 'test-rule-1', params: ['a', 1] },
{ rule: 'test-rule-2', params: [{}] },
],
},
{
not: {
allOf: [
{ rule: 'test-rule-1', params: ['b', 2] },
{ rule: 'test-rule-2', params: [{ c: 3 }] },
],
},
},
],
},
expectedResult: {
allOf: [
{
anyOf: ['test-rule-1:a/1', 'test-rule-2:{}'],
},
{
not: {
allOf: ['test-rule-1:b/2', 'test-rule-2:{"c":3}'],
},
},
],
},
},
{
conditions: {
allOf: [
{
anyOf: [
{ rule: 'test-rule-1', params: ['a', 1] },
{ rule: 'test-rule-2', params: [{ b: 2 }] },
],
},
{
not: {
allOf: [
{ rule: 'test-rule-1', params: ['c', 3] },
{ not: { rule: 'test-rule-2', params: [{ d: 4 }] } },
],
},
},
],
},
expectedResult: {
allOf: [
{
anyOf: ['test-rule-1:a/1', 'test-rule-2:{"b":2}'],
},
{
not: {
allOf: ['test-rule-1:c/3', { not: 'test-rule-2:{"d":4}' }],
},
},
],
},
},
];
it.each(testCases)(
'works with criteria %#',
({ conditions, expectedResult }) => {
expect(transformConditions(conditions)).toEqual(expectedResult);
},
);
});
@@ -0,0 +1,78 @@
/*
* Copyright 2021 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 {
PermissionCondition,
PermissionCriteria,
} from '@backstage/plugin-permission-common';
import { PermissionRule } from '../types';
import {
createGetRule,
isAndCriteria,
isNotCriteria,
isOrCriteria,
} from './util';
const mapConditions = <TQuery>(
criteria: PermissionCriteria<PermissionCondition>,
getRule: (name: string) => PermissionRule<unknown, TQuery>,
): PermissionCriteria<TQuery> => {
if (isAndCriteria(criteria)) {
return {
allOf: criteria.allOf.map(child => mapConditions(child, getRule)),
};
} else if (isOrCriteria(criteria)) {
return {
anyOf: criteria.anyOf.map(child => mapConditions(child, getRule)),
};
} else if (isNotCriteria(criteria)) {
return {
not: mapConditions(criteria.not, getRule),
};
}
return getRule(criteria.rule).toQuery(...criteria.params);
};
/**
* A function which accepts {@link @backstage/plugin-permission-common#PermissionCondition}s
* logically grouped in a {@link @backstage/plugin-permission-common#PermissionCriteria}
* object, and transforms the {@link @backstage/plugin-permission-common#PermissionCondition}s
* into plugin specific query fragments while retaining the enclosing criteria shape.
*
* @public
*/
export type ConditionTransformer<TQuery> = (
conditions: PermissionCriteria<PermissionCondition>,
) => PermissionCriteria<TQuery>;
/**
* A higher-order helper function which accepts an array of
* {@link PermissionRule}s, and returns a {@link ConditionTransformer}
* which transforms input conditions into equivalent plugin-specific
* query fragments using the supplied rules.
*
* @public
*/
export const createConditionTransformer = <
TQuery,
TRules extends PermissionRule<any, TQuery>[],
>(
permissionRules: [...TRules],
): ConditionTransformer<TQuery> => {
const getRule = createGetRule(permissionRules);
return conditions => mapConditions(conditions, getRule);
};
@@ -0,0 +1,210 @@
/*
* Copyright 2021 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 { AuthorizeResult } from '@backstage/plugin-permission-common';
import express, { Express, Router } from 'express';
import request from 'supertest';
import { createPermissionIntegrationRouter } from './createPermissionIntegrationRouter';
const mockGetResource: jest.MockedFunction<
(resourceRef: string) => Promise<any>
> = jest.fn((resourceRef: string) =>
Promise.resolve({
resourceRef,
}),
);
const testRule1 = {
name: 'test-rule-1',
description: 'Test rule 1',
apply: jest.fn(
(_resource: any, _firstParam: string, _secondParam: number) => true,
),
toQuery: jest.fn(),
};
const testRule2 = {
name: 'test-rule-2',
description: 'Test rule 2',
apply: jest.fn((_firstParam: object) => false),
toQuery: jest.fn(),
};
describe('createPermissionIntegrationRouter', () => {
let app: Express;
let router: Router;
beforeEach(() => {
router = createPermissionIntegrationRouter({
resourceType: 'test-resource',
getResource: mockGetResource,
rules: [testRule1, testRule2],
});
app = express().use(router);
});
it('works', async () => {
expect(router).toBeDefined();
});
describe('POST /permissions/apply-conditions', () => {
it.each([
{ rule: 'test-rule-1', params: ['abc', 123] },
{
anyOf: [
{ rule: 'test-rule-1', params: ['a', 1] },
{ rule: 'test-rule-2', params: [{}] },
],
},
{
not: { rule: 'test-rule-2', params: [{}] },
},
{
allOf: [
{
anyOf: [
{ rule: 'test-rule-1', params: ['a', 1] },
{ rule: 'test-rule-2', params: [{}] },
],
},
{
not: {
allOf: [
{ rule: 'test-rule-1', params: ['b', 2] },
{ rule: 'test-rule-2', params: [{ c: 3 }] },
],
},
},
],
},
])('returns 200/ALLOW when criteria match (case %#)', async conditions => {
const response = await request(app)
.post('/permissions/apply-conditions')
.send({
resourceRef: 'default:test/resource',
resourceType: 'test-resource',
conditions,
});
expect(response.status).toEqual(200);
expect(response.body).toEqual({ result: AuthorizeResult.ALLOW });
});
it.each([
{ rule: 'test-rule-2', params: [{ foo: 0 }] },
{
allOf: [
{ rule: 'test-rule-1', params: ['a', 1] },
{ rule: 'test-rule-2', params: [{}] },
],
},
{
allOf: [
{
anyOf: [
{ rule: 'test-rule-1', params: ['a', 1] },
{ rule: 'test-rule-2', params: [{ b: 2 }] },
],
},
{
not: {
allOf: [
{ rule: 'test-rule-1', params: ['c', 3] },
{ not: { rule: 'test-rule-2', params: [{ d: 4 }] } },
],
},
},
],
},
])(
'returns 200/DENY when criteria do not match (case %#)',
async conditions => {
const response = await request(app)
.post('/permissions/apply-conditions')
.send({
resourceRef: 'default:test/resource',
resourceType: 'test-resource',
conditions,
});
expect(response.status).toEqual(200);
expect(response.body).toEqual({ result: AuthorizeResult.DENY });
},
);
it('returns 400 when called with incorrect resource type', async () => {
const response = await request(app)
.post('/permissions/apply-conditions')
.send({
resourceRef: 'default:test/resource',
resourceType: 'test-incorrect-resource',
conditions: {
anyOf: [],
},
});
expect(response.status).toEqual(400);
expect(response.error && response.error.text).toMatch(
/unexpected resource type: test-incorrect-resource/i,
);
});
it('returns 400 when resource is not found', async () => {
mockGetResource.mockReturnValueOnce(Promise.resolve(undefined));
const response = await request(app)
.post('/permissions/apply-conditions')
.send({
resourceRef: 'default:test/resource',
resourceType: 'test-resource',
conditions: {
not: {
rule: 'testRule1',
params: ['a', 1],
},
},
});
expect(response.status).toEqual(400);
expect(response.error && response.error.text).toMatch(
/resource for ref default:test\/resource not found/i,
);
});
it.each([
undefined,
{},
{ resourceType: 'test-resource-type' },
{ resourceRef: 'test/resource-ref' },
{
resourceType: 'test-resource-type',
resourceRef: 'test/resource-ref',
},
{ conditions: { anyOf: [] } },
])(`returns 400 for invalid input %#`, async input => {
const response = await request(app)
.post('/permissions/apply-conditions')
.send(input);
expect(response.status).toEqual(400);
expect(response.error && response.error.text).toMatch(
/invalid request body/i,
);
});
});
});
@@ -0,0 +1,177 @@
/*
* Copyright 2021 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 express, { Response, Router } from 'express';
import { z } from 'zod';
import {
AuthorizeResult,
PermissionCondition,
PermissionCriteria,
} from '@backstage/plugin-permission-common';
import { PermissionRule } from '../types';
import {
createGetRule,
isAndCriteria,
isNotCriteria,
isOrCriteria,
} from './util';
const permissionCriteriaSchema: z.ZodSchema<
PermissionCriteria<PermissionCondition>
> = z.lazy(() =>
z.union([
z.object({ anyOf: z.array(permissionCriteriaSchema) }),
z.object({ allOf: z.array(permissionCriteriaSchema) }),
z.object({ not: permissionCriteriaSchema }),
z.object({
rule: z.string(),
params: z.array(z.unknown()),
}),
]),
);
const applyConditionsRequestSchema = z.object({
resourceRef: z.string(),
resourceType: z.string(),
conditions: permissionCriteriaSchema,
});
/**
* A request to load the referenced resource and apply conditions in order to
* finalize a conditional authorization response.
*
* @public
*/
export type ApplyConditionsRequest = {
resourceRef: string;
resourceType: string;
conditions: PermissionCriteria<PermissionCondition>;
};
/**
* The result of applying the conditions, expressed as a definitive authorize
* result of ALLOW or DENY.
*
* @public
*/
export type ApplyConditionsResponse = {
result: AuthorizeResult.ALLOW | AuthorizeResult.DENY;
};
const applyConditions = <TResource>(
criteria: PermissionCriteria<PermissionCondition>,
resource: TResource,
getRule: (name: string) => PermissionRule<TResource, unknown>,
): boolean => {
if (isAndCriteria(criteria)) {
return criteria.allOf.every(child =>
applyConditions(child, resource, getRule),
);
} else if (isOrCriteria(criteria)) {
return criteria.anyOf.some(child =>
applyConditions(child, resource, getRule),
);
} else if (isNotCriteria(criteria)) {
return !applyConditions(criteria.not, resource, getRule);
}
return getRule(criteria.rule).apply(resource, ...criteria.params);
};
/**
* Create an express Router which provides an authorization route to allow integration between the
* permission backend and other Backstage backend plugins. Plugin owners that wish to support
* conditional authorization for their resources should add the router created by this function
* to their express app inside their `createRouter` implementation.
*
* @remarks
*
* To make this concrete, we can use the Backstage software catalog as an example. The catalog has
* conditional rules around access to specific _entities_ in the catalog. The _type_ of resource is
* captured here as `resourceType`, a string identifier (`catalog-entity` in this example) that can
* be provided with permission definitions. This is merely a _type_ to verify that conditions in an
* authorization policy are constructed correctly, not a reference to a specific resource.
*
* The `rules` parameter is an array of {@link PermissionRule}s that introduce conditional
* filtering logic for resources; for the catalog, these are things like `isEntityOwner` or
* `hasAnnotation`. Rules describe how to filter a list of resources, and the `conditions` returned
* allow these rules to be applied with specific parameters (such as 'group:default/team-a', or
* 'backstage.io/edit-url').
*
* The `getResource` argument should load a resource by reference. For the catalog, this is an
* {@link @backstage/catalog-model#EntityRef}. For other plugins, this can be any serialized format.
* This is used to construct the `createPermissionIntegrationRouter`, a function to add an
* authorization route to your backend plugin. This route will be called by the `permission-backend`
* when authorization conditions relating to this plugin need to be evaluated.
* @public
*/
export const createPermissionIntegrationRouter = <TResource>({
resourceType,
rules,
getResource,
}: {
resourceType: string;
rules: PermissionRule<TResource, any>[];
getResource: (resourceRef: string) => Promise<TResource | undefined>;
}): Router => {
const router = Router();
const getRule = createGetRule(rules);
router.post(
'/permissions/apply-conditions',
express.json(),
async (
req,
res: Response<
| {
result: Omit<AuthorizeResult, AuthorizeResult.CONDITIONAL>;
}
| string
>,
) => {
const parseResult = applyConditionsRequestSchema.safeParse(req.body);
if (!parseResult.success) {
return res.status(400).send(`Invalid request body.`);
}
const { data: body } = parseResult;
if (body.resourceType !== resourceType) {
return res
.status(400)
.send(`Unexpected resource type: ${body.resourceType}.`);
}
const resource = await getResource(body.resourceRef);
if (!resource) {
return res
.status(400)
.send(`Resource for ref ${body.resourceRef} not found.`);
}
return res.status(200).json({
result: applyConditions(body.conditions, resource, getRule)
? AuthorizeResult.ALLOW
: AuthorizeResult.DENY,
});
},
);
return router;
};
@@ -0,0 +1,20 @@
/*
* Copyright 2021 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 './createConditionFactory';
export * from './createConditionExports';
export * from './createConditionTransformer';
export * from './createPermissionIntegrationRouter';
@@ -0,0 +1,86 @@
/*
* Copyright 2021 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 {
createGetRule,
isAndCriteria,
isNotCriteria,
isOrCriteria,
} from './util';
describe('permission integration utils', () => {
describe('createGetRule', () => {
let getRule: ReturnType<typeof createGetRule>;
const testRule1 = {
name: 'test-rule-1',
description: 'Test rule 1',
apply: jest.fn(),
toQuery: jest.fn(),
};
const testRule2 = {
name: 'test-rule-2',
description: 'Test rule 2',
apply: jest.fn(),
toQuery: jest.fn(),
};
beforeEach(() => {
getRule = createGetRule([testRule1, testRule2]);
});
it('returns the rule matching the supplied name', () => {
expect(getRule('test-rule-1')).toBe(testRule1);
});
it('throws if there is no rule for the supplied name', () => {
expect(() => getRule('test-rule-3')).toThrowError(
/unexpected permission rule/i,
);
});
});
describe('isOrCriteria', () => {
it('returns true if input has a top-level "anyOf" property', () => {
expect(isOrCriteria({ anyOf: { not: { allOf: [] } } })).toEqual(true);
});
it('returns false if input does not have a top-level "anyOf" property', () => {
expect(isOrCriteria({ allOf: { not: { anyOf: [] } } })).toEqual(false);
});
});
describe('isAndCriteria', () => {
it('returns true if input has a top-level "allOf" property', () => {
expect(isAndCriteria({ allOf: { not: { anyOf: [] } } })).toEqual(true);
});
it('returns false if input does not have a top-level "allOf" property', () => {
expect(isAndCriteria({ anyOf: { not: { allOf: [] } } })).toEqual(false);
});
});
describe('isNotCriteria', () => {
it('returns true if input has a top-level "not" property', () => {
expect(isNotCriteria({ not: { allOf: [{ anyOf: [] }] } })).toEqual(true);
});
it('returns false if input does not have a top-level "not" property', () => {
expect(isNotCriteria({ anyOf: { not: { allOf: [] } } })).toEqual(false);
});
});
});
@@ -0,0 +1,49 @@
/*
* Copyright 2021 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 { PermissionCriteria } from '@backstage/plugin-permission-common';
import { PermissionRule } from '../types';
export const isAndCriteria = (
filter: PermissionCriteria<unknown>,
): filter is { allOf: PermissionCriteria<unknown>[] } =>
Object.prototype.hasOwnProperty.call(filter, 'allOf');
export const isOrCriteria = (
filter: PermissionCriteria<unknown>,
): filter is { anyOf: PermissionCriteria<unknown>[] } =>
Object.prototype.hasOwnProperty.call(filter, 'anyOf');
export const isNotCriteria = (
filter: PermissionCriteria<unknown>,
): filter is { not: PermissionCriteria<unknown> } =>
Object.prototype.hasOwnProperty.call(filter, 'not');
export const createGetRule = <TResource, TQuery>(
rules: PermissionRule<TResource, TQuery>[],
) => {
const rulesMap = new Map(Object.values(rules).map(rule => [rule.name, rule]));
return (name: string): PermissionRule<TResource, TQuery> => {
const rule = rulesMap.get(name);
if (!rule) {
throw new Error(`Unexpected permission rule: ${name}`);
}
return rule;
};
};
@@ -0,0 +1,22 @@
/*
* Copyright 2021 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 type {
ConditionalPolicyResult,
PermissionPolicy,
PolicyAuthorizeRequest,
PolicyResult,
} from './types';
@@ -0,0 +1,90 @@
/*
* Copyright 2021 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 {
AuthorizeRequest,
AuthorizeResult,
PermissionCondition,
PermissionCriteria,
} from '@backstage/plugin-permission-common';
import { BackstageIdentity } from '@backstage/plugin-auth-backend';
/**
* An authorization request to be evaluated by the {@link PermissionPolicy}.
*
* @remarks
*
* This differs from {@link @backstage/permission-common#AuthorizeRequest} in that `resourceRef`
* should never be provided. This forces policies to be written in a way that's compatible with
* filtering collections of resources at data load time.
*
* @public
*/
export type PolicyAuthorizeRequest = Omit<AuthorizeRequest, 'resourceRef'>;
/**
* A conditional result to an authorization request, returned by the {@link PermissionPolicy}.
*
* @remarks
*
* This indicates that the policy allows authorization for the request, given that the returned
* conditions hold when evaluated. The conditions will be evaluated by the corresponding plugin
* which knows about the referenced permission rules.
*
* Similar to {@link @backstage/permission-common#AuthorizeResult}, but with the plugin and resource
* identifiers needed to evaluate the returned conditions.
* @public
*/
export type ConditionalPolicyResult = {
result: AuthorizeResult.CONDITIONAL;
conditions: {
pluginId: string;
resourceType: string;
conditions: PermissionCriteria<PermissionCondition>;
};
};
/**
* The result of evaluating an authorization request with a {@link PermissionPolicy}.
*
* @public
*/
export type PolicyResult =
| { result: AuthorizeResult.ALLOW | AuthorizeResult.DENY }
| ConditionalPolicyResult;
/**
* A policy to evaluate authorization requests for any permissioned action performed in Backstage.
*
* @remarks
*
* This takes as input a permission and an optional Backstage identity, and should return ALLOW if
* the user is permitted to execute that action; otherwise DENY. For permissions relating to
* resources, such a catalog entities, a conditional response can also be returned. This states
* that the action is allowed if the conditions provided hold true.
*
* Conditions are a rule, and parameters to evaluate against that rule. For example, the rule might
* be `isOwner` and the parameters a collection of entityRefs; if one of the entityRefs matches
* the `owner` field on a catalog entity, this would resolve to ALLOW.
*
* @public
*/
export interface PermissionPolicy {
handle(
request: PolicyAuthorizeRequest,
user?: BackstageIdentity,
): Promise<PolicyResult>;
}
+17
View File
@@ -0,0 +1,17 @@
/*
* Copyright 2021 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 {};
+56
View File
@@ -0,0 +1,56 @@
/*
* Copyright 2021 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 type { PermissionCriteria } from '@backstage/plugin-permission-common';
/**
* A conditional rule that can be provided in an
* {@link @backstage/permission-common#AuthorizeResult} response to an authorization request.
*
* @remarks
*
* Rules can either be evaluated against a resource loaded in memory, or used as filters when
* loading a collection of resources from a data source. The `apply` and `toQuery` methods implement
* these two concepts.
*
* The two operations should always have the same logical result. If they dont, the effective
* outcome of an authorization operation will sometimes differ depending on how the authorization
* check was performed.
*
* @public
*/
export type PermissionRule<
TResource,
TQuery,
TParams extends unknown[] = unknown[],
> = {
name: string;
description: string;
/**
* Apply this rule to a resource already loaded from a backing data source. The params are
* arguments supplied for the rule; for example, a rule could be `isOwner` with entityRefs as the
* params.
*/
apply(resource: TResource, ...params: TParams): boolean;
/**
* Translate this rule to criteria suitable for use in querying a backing data store. The criteria
* can be used for loading a collection of resources efficiently with conditional criteria already
* applied.
*/
toQuery(...params: TParams): PermissionCriteria<TQuery>;
};