permission-node: refactor and split createPermissionIntegration
This refactor makes the createPermissionIntegration system much more flexible by splitting it up into a few different helpers with different responsibilities. This frees up plugin authors to connect together the different parts of the permission integration in whatever way is convenient for them, and makes the process of registering additional permission rules a bit more explicit, by requiring them to be passed in when constructing the systems for transforming or applying conditions. Signed-off-by: Mike Lewis <mtlewis@users.noreply.github.com> Co-authored-by: Joon Park <joonp@spotify.com> Co-authored-by: Tim Hansen <timbonicus@gmail.com>
This commit is contained in:
@@ -36,14 +36,6 @@ export type ConditionalPolicyResult = {
|
||||
};
|
||||
};
|
||||
|
||||
// @public
|
||||
export const conditionFor: <TParams extends any[]>(
|
||||
rule: PermissionRule<unknown, unknown, TParams>,
|
||||
) => (...params: TParams) => {
|
||||
rule: string;
|
||||
params: TParams;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type Conditions<
|
||||
TRules extends Record<string, PermissionRule<any, any>>,
|
||||
@@ -52,41 +44,58 @@ export type Conditions<
|
||||
};
|
||||
|
||||
// @public
|
||||
export const createPermissionIntegration: <
|
||||
export type ConditionTransformer<TQuery> = (
|
||||
conditions: PermissionCriteria<PermissionCondition>,
|
||||
) => PermissionCriteria<TQuery>;
|
||||
|
||||
// @public
|
||||
export const createConditionExports: <
|
||||
TResource,
|
||||
TRules extends {
|
||||
[key: string]: PermissionRule<TResource, any, unknown[]>;
|
||||
},
|
||||
TGetResourceParams extends any[] = [],
|
||||
TRules extends Record<string, PermissionRule<TResource, any, unknown[]>>,
|
||||
>({
|
||||
pluginId,
|
||||
resourceType,
|
||||
rules,
|
||||
getResource,
|
||||
}: {
|
||||
pluginId: string;
|
||||
resourceType: string;
|
||||
rules: TRules;
|
||||
getResource: (
|
||||
resourceRef: string,
|
||||
...params: TGetResourceParams
|
||||
) => Promise<TResource | undefined>;
|
||||
}) => {
|
||||
createPermissionIntegrationRouter: (...params: TGetResourceParams) => Router;
|
||||
toQuery: (
|
||||
conditions: PermissionCriteria<PermissionCondition>,
|
||||
) => PermissionCriteria<QueryType<TRules>>;
|
||||
conditions: Conditions<TRules>;
|
||||
createConditions: (conditions: PermissionCriteria<PermissionCondition>) => {
|
||||
pluginId: string;
|
||||
resourceType: string;
|
||||
conditions: PermissionCriteria<PermissionCondition>;
|
||||
};
|
||||
registerPermissionRule: (
|
||||
rule: PermissionRule<TResource, QueryType<TRules>, unknown[]>,
|
||||
) => void;
|
||||
};
|
||||
|
||||
// @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)
|
||||
@@ -117,12 +126,4 @@ export type PolicyResult =
|
||||
result: AuthorizeResult.ALLOW | AuthorizeResult.DENY;
|
||||
}
|
||||
| ConditionalPolicyResult;
|
||||
|
||||
// @public
|
||||
export type QueryType<TRules> = TRules extends Record<
|
||||
string,
|
||||
PermissionRule<any, infer TQuery, any>
|
||||
>
|
||||
? TQuery
|
||||
: never;
|
||||
```
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"main": "dist/index.cjs.js",
|
||||
"module": "dist/index.esm.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"homepage": "https://backstage.io",
|
||||
@@ -32,7 +31,7 @@
|
||||
"dependencies": {
|
||||
"@backstage/plugin-auth-backend": "^0.4.6",
|
||||
"@backstage/plugin-permission-common": "^0.1.0",
|
||||
"@types/express": "*",
|
||||
"@types/express": "^4.17.6",
|
||||
"express": "^4.17.1",
|
||||
"zod": "^3.11.6"
|
||||
},
|
||||
|
||||
@@ -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,94 @@
|
||||
/*
|
||||
* 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. It 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>>,
|
||||
>({
|
||||
pluginId,
|
||||
resourceType,
|
||||
rules,
|
||||
}: {
|
||||
pluginId: string;
|
||||
resourceType: string;
|
||||
rules: TRules;
|
||||
}): {
|
||||
conditions: Conditions<TRules>;
|
||||
createConditions: (conditions: PermissionCriteria<PermissionCondition>) => {
|
||||
pluginId: string;
|
||||
resourceType: string;
|
||||
conditions: PermissionCriteria<PermissionCondition>;
|
||||
};
|
||||
} => ({
|
||||
conditions: Object.entries(rules).reduce(
|
||||
(acc, [key, rule]) => ({
|
||||
...acc,
|
||||
[key]: createConditionFactory(rule),
|
||||
}),
|
||||
{} as Conditions<TRules>,
|
||||
),
|
||||
createConditions: (conditions: PermissionCriteria<PermissionCondition>) => ({
|
||||
pluginId,
|
||||
resourceType,
|
||||
conditions,
|
||||
}),
|
||||
});
|
||||
+4
-4
@@ -14,9 +14,9 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { conditionFor } from './conditionFor';
|
||||
import { createConditionFactory } from './createConditionFactory';
|
||||
|
||||
describe('conditionFor', () => {
|
||||
describe('createConditionFactory', () => {
|
||||
const testRule = {
|
||||
name: 'test-rule',
|
||||
description: 'test-description',
|
||||
@@ -25,12 +25,12 @@ describe('conditionFor', () => {
|
||||
};
|
||||
|
||||
it('returns a function', () => {
|
||||
expect(conditionFor(testRule)).toEqual(expect.any(Function));
|
||||
expect(createConditionFactory(testRule)).toEqual(expect.any(Function));
|
||||
});
|
||||
|
||||
describe('return value', () => {
|
||||
it('constructs a condition with the rule name and supplied params', () => {
|
||||
const conditionFactory = conditionFor(testRule);
|
||||
const conditionFactory = createConditionFactory(testRule);
|
||||
expect(conditionFactory('a', 'b', 1, 2)).toEqual({
|
||||
rule: 'test-rule',
|
||||
params: ['a', 'b', 1, 2],
|
||||
+7
-6
@@ -17,19 +17,20 @@
|
||||
import { PermissionRule } from '../types';
|
||||
|
||||
/**
|
||||
* Creates a condition function for a given authorization rule and parameter type.
|
||||
* Creates a condition factory function for a given authorization rule and parameter types.
|
||||
*
|
||||
* 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 createPermissionIntegration} helper, which creates
|
||||
* conditions for the rules supplied. However, a different plugin can also add rules to this
|
||||
* integration (using the returned `registerPermissionRule` from this function), and create the
|
||||
* condition to be used in an {@link PermissionPolicy} using this method directly.
|
||||
* 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 with.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const conditionFor =
|
||||
export const createConditionFactory =
|
||||
<TParams extends any[]>(rule: PermissionRule<unknown, unknown, TParams>) =>
|
||||
(...params: TParams) => ({
|
||||
rule: rule.name,
|
||||
@@ -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);
|
||||
};
|
||||
@@ -1,293 +0,0 @@
|
||||
/*
|
||||
* 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 { createPermissionIntegration } from './createPermissionIntegration';
|
||||
|
||||
const mockGetResource: jest.MockedFunction<
|
||||
(resourceRef: string) => Promise<any>
|
||||
> = jest.fn((resourceRef: string) =>
|
||||
Promise.resolve({
|
||||
resourceRef,
|
||||
}),
|
||||
);
|
||||
|
||||
const testIntegration = () =>
|
||||
createPermissionIntegration({
|
||||
pluginId: 'test-plugin',
|
||||
resourceType: 'test-resource',
|
||||
getResource: mockGetResource,
|
||||
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('createPermissionIntegration', () => {
|
||||
describe('createPermissionIntegrationRouter', () => {
|
||||
let app: Express;
|
||||
let router: Router;
|
||||
|
||||
beforeEach(() => {
|
||||
const { createPermissionIntegrationRouter } = testIntegration();
|
||||
|
||||
router = createPermissionIntegrationRouter();
|
||||
app = express().use(router);
|
||||
});
|
||||
|
||||
it('works', async () => {
|
||||
expect(router).toBeDefined();
|
||||
});
|
||||
|
||||
describe('POST /permissions/apply-conditions', () => {
|
||||
it('returns 200/ALLOW when criteria match', async () => {
|
||||
const response = await request(app)
|
||||
.post('/permissions/apply-conditions')
|
||||
.send({
|
||||
resourceRef: 'default:test/resource',
|
||||
resourceType: 'test-resource',
|
||||
conditions: {
|
||||
rule: 'testRule1',
|
||||
params: ['a', 1],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual({ result: AuthorizeResult.ALLOW });
|
||||
});
|
||||
|
||||
it('returns 200/DENY when criteria do not match', async () => {
|
||||
const response = await request(app)
|
||||
.post('/permissions/apply-conditions')
|
||||
.send({
|
||||
resourceRef: 'default:test/resource',
|
||||
resourceType: 'test-resource',
|
||||
conditions: {
|
||||
anyOf: [],
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('toQuery', () => {
|
||||
it('converts conditions to plugin-specific queries using rule toQuery methods', () => {
|
||||
const { toQuery } = testIntegration();
|
||||
|
||||
expect(
|
||||
toQuery({
|
||||
anyOf: [
|
||||
{
|
||||
allOf: [
|
||||
{ rule: 'testRule1', params: ['a', 1] },
|
||||
{ rule: 'testRule2', params: [{ foo: 'bar' }] },
|
||||
],
|
||||
},
|
||||
{
|
||||
not: { rule: 'testRule1', params: ['b', 2] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
anyOf: [
|
||||
{
|
||||
allOf: [
|
||||
{ query: 'testRule1', params: ['a', 1] },
|
||||
{ query: 'testRule2', params: [{ foo: 'bar' }] },
|
||||
],
|
||||
},
|
||||
{
|
||||
not: { query: 'testRule1', params: ['b', 2] },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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] }],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerPermissionRule', () => {
|
||||
it('adds support for the new rule in toQuery', () => {
|
||||
const { registerPermissionRule, toQuery } = testIntegration();
|
||||
|
||||
registerPermissionRule({
|
||||
name: 'testRule3',
|
||||
description: 'Test rule 3',
|
||||
apply: jest.fn((_resource: any, _firstParam: string) => false),
|
||||
toQuery: jest.fn((firstParam: string) => ({
|
||||
query: 'testRule3',
|
||||
params: [firstParam],
|
||||
})),
|
||||
});
|
||||
|
||||
expect(
|
||||
toQuery({
|
||||
rule: 'testRule3',
|
||||
params: ['abc'],
|
||||
}),
|
||||
).toEqual({
|
||||
query: 'testRule3',
|
||||
params: ['abc'],
|
||||
});
|
||||
});
|
||||
|
||||
it('adds support for the new rule in the apply-conditions endpoint', async () => {
|
||||
const { registerPermissionRule, createPermissionIntegrationRouter } =
|
||||
testIntegration();
|
||||
|
||||
const app = express().use(createPermissionIntegrationRouter());
|
||||
|
||||
registerPermissionRule({
|
||||
name: 'testRule3',
|
||||
description: 'Test rule 3',
|
||||
apply: jest.fn((_resource: any, _firstParam: string) => false),
|
||||
toQuery: jest.fn((firstParam: string) => ({
|
||||
query: 'testRule3',
|
||||
params: [firstParam],
|
||||
})),
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.post('/permissions/apply-conditions')
|
||||
.send({
|
||||
resourceRef: 'default:test/resource',
|
||||
resourceType: 'test-resource',
|
||||
conditions: {
|
||||
not: {
|
||||
rule: 'testRule3',
|
||||
params: ['a'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual({ result: AuthorizeResult.ALLOW });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,242 +0,0 @@
|
||||
/*
|
||||
* 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 { conditionFor } from './conditionFor';
|
||||
import { applyConditions, mapConditions } 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, to finalize a conditional
|
||||
* authorization response.
|
||||
* @public
|
||||
*/
|
||||
export type ApplyConditionsRequest = {
|
||||
resourceRef: string;
|
||||
resourceType: string;
|
||||
conditions: PermissionCriteria<PermissionCondition>;
|
||||
};
|
||||
|
||||
/**
|
||||
* An authorization condition, which is a function to evaluate a given {@link PermissionRule} with
|
||||
* a specific set of parameters.
|
||||
* @see createPermissionIntegration
|
||||
* @public
|
||||
*/
|
||||
export type Condition<TRule> = TRule extends PermissionRule<
|
||||
any,
|
||||
any,
|
||||
infer TParams
|
||||
>
|
||||
? (...params: TParams) => PermissionCondition<TParams>
|
||||
: never;
|
||||
|
||||
/**
|
||||
* A collection of keyed {@link Condition} functions produced from {@link PermissionRule}.
|
||||
* @see createPermissionIntegration
|
||||
* @public
|
||||
*/
|
||||
export type Conditions<
|
||||
TRules extends Record<string, PermissionRule<any, any>>,
|
||||
> = {
|
||||
[Name in keyof TRules]: Condition<TRules[Name]>;
|
||||
};
|
||||
|
||||
/**
|
||||
* The plugin-specific type which authorization conditions are translated into, for plugin use in
|
||||
* filtering resources.
|
||||
* @public
|
||||
*/
|
||||
export type QueryType<TRules> = TRules extends Record<
|
||||
string,
|
||||
PermissionRule<any, infer TQuery, any>
|
||||
>
|
||||
? TQuery
|
||||
: never;
|
||||
|
||||
/**
|
||||
* Create a permission and authorization integration for a plugin that supports _conditional
|
||||
* authorization_ for resources.
|
||||
*
|
||||
* 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` are a map of {@link PermissionRule} 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 createPermissionIntegration = <
|
||||
TResource,
|
||||
TRules extends { [key: string]: PermissionRule<TResource, any> },
|
||||
TGetResourceParams extends any[] = [],
|
||||
>({
|
||||
pluginId,
|
||||
resourceType,
|
||||
rules,
|
||||
getResource,
|
||||
}: {
|
||||
pluginId: string;
|
||||
resourceType: string;
|
||||
rules: TRules;
|
||||
getResource: (
|
||||
resourceRef: string,
|
||||
...params: TGetResourceParams
|
||||
) => Promise<TResource | undefined>;
|
||||
}): {
|
||||
createPermissionIntegrationRouter: (...params: TGetResourceParams) => Router;
|
||||
toQuery: (
|
||||
conditions: PermissionCriteria<PermissionCondition>,
|
||||
) => PermissionCriteria<QueryType<TRules>>;
|
||||
conditions: Conditions<TRules>;
|
||||
createConditions: (conditions: PermissionCriteria<PermissionCondition>) => {
|
||||
pluginId: string;
|
||||
resourceType: string;
|
||||
conditions: PermissionCriteria<PermissionCondition>;
|
||||
};
|
||||
registerPermissionRule: (
|
||||
rule: PermissionRule<TResource, QueryType<TRules>>,
|
||||
) => void;
|
||||
} => {
|
||||
const rulesMap = new Map(Object.values(rules).map(rule => [rule.name, rule]));
|
||||
|
||||
const getRule = (name: string) => {
|
||||
const rule = rulesMap.get(name);
|
||||
|
||||
if (!rule) {
|
||||
throw new Error(`Unexpected permission rule: ${name}`);
|
||||
}
|
||||
|
||||
return rule;
|
||||
};
|
||||
|
||||
return {
|
||||
createPermissionIntegrationRouter: (
|
||||
...getResourceParams: TGetResourceParams
|
||||
) => {
|
||||
const router = Router();
|
||||
|
||||
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,
|
||||
...getResourceParams,
|
||||
);
|
||||
|
||||
if (!resource) {
|
||||
return res
|
||||
.status(400)
|
||||
.send(`Resource for ref ${body.resourceRef} not found.`);
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
result: applyConditions(body.conditions, ({ rule, params }) =>
|
||||
getRule(rule).apply(resource, ...params),
|
||||
)
|
||||
? AuthorizeResult.ALLOW
|
||||
: AuthorizeResult.DENY,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
return router;
|
||||
},
|
||||
toQuery: (
|
||||
conditions: PermissionCriteria<PermissionCondition>,
|
||||
): PermissionCriteria<QueryType<TRules>> => {
|
||||
return mapConditions(conditions, ({ rule, params }) =>
|
||||
getRule(rule).toQuery(...params),
|
||||
);
|
||||
},
|
||||
conditions: Object.entries(rules).reduce(
|
||||
(acc, [key, rule]) => ({
|
||||
...acc,
|
||||
[key]: conditionFor(rule),
|
||||
}),
|
||||
{} as Conditions<TRules>,
|
||||
),
|
||||
createConditions: (
|
||||
conditions: PermissionCriteria<PermissionCondition>,
|
||||
) => ({
|
||||
pluginId,
|
||||
resourceType,
|
||||
conditions,
|
||||
}),
|
||||
registerPermissionRule: rule => {
|
||||
rulesMap.set(rule.name, rule);
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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,165 @@
|
||||
/*
|
||||
* 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>;
|
||||
};
|
||||
|
||||
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.
|
||||
*
|
||||
* 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;
|
||||
};
|
||||
@@ -14,5 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export * from './conditionFor';
|
||||
export * from './createPermissionIntegration';
|
||||
export * from './createConditionFactory';
|
||||
export * from './createConditionExports';
|
||||
export * from './createConditionTransformer';
|
||||
export * from './createPermissionIntegrationRouter';
|
||||
|
||||
@@ -15,188 +15,72 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
PermissionCondition,
|
||||
PermissionCriteria,
|
||||
} from '@backstage/plugin-permission-common';
|
||||
import { applyConditions, mapConditions } from './util';
|
||||
createGetRule,
|
||||
isAndCriteria,
|
||||
isNotCriteria,
|
||||
isOrCriteria,
|
||||
} from './util';
|
||||
|
||||
describe('integration utils', () => {
|
||||
const testCases: {
|
||||
criteria: PermissionCriteria<PermissionCondition>;
|
||||
expectedResult: {
|
||||
applyConditions: boolean;
|
||||
mapConditions: PermissionCriteria<string>;
|
||||
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(),
|
||||
};
|
||||
}[] = [
|
||||
{
|
||||
criteria: { rule: 'test-rule-1', params: [] },
|
||||
expectedResult: {
|
||||
applyConditions: true,
|
||||
mapConditions: 'test-rule-1',
|
||||
},
|
||||
},
|
||||
{
|
||||
criteria: { rule: 'test-rule-2', params: [] },
|
||||
expectedResult: {
|
||||
applyConditions: false,
|
||||
mapConditions: 'test-rule-2',
|
||||
},
|
||||
},
|
||||
{
|
||||
criteria: {
|
||||
anyOf: [
|
||||
{ rule: 'test-rule-1', params: ['a', 'b'] },
|
||||
{ rule: 'test-rule-2', params: ['c', 'd'] },
|
||||
],
|
||||
},
|
||||
expectedResult: {
|
||||
applyConditions: true,
|
||||
mapConditions: {
|
||||
anyOf: ['test-rule-1:a,b', 'test-rule-2:c,d'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
criteria: {
|
||||
allOf: [
|
||||
{ rule: 'test-rule-1', params: ['e', 'f'] },
|
||||
{ rule: 'test-rule-2', params: ['g', 'h'] },
|
||||
],
|
||||
},
|
||||
expectedResult: {
|
||||
applyConditions: false,
|
||||
mapConditions: {
|
||||
allOf: ['test-rule-1:e,f', 'test-rule-2:g,h'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
criteria: {
|
||||
not: { rule: 'test-rule-2', params: ['i'] },
|
||||
},
|
||||
expectedResult: {
|
||||
applyConditions: true,
|
||||
mapConditions: {
|
||||
not: 'test-rule-2:i',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
criteria: {
|
||||
allOf: [
|
||||
{
|
||||
anyOf: [
|
||||
{ rule: 'test-rule-1', params: ['j'] },
|
||||
{ rule: 'test-rule-2', params: ['k'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
not: {
|
||||
allOf: [
|
||||
{ rule: 'test-rule-1', params: ['l'] },
|
||||
{ rule: 'test-rule-2', params: ['m'] },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
expectedResult: {
|
||||
applyConditions: true,
|
||||
mapConditions: {
|
||||
allOf: [
|
||||
{
|
||||
anyOf: ['test-rule-1:j', 'test-rule-2:k'],
|
||||
},
|
||||
{
|
||||
not: {
|
||||
allOf: ['test-rule-1:l', 'test-rule-2:m'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
criteria: {
|
||||
allOf: [
|
||||
{
|
||||
anyOf: [
|
||||
{ rule: 'test-rule-1', params: ['j'] },
|
||||
{ rule: 'test-rule-2', params: ['k'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
not: {
|
||||
allOf: [
|
||||
{ rule: 'test-rule-1', params: ['l'] },
|
||||
{ not: { rule: 'test-rule-2', params: ['m'] } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
expectedResult: {
|
||||
applyConditions: false,
|
||||
mapConditions: {
|
||||
allOf: [
|
||||
{
|
||||
anyOf: ['test-rule-1:j', 'test-rule-2:k'],
|
||||
},
|
||||
{
|
||||
not: {
|
||||
allOf: ['test-rule-1:l', { not: 'test-rule-2:m' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe('applyConditions', () => {
|
||||
const applyFn = jest.fn(condition => condition.rule === 'test-rule-1');
|
||||
const testRule2 = {
|
||||
name: 'test-rule-2',
|
||||
description: 'Test rule 2',
|
||||
apply: jest.fn(),
|
||||
toQuery: jest.fn(),
|
||||
};
|
||||
|
||||
it('invokes applyFn with rules to determine result', () => {
|
||||
applyConditions({ rule: 'test-rule-1', params: ['foo', 'bar'] }, applyFn);
|
||||
|
||||
expect(applyFn).toHaveBeenCalledWith({
|
||||
rule: 'test-rule-1',
|
||||
params: ['foo', 'bar'],
|
||||
});
|
||||
beforeEach(() => {
|
||||
getRule = createGetRule([testRule1, testRule2]);
|
||||
});
|
||||
|
||||
it.each(testCases)(
|
||||
'works with criteria %#',
|
||||
({ criteria, expectedResult }) => {
|
||||
expect(applyConditions(criteria, applyFn)).toEqual(
|
||||
expectedResult.applyConditions,
|
||||
);
|
||||
},
|
||||
);
|
||||
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('mapConditions', () => {
|
||||
const mapFn = jest.fn(
|
||||
({ rule, params }) =>
|
||||
`${rule}${params.length ? `:${params.join(',')}` : ''}`,
|
||||
);
|
||||
|
||||
it('invokes mapFn to transform conditions', () => {
|
||||
mapConditions({ rule: 'test-rule-1', params: ['foo', 'bar'] }, mapFn);
|
||||
|
||||
expect(mapFn).toHaveBeenCalledWith({
|
||||
rule: 'test-rule-1',
|
||||
params: ['foo', 'bar'],
|
||||
});
|
||||
describe('isOrCriteria', () => {
|
||||
it('returns true if input has a top-level "anyOf" property', () => {
|
||||
expect(isOrCriteria({ anyOf: { not: { allOf: [] } } })).toEqual(true);
|
||||
});
|
||||
|
||||
it.each(testCases)(
|
||||
'works with criteria %#',
|
||||
({ criteria, expectedResult }) => {
|
||||
expect(mapConditions(criteria, mapFn)).toEqual(
|
||||
expectedResult.mapConditions,
|
||||
);
|
||||
},
|
||||
);
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,54 +15,35 @@
|
||||
*/
|
||||
|
||||
import { PermissionCriteria } from '@backstage/plugin-permission-common';
|
||||
import { PermissionRule } from '../types';
|
||||
|
||||
const isAndCriteria = (
|
||||
export const isAndCriteria = (
|
||||
filter: PermissionCriteria<unknown>,
|
||||
): filter is { allOf: PermissionCriteria<unknown>[] } =>
|
||||
Object.prototype.hasOwnProperty.call(filter, 'allOf');
|
||||
|
||||
const isOrCriteria = (
|
||||
export const isOrCriteria = (
|
||||
filter: PermissionCriteria<unknown>,
|
||||
): filter is { anyOf: PermissionCriteria<unknown>[] } =>
|
||||
Object.prototype.hasOwnProperty.call(filter, 'anyOf');
|
||||
|
||||
const isNotCriteria = (
|
||||
export const isNotCriteria = (
|
||||
filter: PermissionCriteria<unknown>,
|
||||
): filter is { not: PermissionCriteria<unknown> } =>
|
||||
Object.prototype.hasOwnProperty.call(filter, 'not');
|
||||
|
||||
export const mapConditions = <TQueryIn, TQueryOut>(
|
||||
criteria: PermissionCriteria<TQueryIn>,
|
||||
mapFn: (condition: TQueryIn) => TQueryOut,
|
||||
): PermissionCriteria<TQueryOut> => {
|
||||
if (isAndCriteria(criteria)) {
|
||||
return {
|
||||
allOf: criteria.allOf.map(child => mapConditions(child, mapFn)),
|
||||
};
|
||||
} else if (isOrCriteria(criteria)) {
|
||||
return {
|
||||
anyOf: criteria.anyOf.map(child => mapConditions(child, mapFn)),
|
||||
};
|
||||
} else if (isNotCriteria(criteria)) {
|
||||
return {
|
||||
not: mapConditions(criteria.not, mapFn),
|
||||
};
|
||||
}
|
||||
export const createGetRule = <TResource, TQuery>(
|
||||
rules: PermissionRule<TResource, TQuery>[],
|
||||
) => {
|
||||
const rulesMap = new Map(Object.values(rules).map(rule => [rule.name, rule]));
|
||||
|
||||
return mapFn(criteria);
|
||||
};
|
||||
|
||||
export const applyConditions = <TQuery>(
|
||||
criteria: PermissionCriteria<TQuery>,
|
||||
applyFn: (condition: TQuery) => boolean,
|
||||
): boolean => {
|
||||
if (isAndCriteria(criteria)) {
|
||||
return criteria.allOf.every(child => applyConditions(child, applyFn));
|
||||
} else if (isOrCriteria(criteria)) {
|
||||
return criteria.anyOf.some(child => applyConditions(child, applyFn));
|
||||
} else if (isNotCriteria(criteria)) {
|
||||
return !applyConditions(criteria.not, applyFn);
|
||||
}
|
||||
|
||||
return applyFn(criteria);
|
||||
return (name: string): PermissionRule<TResource, TQuery> => {
|
||||
const rule = rulesMap.get(name);
|
||||
|
||||
if (!rule) {
|
||||
throw new Error(`Unexpected permission rule: ${name}`);
|
||||
}
|
||||
|
||||
return rule;
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user