diff --git a/plugins/permission-node/api-report.md b/plugins/permission-node/api-report.md index fac7e8d7fa..407ea3dc84 100644 --- a/plugins/permission-node/api-report.md +++ b/plugins/permission-node/api-report.md @@ -64,15 +64,13 @@ export const createPermissionIntegration: < }) => { createPermissionIntegrationRouter: (...params: TGetResourceParams) => Router; toQuery: ( - conditions: PermissionCriteria>>, + conditions: PermissionCriteria, ) => PermissionCriteria>; conditions: Conditions; - createConditions: ( - conditions: PermissionCriteria>>, - ) => { + createConditions: (conditions: PermissionCriteria) => { pluginId: string; resourceType: string; - conditions: PermissionCriteria>>; + conditions: PermissionCriteria; }; registerPermissionRule: ( rule: PermissionRule, unknown[]>, @@ -113,6 +111,6 @@ export type PolicyResult = // Warnings were encountered during analysis: // -// src/integration/createPermissionIntegration.d.ts:28:5 - (ae-forgotten-export) The symbol "QueryType" needs to be exported by the entry point index.d.ts -// src/integration/createPermissionIntegration.d.ts:29:5 - (ae-forgotten-export) The symbol "Conditions" needs to be exported by the entry point index.d.ts +// src/integration/createPermissionIntegration.d.ts:38:5 - (ae-forgotten-export) The symbol "QueryType" needs to be exported by the entry point index.d.ts +// src/integration/createPermissionIntegration.d.ts:39:5 - (ae-forgotten-export) The symbol "Conditions" needs to be exported by the entry point index.d.ts ``` diff --git a/plugins/permission-node/src/integration/createPermissionIntegration.ts b/plugins/permission-node/src/integration/createPermissionIntegration.ts index d70da5f7df..35d6f0da54 100644 --- a/plugins/permission-node/src/integration/createPermissionIntegration.ts +++ b/plugins/permission-node/src/integration/createPermissionIntegration.ts @@ -14,14 +14,15 @@ * limitations under the License. */ +import express, { Response, Router } from 'express'; import { AuthorizeResult, PermissionCondition, PermissionCriteria, } from '@backstage/permission-common'; -import express, { Response, Router } from 'express'; import { PermissionRule } from '../types'; import { conditionFor } from './conditionFor'; +import { applyConditions, mapConditions } from './util'; /** * A request to load the referenced resource and apply conditions, to finalize a conditional @@ -49,13 +50,6 @@ type QueryType = TRules extends Record< ? TQuery : never; -// TODO(permission-node): this is no longer correct -function isPermissionCriteria( - criteria: unknown, -): criteria is PermissionCriteria { - return Object.prototype.hasOwnProperty.call(criteria, 'anyOf'); -} - export const createPermissionIntegration = < TResource, TRules extends { [key: string]: PermissionRule }, @@ -76,15 +70,13 @@ export const createPermissionIntegration = < }): { createPermissionIntegrationRouter: (...params: TGetResourceParams) => Router; toQuery: ( - conditions: PermissionCriteria>>, + conditions: PermissionCriteria, ) => PermissionCriteria>; conditions: Conditions; - createConditions: ( - conditions: PermissionCriteria>>, - ) => { + createConditions: (conditions: PermissionCriteria) => { pluginId: string; resourceType: string; - conditions: PermissionCriteria>>; + conditions: PermissionCriteria; }; registerPermissionRule: ( rule: PermissionRule>, @@ -92,9 +84,7 @@ export const createPermissionIntegration = < } => { const rulesMap = new Map(Object.values(rules).map(rule => [rule.name, rule])); - const getRule = ( - name: string, - ): PermissionRule> => { + const getRule = (name: string) => { const rule = rulesMap.get(name); if (!rule) { @@ -110,10 +100,9 @@ export const createPermissionIntegration = < ) => { const router = Router(); - router.use('/permissions/', express.json()); - router.post( '/permissions/apply-conditions', + express.json(), async ( req, res: Response<{ @@ -136,22 +125,10 @@ export const createPermissionIntegration = < return res.status(400).end(); } - const resolveCriteria = ( - criteria: PermissionCriteria< - PermissionCondition> - >, - ): boolean => { - return criteria.anyOf.some(({ allOf }) => - allOf.every(child => - isPermissionCriteria(child) - ? resolveCriteria(child) - : getRule(child.rule).apply(resource, ...child.params), - ), - ); - }; - return res.status(200).json({ - result: resolveCriteria(body.conditions) + result: applyConditions(body.conditions, ({ rule, params }) => + getRule(rule).apply(resource, ...params), + ) ? AuthorizeResult.ALLOW : AuthorizeResult.DENY, }); @@ -161,27 +138,10 @@ export const createPermissionIntegration = < return router; }, toQuery: ( - conditions: PermissionCriteria>>, + conditions: PermissionCriteria, ): PermissionCriteria> => { - const mapCriteria = ( - criteria: PermissionCriteria>>, - mapFn: ( - condition: PermissionCondition>, - ) => QueryType | PermissionCriteria>, - ): PermissionCriteria> => { - return { - anyOf: criteria.anyOf.map(({ allOf }) => ({ - allOf: allOf.map(child => - isPermissionCriteria(child) - ? mapCriteria(child, mapFn) - : mapFn(child), - ), - })), - }; - }; - - return mapCriteria(conditions, condition => - getRule(condition.rule).toQuery(...condition.params), + return mapConditions(conditions, ({ rule, params }) => + getRule(rule).toQuery(...params), ); }, conditions: Object.entries(rules).reduce( @@ -192,7 +152,7 @@ export const createPermissionIntegration = < {} as Conditions, ), createConditions: ( - conditions: PermissionCriteria>>, + conditions: PermissionCriteria, ) => ({ pluginId, resourceType, diff --git a/plugins/permission-node/src/integration/util.test.ts b/plugins/permission-node/src/integration/util.test.ts new file mode 100644 index 0000000000..44a000d395 --- /dev/null +++ b/plugins/permission-node/src/integration/util.test.ts @@ -0,0 +1,217 @@ +/* + * 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/permission-common'; +import { applyConditions, mapConditions } from './util'; + +describe('integration utils', () => { + const testCases: { + criteria: PermissionCriteria; + expectedResult: { + applyConditions: boolean; + mapConditions: PermissionCriteria<{ query: string; params: unknown[] }>; + }; + }[] = [ + { + criteria: { rule: 'test-rule-1', params: [] }, + expectedResult: { + applyConditions: true, + mapConditions: { query: 'test-rule-1', params: [] }, + }, + }, + { + criteria: { rule: 'test-rule-2', params: [] }, + expectedResult: { + applyConditions: false, + mapConditions: { query: 'test-rule-2', params: [] }, + }, + }, + { + criteria: { + anyOf: [ + { rule: 'test-rule-1', params: ['a', 'b'] }, + { rule: 'test-rule-2', params: ['c', 'd'] }, + ], + }, + expectedResult: { + applyConditions: true, + mapConditions: { + anyOf: [ + { query: 'test-rule-1', params: ['a', 'b'] }, + { query: 'test-rule-2', params: ['c', 'd'] }, + ], + }, + }, + }, + { + criteria: { + allOf: [ + { rule: 'test-rule-1', params: ['e', 'f'] }, + { rule: 'test-rule-2', params: ['g', 'h'] }, + ], + }, + expectedResult: { + applyConditions: false, + mapConditions: { + allOf: [ + { query: 'test-rule-1', params: ['e', 'f'] }, + { query: 'test-rule-2', params: ['g', 'h'] }, + ], + }, + }, + }, + { + criteria: { + not: { rule: 'test-rule-2', params: ['i'] }, + }, + expectedResult: { + applyConditions: true, + mapConditions: { + not: { query: 'test-rule-2', params: ['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: [ + { query: 'test-rule-1', params: ['j'] }, + { query: 'test-rule-2', params: ['k'] }, + ], + }, + { + not: { + allOf: [ + { query: 'test-rule-1', params: ['l'] }, + { query: 'test-rule-2', params: ['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: [ + { query: 'test-rule-1', params: ['j'] }, + { query: 'test-rule-2', params: ['k'] }, + ], + }, + { + not: { + allOf: [ + { query: 'test-rule-1', params: ['l'] }, + { not: { query: 'test-rule-2', params: ['m'] } }, + ], + }, + }, + ], + }, + }, + }, + ]; + + describe('applyConditions', () => { + const applyFn = jest.fn(condition => condition.rule === 'test-rule-1'); + + 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'], + }); + }); + + it.each(testCases)( + 'works with criteria %#', + ({ criteria, expectedResult }) => { + expect(applyConditions(criteria, applyFn)).toEqual( + expectedResult.applyConditions, + ); + }, + ); + }); + + describe('mapConditions', () => { + const mapFn = jest.fn(({ rule, params }) => ({ query: rule, params })); + + 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'], + }); + }); + + it.each(testCases)( + 'works with criteria %#', + ({ criteria, expectedResult }) => { + expect(mapConditions(criteria, mapFn)).toEqual( + expectedResult.mapConditions, + ); + }, + ); + }); +}); diff --git a/plugins/permission-node/src/integration/util.ts b/plugins/permission-node/src/integration/util.ts new file mode 100644 index 0000000000..355964d573 --- /dev/null +++ b/plugins/permission-node/src/integration/util.ts @@ -0,0 +1,68 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { PermissionCriteria } from '@backstage/permission-common'; + +const isAndCriteria = ( + filter: PermissionCriteria, +): filter is { allOf: PermissionCriteria[] } => + Object.prototype.hasOwnProperty.call(filter, 'allOf'); + +const isOrCriteria = ( + filter: PermissionCriteria, +): filter is { anyOf: PermissionCriteria[] } => + Object.prototype.hasOwnProperty.call(filter, 'anyOf'); + +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), + }; + } + + 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); +};