diff --git a/plugins/permission-node/api-report.md b/plugins/permission-node/api-report.md index a292f6acd2..1aab9e1f47 100644 --- a/plugins/permission-node/api-report.md +++ b/plugins/permission-node/api-report.md @@ -36,14 +36,6 @@ export type ConditionalPolicyResult = { }; }; -// @public -export const conditionFor: ( - rule: PermissionRule, -) => (...params: TParams) => { - rule: string; - params: TParams; -}; - // @public export type Conditions< TRules extends Record>, @@ -52,41 +44,58 @@ export type Conditions< }; // @public -export const createPermissionIntegration: < +export type ConditionTransformer = ( + conditions: PermissionCriteria, +) => PermissionCriteria; + +// @public +export const createConditionExports: < TResource, - TRules extends { - [key: string]: PermissionRule; - }, - TGetResourceParams extends any[] = [], + TRules extends Record>, >({ pluginId, resourceType, rules, - getResource, }: { pluginId: string; resourceType: string; rules: TRules; - getResource: ( - resourceRef: string, - ...params: TGetResourceParams - ) => Promise; }) => { - createPermissionIntegrationRouter: (...params: TGetResourceParams) => Router; - toQuery: ( - conditions: PermissionCriteria, - ) => PermissionCriteria>; conditions: Conditions; createConditions: (conditions: PermissionCriteria) => { pluginId: string; resourceType: string; conditions: PermissionCriteria; }; - registerPermissionRule: ( - rule: PermissionRule, unknown[]>, - ) => void; }; +// @public +export const createConditionFactory: ( + rule: PermissionRule, +) => (...params: TParams) => { + rule: string; + params: TParams; +}; + +// @public +export const createConditionTransformer: < + TQuery, + TRules extends PermissionRule[], +>( + permissionRules: [...TRules], +) => ConditionTransformer; + +// @public +export const createPermissionIntegrationRouter: ({ + resourceType, + rules, + getResource, +}: { + resourceType: string; + rules: PermissionRule[]; + getResource: (resourceRef: string) => Promise; +}) => Router; + // @public export interface PermissionPolicy { // (undocumented) @@ -117,12 +126,4 @@ export type PolicyResult = result: AuthorizeResult.ALLOW | AuthorizeResult.DENY; } | ConditionalPolicyResult; - -// @public -export type QueryType = TRules extends Record< - string, - PermissionRule -> - ? TQuery - : never; ``` diff --git a/plugins/permission-node/package.json b/plugins/permission-node/package.json index 79923eae97..4a9c837eb6 100644 --- a/plugins/permission-node/package.json +++ b/plugins/permission-node/package.json @@ -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" }, diff --git a/plugins/permission-node/src/integration/createConditionExports.test.ts b/plugins/permission-node/src/integration/createConditionExports.test.ts new file mode 100644 index 0000000000..6f0f8f8632 --- /dev/null +++ b/plugins/permission-node/src/integration/createConditionExports.test.ts @@ -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] }], + }, + }); + }); + }); +}); diff --git a/plugins/permission-node/src/integration/createConditionExports.ts b/plugins/permission-node/src/integration/createConditionExports.ts new file mode 100644 index 0000000000..d7f8c675d2 --- /dev/null +++ b/plugins/permission-node/src/integration/createConditionExports.ts @@ -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 extends PermissionRule< + any, + any, + infer TParams +> + ? (...params: TParams) => PermissionCondition + : 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>, +> = { + [Name in keyof TRules]: Condition; +}; + +/** + * 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>, +>({ + pluginId, + resourceType, + rules, +}: { + pluginId: string; + resourceType: string; + rules: TRules; +}): { + conditions: Conditions; + createConditions: (conditions: PermissionCriteria) => { + pluginId: string; + resourceType: string; + conditions: PermissionCriteria; + }; +} => ({ + conditions: Object.entries(rules).reduce( + (acc, [key, rule]) => ({ + ...acc, + [key]: createConditionFactory(rule), + }), + {} as Conditions, + ), + createConditions: (conditions: PermissionCriteria) => ({ + pluginId, + resourceType, + conditions, + }), +}); diff --git a/plugins/permission-node/src/integration/conditionFor.test.ts b/plugins/permission-node/src/integration/createConditionFactory.test.ts similarity index 80% rename from plugins/permission-node/src/integration/conditionFor.test.ts rename to plugins/permission-node/src/integration/createConditionFactory.test.ts index d1099e7555..8dd43f5da6 100644 --- a/plugins/permission-node/src/integration/conditionFor.test.ts +++ b/plugins/permission-node/src/integration/createConditionFactory.test.ts @@ -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], diff --git a/plugins/permission-node/src/integration/conditionFor.ts b/plugins/permission-node/src/integration/createConditionFactory.ts similarity index 68% rename from plugins/permission-node/src/integration/conditionFor.ts rename to plugins/permission-node/src/integration/createConditionFactory.ts index 7451b35bca..066233bf41 100644 --- a/plugins/permission-node/src/integration/conditionFor.ts +++ b/plugins/permission-node/src/integration/createConditionFactory.ts @@ -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 = (rule: PermissionRule) => (...params: TParams) => ({ rule: rule.name, diff --git a/plugins/permission-node/src/integration/createConditionTransformer.test.ts b/plugins/permission-node/src/integration/createConditionTransformer.test.ts new file mode 100644 index 0000000000..8a93a4ea27 --- /dev/null +++ b/plugins/permission-node/src/integration/createConditionTransformer.test.ts @@ -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; + expectedResult: PermissionCriteria; + }[] = [ + { + 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); + }, + ); +}); diff --git a/plugins/permission-node/src/integration/createConditionTransformer.ts b/plugins/permission-node/src/integration/createConditionTransformer.ts new file mode 100644 index 0000000000..0e736b832c --- /dev/null +++ b/plugins/permission-node/src/integration/createConditionTransformer.ts @@ -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 = ( + criteria: PermissionCriteria, + getRule: (name: string) => PermissionRule, +): PermissionCriteria => { + 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 = ( + conditions: PermissionCriteria, +) => PermissionCriteria; + +/** + * 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[], +>( + permissionRules: [...TRules], +): ConditionTransformer => { + const getRule = createGetRule(permissionRules); + + return conditions => mapConditions(conditions, getRule); +}; diff --git a/plugins/permission-node/src/integration/createPermissionIntegration.test.ts b/plugins/permission-node/src/integration/createPermissionIntegration.test.ts deleted file mode 100644 index 1ef310954f..0000000000 --- a/plugins/permission-node/src/integration/createPermissionIntegration.test.ts +++ /dev/null @@ -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 -> = 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 }); - }); - }); -}); diff --git a/plugins/permission-node/src/integration/createPermissionIntegration.ts b/plugins/permission-node/src/integration/createPermissionIntegration.ts deleted file mode 100644 index 72118572e5..0000000000 --- a/plugins/permission-node/src/integration/createPermissionIntegration.ts +++ /dev/null @@ -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 -> = 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; -}; - -/** - * 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 extends PermissionRule< - any, - any, - infer TParams -> - ? (...params: TParams) => PermissionCondition - : never; - -/** - * A collection of keyed {@link Condition} functions produced from {@link PermissionRule}. - * @see createPermissionIntegration - * @public - */ -export type Conditions< - TRules extends Record>, -> = { - [Name in keyof TRules]: Condition; -}; - -/** - * The plugin-specific type which authorization conditions are translated into, for plugin use in - * filtering resources. - * @public - */ -export type QueryType = TRules extends Record< - string, - PermissionRule -> - ? 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 }, - TGetResourceParams extends any[] = [], ->({ - pluginId, - resourceType, - rules, - getResource, -}: { - pluginId: string; - resourceType: string; - rules: TRules; - getResource: ( - resourceRef: string, - ...params: TGetResourceParams - ) => Promise; -}): { - createPermissionIntegrationRouter: (...params: TGetResourceParams) => Router; - toQuery: ( - conditions: PermissionCriteria, - ) => PermissionCriteria>; - conditions: Conditions; - createConditions: (conditions: PermissionCriteria) => { - pluginId: string; - resourceType: string; - conditions: PermissionCriteria; - }; - registerPermissionRule: ( - rule: PermissionRule>, - ) => 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; - } - | 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, - ): PermissionCriteria> => { - return mapConditions(conditions, ({ rule, params }) => - getRule(rule).toQuery(...params), - ); - }, - conditions: Object.entries(rules).reduce( - (acc, [key, rule]) => ({ - ...acc, - [key]: conditionFor(rule), - }), - {} as Conditions, - ), - createConditions: ( - conditions: PermissionCriteria, - ) => ({ - pluginId, - resourceType, - conditions, - }), - registerPermissionRule: rule => { - rulesMap.set(rule.name, rule); - }, - }; -}; diff --git a/plugins/permission-node/src/integration/createPermissionIntegrationRouter.test.ts b/plugins/permission-node/src/integration/createPermissionIntegrationRouter.test.ts new file mode 100644 index 0000000000..af216dfcb8 --- /dev/null +++ b/plugins/permission-node/src/integration/createPermissionIntegrationRouter.test.ts @@ -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 +> = 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, + ); + }); + }); +}); diff --git a/plugins/permission-node/src/integration/createPermissionIntegrationRouter.ts b/plugins/permission-node/src/integration/createPermissionIntegrationRouter.ts new file mode 100644 index 0000000000..6694b893b7 --- /dev/null +++ b/plugins/permission-node/src/integration/createPermissionIntegrationRouter.ts @@ -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 +> = 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; +}; + +const applyConditions = ( + criteria: PermissionCriteria, + resource: TResource, + getRule: (name: string) => PermissionRule, +): 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 = ({ + resourceType, + rules, + getResource, +}: { + resourceType: string; + rules: PermissionRule[]; + getResource: (resourceRef: string) => Promise; +}): Router => { + const router = Router(); + + const getRule = createGetRule(rules); + + router.post( + '/permissions/apply-conditions', + express.json(), + async ( + req, + res: Response< + | { + result: Omit; + } + | 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; +}; diff --git a/plugins/permission-node/src/integration/index.ts b/plugins/permission-node/src/integration/index.ts index 697d25f65d..f070d57c8f 100644 --- a/plugins/permission-node/src/integration/index.ts +++ b/plugins/permission-node/src/integration/index.ts @@ -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'; diff --git a/plugins/permission-node/src/integration/util.test.ts b/plugins/permission-node/src/integration/util.test.ts index 72e87b2a7b..d27c0a4243 100644 --- a/plugins/permission-node/src/integration/util.test.ts +++ b/plugins/permission-node/src/integration/util.test.ts @@ -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; - expectedResult: { - applyConditions: boolean; - mapConditions: PermissionCriteria; +describe('permission integration utils', () => { + describe('createGetRule', () => { + let getRule: ReturnType; + + 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); + }); }); }); diff --git a/plugins/permission-node/src/integration/util.ts b/plugins/permission-node/src/integration/util.ts index 631aee269e..d9457cfefc 100644 --- a/plugins/permission-node/src/integration/util.ts +++ b/plugins/permission-node/src/integration/util.ts @@ -15,54 +15,35 @@ */ import { PermissionCriteria } from '@backstage/plugin-permission-common'; +import { PermissionRule } from '../types'; -const isAndCriteria = ( +export const isAndCriteria = ( filter: PermissionCriteria, ): filter is { allOf: PermissionCriteria[] } => Object.prototype.hasOwnProperty.call(filter, 'allOf'); -const isOrCriteria = ( +export const isOrCriteria = ( filter: PermissionCriteria, ): filter is { anyOf: PermissionCriteria[] } => Object.prototype.hasOwnProperty.call(filter, 'anyOf'); -const isNotCriteria = ( +export const isNotCriteria = ( filter: PermissionCriteria, ): filter is { not: PermissionCriteria } => Object.prototype.hasOwnProperty.call(filter, 'not'); -export const mapConditions = ( - criteria: PermissionCriteria, - mapFn: (condition: TQueryIn) => TQueryOut, -): PermissionCriteria => { - 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 = ( + rules: PermissionRule[], +) => { + const rulesMap = new Map(Object.values(rules).map(rule => [rule.name, rule])); - return mapFn(criteria); -}; - -export const applyConditions = ( - criteria: PermissionCriteria, - 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 => { + const rule = rulesMap.get(name); + + if (!rule) { + throw new Error(`Unexpected permission rule: ${name}`); + } + + return rule; + }; };