diff --git a/.changeset/heavy-tools-hang.md b/.changeset/heavy-tools-hang.md new file mode 100644 index 0000000000..91a843b0a7 --- /dev/null +++ b/.changeset/heavy-tools-hang.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-permission-node': patch +--- + +New package containing common permission and authorization utilities for backend plugins. For more information, see the [authorization PRFC](https://github.com/backstage/backstage/pull/7761). diff --git a/plugins/permission-node/.eslintrc.js b/plugins/permission-node/.eslintrc.js new file mode 100644 index 0000000000..16a033dbc6 --- /dev/null +++ b/plugins/permission-node/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint.backend')], +}; diff --git a/plugins/permission-node/README.md b/plugins/permission-node/README.md new file mode 100644 index 0000000000..82dfe54f9c --- /dev/null +++ b/plugins/permission-node/README.md @@ -0,0 +1,7 @@ +# @backstage/plugin-permission-node + +> NOTE: THIS PACKAGE IS EXPERIMENTAL, HERE BE DRAGONS + +Common permission and authorization utilities for backend plugins. For more +information, see the [authorization +PRFC](https://github.com/backstage/backstage/pull/7761). diff --git a/plugins/permission-node/api-report.md b/plugins/permission-node/api-report.md new file mode 100644 index 0000000000..e5fe9a7087 --- /dev/null +++ b/plugins/permission-node/api-report.md @@ -0,0 +1,130 @@ +## API Report File for "@backstage/plugin-permission-node" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { AuthorizeRequest } from '@backstage/plugin-permission-common'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; +import { BackstageIdentity } from '@backstage/plugin-auth-backend'; +import { PermissionCondition } from '@backstage/plugin-permission-common'; +import { PermissionCriteria } from '@backstage/plugin-permission-common'; +import { Router } from 'express'; + +// @public +export type ApplyConditionsRequest = { + resourceRef: string; + resourceType: string; + conditions: PermissionCriteria; +}; + +// @public +export type ApplyConditionsResponse = { + result: AuthorizeResult.ALLOW | AuthorizeResult.DENY; +}; + +// @public +export type Condition = TRule extends PermissionRule< + any, + any, + infer TParams +> + ? (...params: TParams) => PermissionCondition + : never; + +// @public +export type ConditionalPolicyResult = { + result: AuthorizeResult.CONDITIONAL; + conditions: { + pluginId: string; + resourceType: string; + conditions: PermissionCriteria; + }; +}; + +// @public +export type Conditions< + TRules extends Record>, +> = { + [Name in keyof TRules]: Condition; +}; + +// @public +export type ConditionTransformer = ( + conditions: PermissionCriteria, +) => PermissionCriteria; + +// @public +export const createConditionExports: < + TResource, + TRules extends Record>, +>(options: { + pluginId: string; + resourceType: string; + rules: TRules; +}) => { + conditions: Conditions; + createConditions: (conditions: PermissionCriteria) => { + pluginId: string; + resourceType: string; + conditions: PermissionCriteria; + }; +}; + +// @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) + handle( + request: PolicyAuthorizeRequest, + user?: BackstageIdentity, + ): Promise; +} + +// @public +export type PermissionRule< + TResource, + TQuery, + TParams extends unknown[] = unknown[], +> = { + name: string; + description: string; + apply(resource: TResource, ...params: TParams): boolean; + toQuery(...params: TParams): PermissionCriteria; +}; + +// @public +export type PolicyAuthorizeRequest = Omit; + +// @public +export type PolicyResult = + | { + result: AuthorizeResult.ALLOW | AuthorizeResult.DENY; + } + | ConditionalPolicyResult; +``` diff --git a/plugins/permission-node/package.json b/plugins/permission-node/package.json new file mode 100644 index 0000000000..e22e2d2de4 --- /dev/null +++ b/plugins/permission-node/package.json @@ -0,0 +1,46 @@ +{ + "name": "@backstage/plugin-permission-node", + "description": "Common permission and authorization utilities for backend plugins", + "version": "0.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/permission-node" + }, + "keywords": [ + "backstage", + "permissions" + ], + "scripts": { + "build": "backstage-cli backend:build", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/plugin-auth-backend": "^0.4.6", + "@backstage/plugin-permission-common": "^0.1.0", + "@types/express": "^4.17.6", + "express": "^4.17.1", + "zod": "^3.11.6" + }, + "devDependencies": { + "@backstage/cli": "^0.9.0", + "@types/supertest": "^2.0.8", + "supertest": "^6.1.3" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/permission-node/src/index.ts b/plugins/permission-node/src/index.ts new file mode 100644 index 0000000000..39527bac71 --- /dev/null +++ b/plugins/permission-node/src/index.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Common permission and authorization utilities for backend plugins + * + * @packageDocumentation + */ +export * from './integration'; +export * from './policy'; +export * from './types'; 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..30cff6628a --- /dev/null +++ b/plugins/permission-node/src/integration/createConditionExports.ts @@ -0,0 +1,100 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + PermissionCondition, + PermissionCriteria, +} from '@backstage/plugin-permission-common'; +import { PermissionRule } from '../types'; +import { createConditionFactory } from './createConditionFactory'; + +/** + * A utility type for mapping a single {@link PermissionRule} to its + * corresponding {@link @backstage/plugin-permission-common#PermissionCondition}. + * + * @public + */ +export type Condition = TRule 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. + * + * @remarks + * + * The function returns a `conditions` object containing a + * {@link @backstage/plugin-permission-common#PermissionCondition} factory for each of the + * supplied {@link PermissionRule}s, along with a `createConditions` function which builds the + * wrapper object needed to enclose conditions when authoring {@link PermissionPolicy} implementations. + * + * Plugin authors should generally call this method with all the built-in {@link PermissionRule}s + * the plugin supports, and export the resulting `conditions` object and `createConditions` + * function so that they can be used by {@link PermissionPolicy} authors. + * + * @public + */ +export const createConditionExports = < + TResource, + TRules extends Record>, +>(options: { + pluginId: string; + resourceType: string; + rules: TRules; +}): { + conditions: Conditions; + createConditions: (conditions: PermissionCriteria) => { + pluginId: string; + resourceType: string; + conditions: PermissionCriteria; + }; +} => { + const { pluginId, resourceType, rules } = options; + + return { + 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/createConditionFactory.test.ts b/plugins/permission-node/src/integration/createConditionFactory.test.ts new file mode 100644 index 0000000000..8dd43f5da6 --- /dev/null +++ b/plugins/permission-node/src/integration/createConditionFactory.test.ts @@ -0,0 +1,40 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createConditionFactory } from './createConditionFactory'; + +describe('createConditionFactory', () => { + const testRule = { + name: 'test-rule', + description: 'test-description', + apply: jest.fn(), + toQuery: jest.fn(), + }; + + it('returns a function', () => { + expect(createConditionFactory(testRule)).toEqual(expect.any(Function)); + }); + + describe('return value', () => { + it('constructs a condition with the rule name and supplied params', () => { + const conditionFactory = createConditionFactory(testRule); + expect(conditionFactory('a', 'b', 1, 2)).toEqual({ + rule: 'test-rule', + params: ['a', 'b', 1, 2], + }); + }); + }); +}); diff --git a/plugins/permission-node/src/integration/createConditionFactory.ts b/plugins/permission-node/src/integration/createConditionFactory.ts new file mode 100644 index 0000000000..84a8dc86a6 --- /dev/null +++ b/plugins/permission-node/src/integration/createConditionFactory.ts @@ -0,0 +1,40 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { PermissionRule } from '../types'; + +/** + * Creates a condition factory function for a given authorization rule and parameter types. + * + * @remarks + * + * For example, an isEntityOwner rule for catalog entities might take an array of entityRef strings. + * The rule itself defines _how_ to check a given resource, whereas a condition also includes _what_ + * to verify. + * + * Plugin authors should generally use the {@link createConditionExports} in order to efficiently + * create multiple condition factories. This helper should generally only be used to construct + * condition factories for third-party rules that aren't part of the backend plugin with which + * they're intended to integrate. + * + * @public + */ +export const createConditionFactory = + (rule: PermissionRule) => + (...params: TParams) => ({ + rule: rule.name, + params, + }); 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/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..99e282b144 --- /dev/null +++ b/plugins/permission-node/src/integration/createPermissionIntegrationRouter.ts @@ -0,0 +1,177 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import express, { Response, Router } from 'express'; +import { z } from 'zod'; +import { + AuthorizeResult, + PermissionCondition, + PermissionCriteria, +} from '@backstage/plugin-permission-common'; +import { PermissionRule } from '../types'; +import { + createGetRule, + isAndCriteria, + isNotCriteria, + isOrCriteria, +} from './util'; + +const permissionCriteriaSchema: z.ZodSchema< + PermissionCriteria +> = 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; +}; + +/** + * The result of applying the conditions, expressed as a definitive authorize + * result of ALLOW or DENY. + * + * @public + */ +export type ApplyConditionsResponse = { + result: AuthorizeResult.ALLOW | AuthorizeResult.DENY; +}; + +const applyConditions = ( + 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. + * + * @remarks + * + * To make this concrete, we can use the Backstage software catalog as an example. The catalog has + * conditional rules around access to specific _entities_ in the catalog. The _type_ of resource is + * captured here as `resourceType`, a string identifier (`catalog-entity` in this example) that can + * be provided with permission definitions. This is merely a _type_ to verify that conditions in an + * authorization policy are constructed correctly, not a reference to a specific resource. + * + * The `rules` parameter is an array of {@link PermissionRule}s that introduce conditional + * filtering logic for resources; for the catalog, these are things like `isEntityOwner` or + * `hasAnnotation`. Rules describe how to filter a list of resources, and the `conditions` returned + * allow these rules to be applied with specific parameters (such as 'group:default/team-a', or + * 'backstage.io/edit-url'). + * + * The `getResource` argument should load a resource by reference. For the catalog, this is an + * {@link @backstage/catalog-model#EntityRef}. For other plugins, this can be any serialized format. + * This is used to construct the `createPermissionIntegrationRouter`, a function to add an + * authorization route to your backend plugin. This route will be called by the `permission-backend` + * when authorization conditions relating to this plugin need to be evaluated. + * @public + */ +export const createPermissionIntegrationRouter = ({ + 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 new file mode 100644 index 0000000000..f070d57c8f --- /dev/null +++ b/plugins/permission-node/src/integration/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './createConditionFactory'; +export * from './createConditionExports'; +export * from './createConditionTransformer'; +export * from './createPermissionIntegrationRouter'; 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..d27c0a4243 --- /dev/null +++ b/plugins/permission-node/src/integration/util.test.ts @@ -0,0 +1,86 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + createGetRule, + isAndCriteria, + isNotCriteria, + isOrCriteria, +} from './util'; + +describe('permission integration utils', () => { + describe('createGetRule', () => { + let getRule: ReturnType; + + const testRule1 = { + name: 'test-rule-1', + description: 'Test rule 1', + apply: jest.fn(), + toQuery: jest.fn(), + }; + + const testRule2 = { + name: 'test-rule-2', + description: 'Test rule 2', + apply: jest.fn(), + toQuery: jest.fn(), + }; + + beforeEach(() => { + getRule = createGetRule([testRule1, testRule2]); + }); + + it('returns the rule matching the supplied name', () => { + expect(getRule('test-rule-1')).toBe(testRule1); + }); + + it('throws if there is no rule for the supplied name', () => { + expect(() => getRule('test-rule-3')).toThrowError( + /unexpected permission rule/i, + ); + }); + }); + + describe('isOrCriteria', () => { + it('returns true if input has a top-level "anyOf" property', () => { + expect(isOrCriteria({ anyOf: { not: { allOf: [] } } })).toEqual(true); + }); + + it('returns false if input does not have a top-level "anyOf" property', () => { + expect(isOrCriteria({ allOf: { not: { anyOf: [] } } })).toEqual(false); + }); + }); + + describe('isAndCriteria', () => { + it('returns true if input has a top-level "allOf" property', () => { + expect(isAndCriteria({ allOf: { not: { anyOf: [] } } })).toEqual(true); + }); + + it('returns false if input does not have a top-level "allOf" property', () => { + expect(isAndCriteria({ anyOf: { not: { allOf: [] } } })).toEqual(false); + }); + }); + + describe('isNotCriteria', () => { + it('returns true if input has a top-level "not" property', () => { + expect(isNotCriteria({ not: { allOf: [{ anyOf: [] }] } })).toEqual(true); + }); + + it('returns false if input does not have a top-level "not" property', () => { + expect(isNotCriteria({ anyOf: { not: { allOf: [] } } })).toEqual(false); + }); + }); +}); diff --git a/plugins/permission-node/src/integration/util.ts b/plugins/permission-node/src/integration/util.ts new file mode 100644 index 0000000000..d9457cfefc --- /dev/null +++ b/plugins/permission-node/src/integration/util.ts @@ -0,0 +1,49 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { PermissionCriteria } from '@backstage/plugin-permission-common'; +import { PermissionRule } from '../types'; + +export const isAndCriteria = ( + filter: PermissionCriteria, +): filter is { allOf: PermissionCriteria[] } => + Object.prototype.hasOwnProperty.call(filter, 'allOf'); + +export const isOrCriteria = ( + filter: PermissionCriteria, +): filter is { anyOf: PermissionCriteria[] } => + Object.prototype.hasOwnProperty.call(filter, 'anyOf'); + +export const isNotCriteria = ( + filter: PermissionCriteria, +): filter is { not: PermissionCriteria } => + Object.prototype.hasOwnProperty.call(filter, 'not'); + +export const createGetRule = ( + rules: PermissionRule[], +) => { + const rulesMap = new Map(Object.values(rules).map(rule => [rule.name, rule])); + + return (name: string): PermissionRule => { + const rule = rulesMap.get(name); + + if (!rule) { + throw new Error(`Unexpected permission rule: ${name}`); + } + + return rule; + }; +}; diff --git a/plugins/permission-node/src/policy/index.ts b/plugins/permission-node/src/policy/index.ts new file mode 100644 index 0000000000..f04c2a094c --- /dev/null +++ b/plugins/permission-node/src/policy/index.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type { + ConditionalPolicyResult, + PermissionPolicy, + PolicyAuthorizeRequest, + PolicyResult, +} from './types'; diff --git a/plugins/permission-node/src/policy/types.ts b/plugins/permission-node/src/policy/types.ts new file mode 100644 index 0000000000..44380bad26 --- /dev/null +++ b/plugins/permission-node/src/policy/types.ts @@ -0,0 +1,90 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + AuthorizeRequest, + AuthorizeResult, + PermissionCondition, + PermissionCriteria, +} from '@backstage/plugin-permission-common'; +import { BackstageIdentity } from '@backstage/plugin-auth-backend'; + +/** + * An authorization request to be evaluated by the {@link PermissionPolicy}. + * + * @remarks + * + * This differs from {@link @backstage/permission-common#AuthorizeRequest} in that `resourceRef` + * should never be provided. This forces policies to be written in a way that's compatible with + * filtering collections of resources at data load time. + * + * @public + */ +export type PolicyAuthorizeRequest = Omit; + +/** + * A conditional result to an authorization request, returned by the {@link PermissionPolicy}. + * + * @remarks + * + * This indicates that the policy allows authorization for the request, given that the returned + * conditions hold when evaluated. The conditions will be evaluated by the corresponding plugin + * which knows about the referenced permission rules. + * + * Similar to {@link @backstage/permission-common#AuthorizeResult}, but with the plugin and resource + * identifiers needed to evaluate the returned conditions. + * @public + */ +export type ConditionalPolicyResult = { + result: AuthorizeResult.CONDITIONAL; + conditions: { + pluginId: string; + resourceType: string; + conditions: PermissionCriteria; + }; +}; + +/** + * The result of evaluating an authorization request with a {@link PermissionPolicy}. + * + * @public + */ +export type PolicyResult = + | { result: AuthorizeResult.ALLOW | AuthorizeResult.DENY } + | ConditionalPolicyResult; + +/** + * A policy to evaluate authorization requests for any permissioned action performed in Backstage. + * + * @remarks + * + * This takes as input a permission and an optional Backstage identity, and should return ALLOW if + * the user is permitted to execute that action; otherwise DENY. For permissions relating to + * resources, such a catalog entities, a conditional response can also be returned. This states + * that the action is allowed if the conditions provided hold true. + * + * Conditions are a rule, and parameters to evaluate against that rule. For example, the rule might + * be `isOwner` and the parameters a collection of entityRefs; if one of the entityRefs matches + * the `owner` field on a catalog entity, this would resolve to ALLOW. + * + * @public + */ +export interface PermissionPolicy { + handle( + request: PolicyAuthorizeRequest, + user?: BackstageIdentity, + ): Promise; +} diff --git a/plugins/permission-node/src/setupTests.ts b/plugins/permission-node/src/setupTests.ts new file mode 100644 index 0000000000..a330613afb --- /dev/null +++ b/plugins/permission-node/src/setupTests.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export {}; diff --git a/plugins/permission-node/src/types.ts b/plugins/permission-node/src/types.ts new file mode 100644 index 0000000000..678befc99c --- /dev/null +++ b/plugins/permission-node/src/types.ts @@ -0,0 +1,56 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { PermissionCriteria } from '@backstage/plugin-permission-common'; + +/** + * A conditional rule that can be provided in an + * {@link @backstage/permission-common#AuthorizeResult} response to an authorization request. + * + * @remarks + * + * Rules can either be evaluated against a resource loaded in memory, or used as filters when + * loading a collection of resources from a data source. The `apply` and `toQuery` methods implement + * these two concepts. + * + * The two operations should always have the same logical result. If they don’t, the effective + * outcome of an authorization operation will sometimes differ depending on how the authorization + * check was performed. + * + * @public + */ +export type PermissionRule< + TResource, + TQuery, + TParams extends unknown[] = unknown[], +> = { + name: string; + description: string; + + /** + * Apply this rule to a resource already loaded from a backing data source. The params are + * arguments supplied for the rule; for example, a rule could be `isOwner` with entityRefs as the + * params. + */ + apply(resource: TResource, ...params: TParams): boolean; + + /** + * Translate this rule to criteria suitable for use in querying a backing data store. The criteria + * can be used for loading a collection of resources efficiently with conditional criteria already + * applied. + */ + toQuery(...params: TParams): PermissionCriteria; +};