From a1e2972ddaf6bc2fec733d53c6fdc453510bbd0c Mon Sep 17 00:00:00 2001 From: Tim Hansen Date: Wed, 10 Nov 2021 16:00:21 -0700 Subject: [PATCH 01/24] Add permission-node package Signed-off-by: Tim Hansen Co-authored-by: Mike Lewis Co-authored-by: Himanshu Mishra Co-authored-by: Joe Porpeglia Co-authored-by: Vincenzo Scamporlino --- plugins/permission-node/.eslintrc.js | 3 + plugins/permission-node/README.md | 7 + plugins/permission-node/api-report.md | 126 +++++++++++ plugins/permission-node/package.json | 33 +++ plugins/permission-node/src/index.ts | 25 +++ .../src/integration/conditionFor.ts | 24 +++ .../createPermissionIntegration.ts | 203 ++++++++++++++++++ .../permission-node/src/integration/index.ts | 18 ++ plugins/permission-node/src/policy/index.ts | 21 ++ plugins/permission-node/src/policy/types.ts | 81 +++++++ plugins/permission-node/src/setupTests.ts | 17 ++ plugins/permission-node/src/types.ts | 49 +++++ 12 files changed, 607 insertions(+) create mode 100644 plugins/permission-node/.eslintrc.js create mode 100644 plugins/permission-node/README.md create mode 100644 plugins/permission-node/api-report.md create mode 100644 plugins/permission-node/package.json create mode 100644 plugins/permission-node/src/index.ts create mode 100644 plugins/permission-node/src/integration/conditionFor.ts create mode 100644 plugins/permission-node/src/integration/createPermissionIntegration.ts create mode 100644 plugins/permission-node/src/integration/index.ts create mode 100644 plugins/permission-node/src/policy/index.ts create mode 100644 plugins/permission-node/src/policy/types.ts create mode 100644 plugins/permission-node/src/setupTests.ts create mode 100644 plugins/permission-node/src/types.ts 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..1285207e61 --- /dev/null +++ b/plugins/permission-node/api-report.md @@ -0,0 +1,126 @@ +## 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 { AuthorizeResult } from '@backstage/permission-common'; +import { BackstageIdentity } from '@backstage/plugin-auth-backend'; +import { DefinitiveAuthorizeResult } from '@backstage/permission-common'; +import { OpaqueAuthorizeRequest } from '@backstage/permission-common'; +import { PermissionCondition } from '@backstage/permission-common'; +import { PermissionCriteria } from '@backstage/permission-common'; +import { Router } from 'express'; + +// Warning: (ae-missing-release-tag) "AllowAllPermissionPolicy" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export class AllowAllPermissionPolicy implements PermissionPolicy { + // (undocumented) + handle( + _request: OpaqueAuthorizeRequest, + _user?: BackstageIdentity, + ): Promise; +} + +// Warning: (ae-missing-release-tag) "ApplyConditionsRequest" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type ApplyConditionsRequest = { + resourceRef: string; + resourceType: string; + conditions: PermissionCriteria>; +}; + +// Warning: (ae-missing-release-tag) "ConditionalPolicyResult" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type ConditionalPolicyResult = { + result: AuthorizeResult.MAYBE; + conditions: { + pluginId: string; + resourceType: string; + conditions: PermissionCriteria; + }; +}; + +// Warning: (ae-missing-release-tag) "conditionFor" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const conditionFor: ( + rule: PermissionRule, +) => (...params: TParams) => { + rule: string; + params: TParams; +}; + +// Warning: (ae-missing-release-tag) "createPermissionIntegration" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +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, any>, + ) => void; +}; + +// Warning: (ae-missing-release-tag) "PermissionPolicy" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface PermissionPolicy { + // (undocumented) + handle( + request: OpaqueAuthorizeRequest, + user?: BackstageIdentity, + ): Promise; +} + +// Warning: (ae-missing-release-tag) "PermissionRule" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type PermissionRule = { + name: string; + description: string; + apply(resource: TResource, ...params: TParams): boolean; + toQuery(...params: TParams): TQuery | PermissionCriteria; +}; + +// Warning: (ae-missing-release-tag) "PolicyResult" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type PolicyResult = DefinitiveAuthorizeResult | ConditionalPolicyResult; + +// Warnings were encountered during analysis: +// +// src/integration/createPermissionIntegration.d.ts:23:5 - (ae-forgotten-export) The symbol "QueryType" needs to be exported by the entry point index.d.ts +// src/integration/createPermissionIntegration.d.ts:24:5 - (ae-forgotten-export) The symbol "Conditions" needs to be exported by the entry point index.d.ts + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/permission-node/package.json b/plugins/permission-node/package.json new file mode 100644 index 0000000000..112976b78b --- /dev/null +++ b/plugins/permission-node/package.json @@ -0,0 +1,33 @@ +{ + "name": "@backstage/plugin-permission-node", + "description": "Common permission and authorization utilities for backend plugins", + "version": "0.1.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" + }, + "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/permission-common": "^0.1.0", + "@backstage/plugin-auth-backend": "^0.4.6", + "@types/express": "*", + "express": "^4.17.1" + }, + "devDependencies": { + "@backstage/cli": "^0.8.1" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/permission-node/src/index.ts b/plugins/permission-node/src/index.ts new file mode 100644 index 0000000000..697c02ab22 --- /dev/null +++ b/plugins/permission-node/src/index.ts @@ -0,0 +1,25 @@ +/* + * 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/conditionFor.ts b/plugins/permission-node/src/integration/conditionFor.ts new file mode 100644 index 0000000000..6cead51716 --- /dev/null +++ b/plugins/permission-node/src/integration/conditionFor.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. + */ + +import { PermissionRule } from '../types'; + +export const conditionFor = + (rule: PermissionRule) => + (...params: TParams) => ({ + rule: rule.name, + params, + }); diff --git a/plugins/permission-node/src/integration/createPermissionIntegration.ts b/plugins/permission-node/src/integration/createPermissionIntegration.ts new file mode 100644 index 0000000000..88a05a7cf8 --- /dev/null +++ b/plugins/permission-node/src/integration/createPermissionIntegration.ts @@ -0,0 +1,203 @@ +/* + * 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, + PermissionCondition, + PermissionCriteria, +} from '@backstage/permission-common'; +import express, { Response, Router } from 'express'; +import { PermissionRule } from '../types'; +import { conditionFor } from './conditionFor'; + +/** + * 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>; +}; + +type Condition = TRule extends PermissionRule + ? (...params: TParams) => PermissionCondition + : never; + +type Conditions>> = { + [Name in keyof TRules]: Condition; +}; + +type QueryType = TRules extends Record< + string, + PermissionRule +> + ? 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 }, + 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, + ): PermissionRule> => { + const rule = rulesMap.get(name); + + if (!rule) { + throw new Error(`Unexpected permission rule: ${name}`); + } + + return rule; + }; + + return { + createPermissionIntegrationRouter: ( + ...getResourceParams: TGetResourceParams + ) => { + const router = Router(); + + router.use('/permissions/', express.json()); + + router.post( + '/permissions/apply-conditions', + async ( + req, + res: Response>, + ) => { + // TODO(authorization-framework): validate input + const body = req.body as ApplyConditionsRequest; + + if (body.resourceType !== resourceType) { + throw new Error(`Unexpected resource type: ${body.resourceType}`); + } + + const resource = await getResource( + body.resourceRef, + ...getResourceParams, + ); + + if (!resource) { + 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) + ? AuthorizeResult.ALLOW + : AuthorizeResult.DENY, + }); + }, + ); + + return router; + }, + toQuery: ( + 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), + ); + }, + 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/index.ts b/plugins/permission-node/src/integration/index.ts new file mode 100644 index 0000000000..697d25f65d --- /dev/null +++ b/plugins/permission-node/src/integration/index.ts @@ -0,0 +1,18 @@ +/* + * 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 './conditionFor'; +export * from './createPermissionIntegration'; diff --git a/plugins/permission-node/src/policy/index.ts b/plugins/permission-node/src/policy/index.ts new file mode 100644 index 0000000000..226d50df21 --- /dev/null +++ b/plugins/permission-node/src/policy/index.ts @@ -0,0 +1,21 @@ +/* + * 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, + 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..b1b3881c3e --- /dev/null +++ b/plugins/permission-node/src/policy/types.ts @@ -0,0 +1,81 @@ +/* + * 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/permission-common'; +import { BackstageIdentity } from '@backstage/plugin-auth-backend'; + +/** + * An authorization request to be evaluated by the {@link PermissionPolicy}. + * + * This differs from {@link 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}. + * + * 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 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. + * + * 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..6d2622119b --- /dev/null +++ b/plugins/permission-node/src/types.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 type { PermissionCriteria } from '@backstage/permission-common'; + +/** + * A conditional rule that can be provided in an {@link AuthorizeResult} response to an + * authorization request. + * + * 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 = { + 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): TQuery | PermissionCriteria; +}; From a82709c38f4225331fd8f6fc91e81f91fd64a5d3 Mon Sep 17 00:00:00 2001 From: Mike Lewis Date: Thu, 11 Nov 2021 09:53:50 +0000 Subject: [PATCH 02/24] authz: update unused type params in conditionFor to unknown Signed-off-by: Mike Lewis --- plugins/permission-node/src/integration/conditionFor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/permission-node/src/integration/conditionFor.ts b/plugins/permission-node/src/integration/conditionFor.ts index 6cead51716..3a13a3afe3 100644 --- a/plugins/permission-node/src/integration/conditionFor.ts +++ b/plugins/permission-node/src/integration/conditionFor.ts @@ -17,7 +17,7 @@ import { PermissionRule } from '../types'; export const conditionFor = - (rule: PermissionRule) => + (rule: PermissionRule) => (...params: TParams) => ({ rule: rule.name, params, From 4083fcdb5e5dfd1bf7712c8e3c75204d3da1d859 Mon Sep 17 00:00:00 2001 From: Mike Lewis Date: Thu, 11 Nov 2021 13:54:29 +0000 Subject: [PATCH 03/24] authz: fix PermissionCondition type parameter `unknown` doesn't satisfy the (recently added) `extends unknown[]` constraint. In these two cases, we can remove the parameter entirely and rely instead on the default of `unknown[]`. Signed-off-by: Mike Lewis --- .../src/integration/createPermissionIntegration.ts | 2 +- plugins/permission-node/src/policy/types.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/permission-node/src/integration/createPermissionIntegration.ts b/plugins/permission-node/src/integration/createPermissionIntegration.ts index 88a05a7cf8..bbd1c8af2a 100644 --- a/plugins/permission-node/src/integration/createPermissionIntegration.ts +++ b/plugins/permission-node/src/integration/createPermissionIntegration.ts @@ -31,7 +31,7 @@ import { conditionFor } from './conditionFor'; export type ApplyConditionsRequest = { resourceRef: string; resourceType: string; - conditions: PermissionCriteria>; + conditions: PermissionCriteria; }; type Condition = TRule extends PermissionRule diff --git a/plugins/permission-node/src/policy/types.ts b/plugins/permission-node/src/policy/types.ts index b1b3881c3e..e81757d59c 100644 --- a/plugins/permission-node/src/policy/types.ts +++ b/plugins/permission-node/src/policy/types.ts @@ -48,7 +48,7 @@ export type ConditionalPolicyResult = { conditions: { pluginId: string; resourceType: string; - conditions: PermissionCriteria>; + conditions: PermissionCriteria; }; }; From f3d2ccfeb883b6e79dc9e3177e4ca9a6b36a7082 Mon Sep 17 00:00:00 2001 From: Mike Lewis Date: Thu, 11 Nov 2021 13:56:07 +0000 Subject: [PATCH 04/24] authz: switch to unknown[] in PermissionRule type parameter Signed-off-by: Mike Lewis --- plugins/permission-node/src/types.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/plugins/permission-node/src/types.ts b/plugins/permission-node/src/types.ts index 6d2622119b..308eafe481 100644 --- a/plugins/permission-node/src/types.ts +++ b/plugins/permission-node/src/types.ts @@ -29,7 +29,11 @@ import type { PermissionCriteria } from '@backstage/permission-common'; * check was performed. * @public */ -export type PermissionRule = { +export type PermissionRule< + TResource, + TQuery, + TParams extends unknown[] = unknown[], +> = { name: string; description: string; From 652af0834ae886da9310bf60b9e84be0bb0f46ba Mon Sep 17 00:00:00 2001 From: Mike Lewis Date: Thu, 11 Nov 2021 13:58:05 +0000 Subject: [PATCH 05/24] authz: add test suite for conditionFor Signed-off-by: Mike Lewis --- .../src/integration/conditionFor.test.ts | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 plugins/permission-node/src/integration/conditionFor.test.ts diff --git a/plugins/permission-node/src/integration/conditionFor.test.ts b/plugins/permission-node/src/integration/conditionFor.test.ts new file mode 100644 index 0000000000..d1099e7555 --- /dev/null +++ b/plugins/permission-node/src/integration/conditionFor.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 { conditionFor } from './conditionFor'; + +describe('conditionFor', () => { + const testRule = { + name: 'test-rule', + description: 'test-description', + apply: jest.fn(), + toQuery: jest.fn(), + }; + + it('returns a function', () => { + expect(conditionFor(testRule)).toEqual(expect.any(Function)); + }); + + describe('return value', () => { + it('constructs a condition with the rule name and supplied params', () => { + const conditionFactory = conditionFor(testRule); + expect(conditionFactory('a', 'b', 1, 2)).toEqual({ + rule: 'test-rule', + params: ['a', 'b', 1, 2], + }); + }); + }); +}); From 64b6ff368f1e5081a8739cbad6c162c8bb402ce7 Mon Sep 17 00:00:00 2001 From: Mike Lewis Date: Thu, 11 Nov 2021 13:58:28 +0000 Subject: [PATCH 06/24] authz: fix expected response type for /apply-conditions route Signed-off-by: Mike Lewis --- .../src/integration/createPermissionIntegration.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/plugins/permission-node/src/integration/createPermissionIntegration.ts b/plugins/permission-node/src/integration/createPermissionIntegration.ts index bbd1c8af2a..d70da5f7df 100644 --- a/plugins/permission-node/src/integration/createPermissionIntegration.ts +++ b/plugins/permission-node/src/integration/createPermissionIntegration.ts @@ -116,7 +116,9 @@ export const createPermissionIntegration = < '/permissions/apply-conditions', async ( req, - res: Response>, + res: Response<{ + result: Omit; + }>, ) => { // TODO(authorization-framework): validate input const body = req.body as ApplyConditionsRequest; From d0a99e3219e67fb1b81e205d2cf864c61fc8a81c Mon Sep 17 00:00:00 2001 From: Mike Lewis Date: Thu, 11 Nov 2021 14:12:38 +0000 Subject: [PATCH 07/24] authz: update permission-node api-report Signed-off-by: Mike Lewis --- plugins/permission-node/api-report.md | 74 ++++++++++++--------------- 1 file changed, 33 insertions(+), 41 deletions(-) diff --git a/plugins/permission-node/api-report.md b/plugins/permission-node/api-report.md index 1285207e61..fac7e8d7fa 100644 --- a/plugins/permission-node/api-report.md +++ b/plugins/permission-node/api-report.md @@ -3,39 +3,25 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import { AuthorizeRequest } from '@backstage/permission-common'; import { AuthorizeResult } from '@backstage/permission-common'; import { BackstageIdentity } from '@backstage/plugin-auth-backend'; -import { DefinitiveAuthorizeResult } from '@backstage/permission-common'; -import { OpaqueAuthorizeRequest } from '@backstage/permission-common'; import { PermissionCondition } from '@backstage/permission-common'; import { PermissionCriteria } from '@backstage/permission-common'; import { Router } from 'express'; -// Warning: (ae-missing-release-tag) "AllowAllPermissionPolicy" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -export class AllowAllPermissionPolicy implements PermissionPolicy { - // (undocumented) - handle( - _request: OpaqueAuthorizeRequest, - _user?: BackstageIdentity, - ): Promise; -} - -// Warning: (ae-missing-release-tag) "ApplyConditionsRequest" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export type ApplyConditionsRequest = { resourceRef: string; resourceType: string; - conditions: PermissionCriteria>; + conditions: PermissionCriteria; }; -// Warning: (ae-missing-release-tag) "ConditionalPolicyResult" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-unresolved-link) The @link reference could not be resolved: The package "@backstage/plugin-permission-node" does not have an export "AuthorizeResult" // -// @public (undocumented) +// @public export type ConditionalPolicyResult = { - result: AuthorizeResult.MAYBE; + result: AuthorizeResult.CONDITIONAL; conditions: { pluginId: string; resourceType: string; @@ -47,7 +33,7 @@ export type ConditionalPolicyResult = { // // @public (undocumented) export const conditionFor: ( - rule: PermissionRule, + rule: PermissionRule, ) => (...params: TParams) => { rule: string; params: TParams; @@ -59,7 +45,7 @@ export const conditionFor: ( export const createPermissionIntegration: < TResource, TRules extends { - [key: string]: PermissionRule; + [key: string]: PermissionRule; }, TGetResourceParams extends any[] = [], >({ @@ -78,49 +64,55 @@ 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, any>, + rule: PermissionRule, unknown[]>, ) => void; }; -// Warning: (ae-missing-release-tag) "PermissionPolicy" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export interface PermissionPolicy { + // Warning: (ae-forgotten-export) The symbol "PolicyAuthorizeRequest" needs to be exported by the entry point index.d.ts + // // (undocumented) handle( - request: OpaqueAuthorizeRequest, + request: PolicyAuthorizeRequest, user?: BackstageIdentity, ): Promise; } -// Warning: (ae-missing-release-tag) "PermissionRule" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-unresolved-link) The @link reference could not be resolved: The package "@backstage/plugin-permission-node" does not have an export "AuthorizeResult" // -// @public (undocumented) -export type PermissionRule = { +// @public +export type PermissionRule< + TResource, + TQuery, + TParams extends unknown[] = unknown[], +> = { name: string; description: string; apply(resource: TResource, ...params: TParams): boolean; toQuery(...params: TParams): TQuery | PermissionCriteria; }; -// Warning: (ae-missing-release-tag) "PolicyResult" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -export type PolicyResult = DefinitiveAuthorizeResult | ConditionalPolicyResult; +// @public +export type PolicyResult = + | { + result: AuthorizeResult.ALLOW | AuthorizeResult.DENY; + } + | ConditionalPolicyResult; // Warnings were encountered during analysis: // -// src/integration/createPermissionIntegration.d.ts:23:5 - (ae-forgotten-export) The symbol "QueryType" needs to be exported by the entry point index.d.ts -// src/integration/createPermissionIntegration.d.ts:24:5 - (ae-forgotten-export) The symbol "Conditions" needs to be exported by the entry point index.d.ts - -// (No @packageDocumentation comment for this package) +// 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 ``` From 22f5de984007ecd6a4d24316e816a64b9c092d4b Mon Sep 17 00:00:00 2001 From: Mike Lewis Date: Thu, 11 Nov 2021 14:13:26 +0000 Subject: [PATCH 08/24] authz: update createPermissionIntegration to work with new PermissionCriteria type Signed-off-by: Mike Lewis --- plugins/permission-node/api-report.md | 12 +- .../createPermissionIntegration.ts | 68 ++---- .../src/integration/util.test.ts | 217 ++++++++++++++++++ .../permission-node/src/integration/util.ts | 68 ++++++ 4 files changed, 304 insertions(+), 61 deletions(-) create mode 100644 plugins/permission-node/src/integration/util.test.ts create mode 100644 plugins/permission-node/src/integration/util.ts 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); +}; From 2b668f2eb886970a73b7f9498d5641a5fbe5d45b Mon Sep 17 00:00:00 2001 From: Mike Lewis Date: Thu, 11 Nov 2021 18:26:46 +0000 Subject: [PATCH 09/24] build(deps): add dependency on zod in permission-node Signed-off-by: Mike Lewis --- plugins/permission-node/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/permission-node/package.json b/plugins/permission-node/package.json index 112976b78b..85e21049ed 100644 --- a/plugins/permission-node/package.json +++ b/plugins/permission-node/package.json @@ -22,7 +22,8 @@ "@backstage/permission-common": "^0.1.0", "@backstage/plugin-auth-backend": "^0.4.6", "@types/express": "*", - "express": "^4.17.1" + "express": "^4.17.1", + "zod": "^3.11.6" }, "devDependencies": { "@backstage/cli": "^0.8.1" From 5c75c89106f824ae2d202cc2ee3eaef396160d28 Mon Sep 17 00:00:00 2001 From: Mike Lewis Date: Thu, 11 Nov 2021 18:27:27 +0000 Subject: [PATCH 10/24] authz: improve error handling in permission-node apply-conditions route Signed-off-by: Mike Lewis --- plugins/permission-node/api-report.md | 4 +- .../createPermissionIntegration.ts | 47 ++++++++++++++++--- 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/plugins/permission-node/api-report.md b/plugins/permission-node/api-report.md index 407ea3dc84..fe26dc2a1a 100644 --- a/plugins/permission-node/api-report.md +++ b/plugins/permission-node/api-report.md @@ -111,6 +111,6 @@ export type PolicyResult = // Warnings were encountered during analysis: // -// 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 +// 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 ``` diff --git a/plugins/permission-node/src/integration/createPermissionIntegration.ts b/plugins/permission-node/src/integration/createPermissionIntegration.ts index 35d6f0da54..cbdcb72041 100644 --- a/plugins/permission-node/src/integration/createPermissionIntegration.ts +++ b/plugins/permission-node/src/integration/createPermissionIntegration.ts @@ -15,6 +15,7 @@ */ import express, { Response, Router } from 'express'; +import { z } from 'zod'; import { AuthorizeResult, PermissionCondition, @@ -24,6 +25,26 @@ 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. @@ -105,15 +126,25 @@ export const createPermissionIntegration = < express.json(), async ( req, - res: Response<{ - result: Omit; - }>, + res: Response< + | { + result: Omit; + } + | string + >, ) => { - // TODO(authorization-framework): validate input - const body = req.body as ApplyConditionsRequest; + 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) { - throw new Error(`Unexpected resource type: ${body.resourceType}`); + return res + .status(400) + .send(`Unexpected resource type: ${body.resourceType}.`); } const resource = await getResource( @@ -122,7 +153,9 @@ export const createPermissionIntegration = < ); if (!resource) { - return res.status(400).end(); + return res + .status(400) + .send(`Resource for ref ${body.resourceRef} not found.`); } return res.status(200).json({ From f72d7bba0e1a93343b76568b351220c0a608e040 Mon Sep 17 00:00:00 2001 From: Mike Lewis Date: Thu, 11 Nov 2021 18:27:46 +0000 Subject: [PATCH 11/24] authz: add testsuite for createPermissionIntegration Signed-off-by: Mike Lewis --- plugins/permission-node/package.json | 4 +- .../createPermissionIntegration.test.ts | 293 ++++++++++++++++++ 2 files changed, 296 insertions(+), 1 deletion(-) create mode 100644 plugins/permission-node/src/integration/createPermissionIntegration.test.ts diff --git a/plugins/permission-node/package.json b/plugins/permission-node/package.json index 85e21049ed..a23cb0fae2 100644 --- a/plugins/permission-node/package.json +++ b/plugins/permission-node/package.json @@ -26,7 +26,9 @@ "zod": "^3.11.6" }, "devDependencies": { - "@backstage/cli": "^0.8.1" + "@backstage/cli": "^0.8.1", + "@types/supertest": "^2.0.8", + "supertest": "^6.1.3" }, "files": [ "dist" diff --git a/plugins/permission-node/src/integration/createPermissionIntegration.test.ts b/plugins/permission-node/src/integration/createPermissionIntegration.test.ts new file mode 100644 index 0000000000..6b0ca948bf --- /dev/null +++ b/plugins/permission-node/src/integration/createPermissionIntegration.test.ts @@ -0,0 +1,293 @@ +/* + * 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/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 }); + }); + }); +}); From e2088541ddc81dbeff9331ae7711ed452233021f Mon Sep 17 00:00:00 2001 From: Mike Lewis Date: Fri, 12 Nov 2021 12:30:04 +0000 Subject: [PATCH 12/24] authz: update references to permission-common after move to plugins Signed-off-by: Mike Lewis --- plugins/permission-node/api-report.md | 8 ++++---- plugins/permission-node/package.json | 2 +- .../src/integration/createPermissionIntegration.test.ts | 2 +- .../src/integration/createPermissionIntegration.ts | 2 +- plugins/permission-node/src/integration/util.test.ts | 2 +- plugins/permission-node/src/integration/util.ts | 2 +- plugins/permission-node/src/policy/types.ts | 2 +- plugins/permission-node/src/types.ts | 2 +- 8 files changed, 11 insertions(+), 11 deletions(-) diff --git a/plugins/permission-node/api-report.md b/plugins/permission-node/api-report.md index fe26dc2a1a..0044807857 100644 --- a/plugins/permission-node/api-report.md +++ b/plugins/permission-node/api-report.md @@ -3,11 +3,11 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts -import { AuthorizeRequest } from '@backstage/permission-common'; -import { AuthorizeResult } from '@backstage/permission-common'; +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/permission-common'; -import { PermissionCriteria } from '@backstage/permission-common'; +import { PermissionCondition } from '@backstage/plugin-permission-common'; +import { PermissionCriteria } from '@backstage/plugin-permission-common'; import { Router } from 'express'; // @public diff --git a/plugins/permission-node/package.json b/plugins/permission-node/package.json index a23cb0fae2..eaf9efdbba 100644 --- a/plugins/permission-node/package.json +++ b/plugins/permission-node/package.json @@ -19,8 +19,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/permission-common": "^0.1.0", "@backstage/plugin-auth-backend": "^0.4.6", + "@backstage/plugin-permission-common": "^0.1.0", "@types/express": "*", "express": "^4.17.1", "zod": "^3.11.6" diff --git a/plugins/permission-node/src/integration/createPermissionIntegration.test.ts b/plugins/permission-node/src/integration/createPermissionIntegration.test.ts index 6b0ca948bf..1ef310954f 100644 --- a/plugins/permission-node/src/integration/createPermissionIntegration.test.ts +++ b/plugins/permission-node/src/integration/createPermissionIntegration.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { AuthorizeResult } from '@backstage/permission-common'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; import express, { Express, Router } from 'express'; import request from 'supertest'; import { createPermissionIntegration } from './createPermissionIntegration'; diff --git a/plugins/permission-node/src/integration/createPermissionIntegration.ts b/plugins/permission-node/src/integration/createPermissionIntegration.ts index cbdcb72041..7a7ae0ea4b 100644 --- a/plugins/permission-node/src/integration/createPermissionIntegration.ts +++ b/plugins/permission-node/src/integration/createPermissionIntegration.ts @@ -20,7 +20,7 @@ import { AuthorizeResult, PermissionCondition, PermissionCriteria, -} from '@backstage/permission-common'; +} from '@backstage/plugin-permission-common'; import { PermissionRule } from '../types'; import { conditionFor } from './conditionFor'; import { applyConditions, mapConditions } from './util'; diff --git a/plugins/permission-node/src/integration/util.test.ts b/plugins/permission-node/src/integration/util.test.ts index 44a000d395..93f51d8275 100644 --- a/plugins/permission-node/src/integration/util.test.ts +++ b/plugins/permission-node/src/integration/util.test.ts @@ -17,7 +17,7 @@ import { PermissionCondition, PermissionCriteria, -} from '@backstage/permission-common'; +} from '@backstage/plugin-permission-common'; import { applyConditions, mapConditions } from './util'; describe('integration utils', () => { diff --git a/plugins/permission-node/src/integration/util.ts b/plugins/permission-node/src/integration/util.ts index 355964d573..631aee269e 100644 --- a/plugins/permission-node/src/integration/util.ts +++ b/plugins/permission-node/src/integration/util.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { PermissionCriteria } from '@backstage/permission-common'; +import { PermissionCriteria } from '@backstage/plugin-permission-common'; const isAndCriteria = ( filter: PermissionCriteria, diff --git a/plugins/permission-node/src/policy/types.ts b/plugins/permission-node/src/policy/types.ts index e81757d59c..d594d3bdb7 100644 --- a/plugins/permission-node/src/policy/types.ts +++ b/plugins/permission-node/src/policy/types.ts @@ -19,7 +19,7 @@ import { AuthorizeResult, PermissionCondition, PermissionCriteria, -} from '@backstage/permission-common'; +} from '@backstage/plugin-permission-common'; import { BackstageIdentity } from '@backstage/plugin-auth-backend'; /** diff --git a/plugins/permission-node/src/types.ts b/plugins/permission-node/src/types.ts index 308eafe481..d3379c40a6 100644 --- a/plugins/permission-node/src/types.ts +++ b/plugins/permission-node/src/types.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { PermissionCriteria } from '@backstage/permission-common'; +import type { PermissionCriteria } from '@backstage/plugin-permission-common'; /** * A conditional rule that can be provided in an {@link AuthorizeResult} response to an From 75dd00c8b627b4d80e1b1c0ca714897ff6a9af5e Mon Sep 17 00:00:00 2001 From: Mike Lewis Date: Fri, 12 Nov 2021 12:37:23 +0000 Subject: [PATCH 13/24] authz: add missing fields to permission-node package.json Signed-off-by: Mike Lewis --- plugins/permission-node/package.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/plugins/permission-node/package.json b/plugins/permission-node/package.json index eaf9efdbba..4e17d7ecfb 100644 --- a/plugins/permission-node/package.json +++ b/plugins/permission-node/package.json @@ -10,6 +10,16 @@ "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", From 5c5a1d16e41ee5e1baddba44bcaa7d95e5fcaa07 Mon Sep 17 00:00:00 2001 From: Mike Lewis Date: Fri, 12 Nov 2021 13:43:59 +0000 Subject: [PATCH 14/24] authz: remove superfluous union type PermissionCriteria is a union type which already includes TQuery itself, so we don't need a second union here. Signed-off-by: Mike Lewis --- plugins/permission-node/api-report.md | 2 +- plugins/permission-node/src/types.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/permission-node/api-report.md b/plugins/permission-node/api-report.md index 0044807857..4ee8953d70 100644 --- a/plugins/permission-node/api-report.md +++ b/plugins/permission-node/api-report.md @@ -99,7 +99,7 @@ export type PermissionRule< name: string; description: string; apply(resource: TResource, ...params: TParams): boolean; - toQuery(...params: TParams): TQuery | PermissionCriteria; + toQuery(...params: TParams): PermissionCriteria; }; // @public diff --git a/plugins/permission-node/src/types.ts b/plugins/permission-node/src/types.ts index d3379c40a6..f983c80ea5 100644 --- a/plugins/permission-node/src/types.ts +++ b/plugins/permission-node/src/types.ts @@ -49,5 +49,5 @@ export type PermissionRule< * can be used for loading a collection of resources efficiently with conditional criteria already * applied. */ - toQuery(...params: TParams): TQuery | PermissionCriteria; + toQuery(...params: TParams): PermissionCriteria; }; From 27d5f5fed3e4343f2f2389fa5ba57ebf9e496550 Mon Sep 17 00:00:00 2001 From: Tim Hansen Date: Wed, 17 Nov 2021 11:17:50 -0700 Subject: [PATCH 15/24] Improve api-report Signed-off-by: Tim Hansen --- plugins/permission-node/api-report.md | 44 ++++++++++------ plugins/permission-node/package.json | 1 + .../src/integration/conditionFor.ts | 13 +++++ .../createPermissionIntegration.ts | 50 +++++++++++++++++-- plugins/permission-node/src/policy/index.ts | 1 + plugins/permission-node/src/policy/types.ts | 10 ++-- plugins/permission-node/src/types.ts | 4 +- 7 files changed, 97 insertions(+), 26 deletions(-) diff --git a/plugins/permission-node/api-report.md b/plugins/permission-node/api-report.md index 4ee8953d70..a292f6acd2 100644 --- a/plugins/permission-node/api-report.md +++ b/plugins/permission-node/api-report.md @@ -17,8 +17,15 @@ export type ApplyConditionsRequest = { conditions: PermissionCriteria; }; -// Warning: (ae-unresolved-link) The @link reference could not be resolved: The package "@backstage/plugin-permission-node" does not have an export "AuthorizeResult" -// +// @public +export type Condition = TRule extends PermissionRule< + any, + any, + infer TParams +> + ? (...params: TParams) => PermissionCondition + : never; + // @public export type ConditionalPolicyResult = { result: AuthorizeResult.CONDITIONAL; @@ -29,9 +36,7 @@ export type ConditionalPolicyResult = { }; }; -// Warning: (ae-missing-release-tag) "conditionFor" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export const conditionFor: ( rule: PermissionRule, ) => (...params: TParams) => { @@ -39,9 +44,14 @@ export const conditionFor: ( params: TParams; }; -// Warning: (ae-missing-release-tag) "createPermissionIntegration" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public +export type Conditions< + TRules extends Record>, +> = { + [Name in keyof TRules]: Condition; +}; + +// @public export const createPermissionIntegration: < TResource, TRules extends { @@ -79,8 +89,6 @@ export const createPermissionIntegration: < // @public export interface PermissionPolicy { - // Warning: (ae-forgotten-export) The symbol "PolicyAuthorizeRequest" needs to be exported by the entry point index.d.ts - // // (undocumented) handle( request: PolicyAuthorizeRequest, @@ -88,8 +96,6 @@ export interface PermissionPolicy { ): Promise; } -// Warning: (ae-unresolved-link) The @link reference could not be resolved: The package "@backstage/plugin-permission-node" does not have an export "AuthorizeResult" -// // @public export type PermissionRule< TResource, @@ -102,6 +108,9 @@ export type PermissionRule< toQuery(...params: TParams): PermissionCriteria; }; +// @public +export type PolicyAuthorizeRequest = Omit; + // @public export type PolicyResult = | { @@ -109,8 +118,11 @@ export type PolicyResult = } | ConditionalPolicyResult; -// 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 +// @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 4e17d7ecfb..830c945158 100644 --- a/plugins/permission-node/package.json +++ b/plugins/permission-node/package.json @@ -8,6 +8,7 @@ "publishConfig": { "access": "public", "main": "dist/index.cjs.js", + "module": "dist/index.esm.js", "types": "dist/index.d.ts" }, "homepage": "https://backstage.io", diff --git a/plugins/permission-node/src/integration/conditionFor.ts b/plugins/permission-node/src/integration/conditionFor.ts index 3a13a3afe3..7451b35bca 100644 --- a/plugins/permission-node/src/integration/conditionFor.ts +++ b/plugins/permission-node/src/integration/conditionFor.ts @@ -16,6 +16,19 @@ import { PermissionRule } from '../types'; +/** + * Creates a condition function for a given authorization rule and parameter type. + * + * 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. + * @public + */ export const conditionFor = (rule: PermissionRule) => (...params: TParams) => ({ diff --git a/plugins/permission-node/src/integration/createPermissionIntegration.ts b/plugins/permission-node/src/integration/createPermissionIntegration.ts index 7a7ae0ea4b..72118572e5 100644 --- a/plugins/permission-node/src/integration/createPermissionIntegration.ts +++ b/plugins/permission-node/src/integration/createPermissionIntegration.ts @@ -56,21 +56,65 @@ export type ApplyConditionsRequest = { conditions: PermissionCriteria; }; -type Condition = TRule extends PermissionRule +/** + * 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; -type Conditions>> = { +/** + * 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; }; -type QueryType = TRules extends Record< +/** + * 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 }, diff --git a/plugins/permission-node/src/policy/index.ts b/plugins/permission-node/src/policy/index.ts index 226d50df21..f04c2a094c 100644 --- a/plugins/permission-node/src/policy/index.ts +++ b/plugins/permission-node/src/policy/index.ts @@ -17,5 +17,6 @@ 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 index d594d3bdb7..36186d5e57 100644 --- a/plugins/permission-node/src/policy/types.ts +++ b/plugins/permission-node/src/policy/types.ts @@ -25,9 +25,9 @@ import { BackstageIdentity } from '@backstage/plugin-auth-backend'; /** * An authorization request to be evaluated by the {@link PermissionPolicy}. * - * This differs from {@link 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. + * 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; @@ -39,8 +39,8 @@ export type PolicyAuthorizeRequest = Omit; * conditions hold when evaluated. The conditions will be evaluated by the corresponding plugin * which knows about the referenced permission rules. * - * Similar to {@link AuthorizeResult}, but with the plugin and resource identifiers needed to - * evaluate the returned conditions. + * Similar to {@link @backstage/permission-common#AuthorizeResult}, but with the plugin and resource + * identifiers needed to evaluate the returned conditions. * @public */ export type ConditionalPolicyResult = { diff --git a/plugins/permission-node/src/types.ts b/plugins/permission-node/src/types.ts index f983c80ea5..a4e95fbd61 100644 --- a/plugins/permission-node/src/types.ts +++ b/plugins/permission-node/src/types.ts @@ -17,8 +17,8 @@ import type { PermissionCriteria } from '@backstage/plugin-permission-common'; /** - * A conditional rule that can be provided in an {@link AuthorizeResult} response to an - * authorization request. + * A conditional rule that can be provided in an + * {@link @backstage/permission-common#AuthorizeResult} response to an authorization request. * * 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 From 0eb339c3a482cab5ac323d9cd89e0db7d3bf8809 Mon Sep 17 00:00:00 2001 From: Mike Lewis Date: Thu, 18 Nov 2021 13:54:28 +0000 Subject: [PATCH 16/24] permission-node: more visible condition transform in mapCriteria test Signed-off-by: Mike Lewis --- .../src/integration/util.test.ts | 43 ++++++------------- 1 file changed, 14 insertions(+), 29 deletions(-) diff --git a/plugins/permission-node/src/integration/util.test.ts b/plugins/permission-node/src/integration/util.test.ts index 93f51d8275..72e87b2a7b 100644 --- a/plugins/permission-node/src/integration/util.test.ts +++ b/plugins/permission-node/src/integration/util.test.ts @@ -25,21 +25,21 @@ describe('integration utils', () => { criteria: PermissionCriteria; expectedResult: { applyConditions: boolean; - mapConditions: PermissionCriteria<{ query: string; params: unknown[] }>; + mapConditions: PermissionCriteria; }; }[] = [ { criteria: { rule: 'test-rule-1', params: [] }, expectedResult: { applyConditions: true, - mapConditions: { query: 'test-rule-1', params: [] }, + mapConditions: 'test-rule-1', }, }, { criteria: { rule: 'test-rule-2', params: [] }, expectedResult: { applyConditions: false, - mapConditions: { query: 'test-rule-2', params: [] }, + mapConditions: 'test-rule-2', }, }, { @@ -52,10 +52,7 @@ describe('integration utils', () => { expectedResult: { applyConditions: true, mapConditions: { - anyOf: [ - { query: 'test-rule-1', params: ['a', 'b'] }, - { query: 'test-rule-2', params: ['c', 'd'] }, - ], + anyOf: ['test-rule-1:a,b', 'test-rule-2:c,d'], }, }, }, @@ -69,10 +66,7 @@ describe('integration utils', () => { expectedResult: { applyConditions: false, mapConditions: { - allOf: [ - { query: 'test-rule-1', params: ['e', 'f'] }, - { query: 'test-rule-2', params: ['g', 'h'] }, - ], + allOf: ['test-rule-1:e,f', 'test-rule-2:g,h'], }, }, }, @@ -83,7 +77,7 @@ describe('integration utils', () => { expectedResult: { applyConditions: true, mapConditions: { - not: { query: 'test-rule-2', params: ['i'] }, + not: 'test-rule-2:i', }, }, }, @@ -111,17 +105,11 @@ describe('integration utils', () => { mapConditions: { allOf: [ { - anyOf: [ - { query: 'test-rule-1', params: ['j'] }, - { query: 'test-rule-2', params: ['k'] }, - ], + anyOf: ['test-rule-1:j', 'test-rule-2:k'], }, { not: { - allOf: [ - { query: 'test-rule-1', params: ['l'] }, - { query: 'test-rule-2', params: ['m'] }, - ], + allOf: ['test-rule-1:l', 'test-rule-2:m'], }, }, ], @@ -152,17 +140,11 @@ describe('integration utils', () => { mapConditions: { allOf: [ { - anyOf: [ - { query: 'test-rule-1', params: ['j'] }, - { query: 'test-rule-2', params: ['k'] }, - ], + anyOf: ['test-rule-1:j', 'test-rule-2:k'], }, { not: { - allOf: [ - { query: 'test-rule-1', params: ['l'] }, - { not: { query: 'test-rule-2', params: ['m'] } }, - ], + allOf: ['test-rule-1:l', { not: 'test-rule-2:m' }], }, }, ], @@ -194,7 +176,10 @@ describe('integration utils', () => { }); describe('mapConditions', () => { - const mapFn = jest.fn(({ rule, params }) => ({ query: rule, params })); + 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); From 70ce7c91e269d8ed84405daae49eed3096da6ff6 Mon Sep 17 00:00:00 2001 From: Mike Lewis Date: Fri, 19 Nov 2021 18:40:41 +0000 Subject: [PATCH 17/24] permission-node: bump @backstage/cli version Signed-off-by: Mike Lewis --- plugins/permission-node/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/permission-node/package.json b/plugins/permission-node/package.json index 830c945158..79923eae97 100644 --- a/plugins/permission-node/package.json +++ b/plugins/permission-node/package.json @@ -37,7 +37,7 @@ "zod": "^3.11.6" }, "devDependencies": { - "@backstage/cli": "^0.8.1", + "@backstage/cli": "^0.9.0", "@types/supertest": "^2.0.8", "supertest": "^6.1.3" }, From 3254303a96937f9b346ae9259107a823c6d78b9e Mon Sep 17 00:00:00 2001 From: Mike Lewis Date: Fri, 19 Nov 2021 13:43:59 +0000 Subject: [PATCH 18/24] permission-node: refactor and split createPermissionIntegration This refactor makes the createPermissionIntegration system much more flexible by splitting it up into a few different helpers with different responsibilities. This frees up plugin authors to connect together the different parts of the permission integration in whatever way is convenient for them, and makes the process of registering additional permission rules a bit more explicit, by requiring them to be passed in when constructing the systems for transforming or applying conditions. Signed-off-by: Mike Lewis Co-authored-by: Joon Park Co-authored-by: Tim Hansen --- plugins/permission-node/api-report.md | 67 ++-- plugins/permission-node/package.json | 3 +- .../createConditionExports.test.ts | 79 +++++ .../src/integration/createConditionExports.ts | 94 ++++++ ...test.ts => createConditionFactory.test.ts} | 8 +- ...ditionFor.ts => createConditionFactory.ts} | 13 +- .../createConditionTransformer.test.ts | 158 ++++++++++ .../integration/createConditionTransformer.ts | 78 +++++ .../createPermissionIntegration.test.ts | 293 ------------------ .../createPermissionIntegration.ts | 242 --------------- .../createPermissionIntegrationRouter.test.ts | 210 +++++++++++++ .../createPermissionIntegrationRouter.ts | 165 ++++++++++ .../permission-node/src/integration/index.ts | 6 +- .../src/integration/util.test.ts | 230 ++++---------- .../permission-node/src/integration/util.ts | 53 +--- 15 files changed, 908 insertions(+), 791 deletions(-) create mode 100644 plugins/permission-node/src/integration/createConditionExports.test.ts create mode 100644 plugins/permission-node/src/integration/createConditionExports.ts rename plugins/permission-node/src/integration/{conditionFor.test.ts => createConditionFactory.test.ts} (80%) rename plugins/permission-node/src/integration/{conditionFor.ts => createConditionFactory.ts} (68%) create mode 100644 plugins/permission-node/src/integration/createConditionTransformer.test.ts create mode 100644 plugins/permission-node/src/integration/createConditionTransformer.ts delete mode 100644 plugins/permission-node/src/integration/createPermissionIntegration.test.ts delete mode 100644 plugins/permission-node/src/integration/createPermissionIntegration.ts create mode 100644 plugins/permission-node/src/integration/createPermissionIntegrationRouter.test.ts create mode 100644 plugins/permission-node/src/integration/createPermissionIntegrationRouter.ts 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; + }; }; From 5bff67aac43eb68ceece4704fa788959b9e07c73 Mon Sep 17 00:00:00 2001 From: Mike Lewis Date: Mon, 22 Nov 2021 13:06:39 +0000 Subject: [PATCH 19/24] permission-node: expose ApplyConditionsResponse type This type will be shared with the backend. Signed-off-by: Mike Lewis --- plugins/permission-node/api-report.md | 5 +++++ .../integration/createPermissionIntegrationRouter.ts | 10 ++++++++++ 2 files changed, 15 insertions(+) diff --git a/plugins/permission-node/api-report.md b/plugins/permission-node/api-report.md index 1aab9e1f47..95e1eb0711 100644 --- a/plugins/permission-node/api-report.md +++ b/plugins/permission-node/api-report.md @@ -17,6 +17,11 @@ export type ApplyConditionsRequest = { conditions: PermissionCriteria; }; +// @public +export type ApplyConditionsResponse = { + result: AuthorizeResult.ALLOW | AuthorizeResult.DENY; +}; + // @public export type Condition = TRule extends PermissionRule< any, diff --git a/plugins/permission-node/src/integration/createPermissionIntegrationRouter.ts b/plugins/permission-node/src/integration/createPermissionIntegrationRouter.ts index 6694b893b7..7757678128 100644 --- a/plugins/permission-node/src/integration/createPermissionIntegrationRouter.ts +++ b/plugins/permission-node/src/integration/createPermissionIntegrationRouter.ts @@ -61,6 +61,16 @@ export type ApplyConditionsRequest = { 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, From b08dbb103552ad16064c0c84bad975311152320d Mon Sep 17 00:00:00 2001 From: Mike Lewis Date: Wed, 24 Nov 2021 14:18:11 +0000 Subject: [PATCH 20/24] permission-node: destructure options inside function to simplify api-report Signed-off-by: Mike Lewis --- plugins/permission-node/api-report.md | 6 +-- .../src/integration/createConditionExports.ts | 38 ++++++++++--------- 2 files changed, 21 insertions(+), 23 deletions(-) diff --git a/plugins/permission-node/api-report.md b/plugins/permission-node/api-report.md index 95e1eb0711..e5fe9a7087 100644 --- a/plugins/permission-node/api-report.md +++ b/plugins/permission-node/api-report.md @@ -57,11 +57,7 @@ export type ConditionTransformer = ( export const createConditionExports: < TResource, TRules extends Record>, ->({ - pluginId, - resourceType, - rules, -}: { +>(options: { pluginId: string; resourceType: string; rules: TRules; diff --git a/plugins/permission-node/src/integration/createConditionExports.ts b/plugins/permission-node/src/integration/createConditionExports.ts index d7f8c675d2..d69064a56c 100644 --- a/plugins/permission-node/src/integration/createConditionExports.ts +++ b/plugins/permission-node/src/integration/createConditionExports.ts @@ -63,11 +63,7 @@ export type Conditions< export const createConditionExports = < TResource, TRules extends Record>, ->({ - pluginId, - resourceType, - rules, -}: { +>(options: { pluginId: string; resourceType: string; rules: TRules; @@ -78,17 +74,23 @@ export const createConditionExports = < resourceType: string; conditions: PermissionCriteria; }; -} => ({ - conditions: Object.entries(rules).reduce( - (acc, [key, rule]) => ({ - ...acc, - [key]: createConditionFactory(rule), +} => { + 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, }), - {} as Conditions, - ), - createConditions: (conditions: PermissionCriteria) => ({ - pluginId, - resourceType, - conditions, - }), -}); + }; +}; From f5a25ec8046dd40c05f258ae5f58a430b37a9039 Mon Sep 17 00:00:00 2001 From: Mike Lewis Date: Wed, 24 Nov 2021 14:19:32 +0000 Subject: [PATCH 21/24] permission-node: fix typo in doc comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Signed-off-by: Mike Lewis --- .../permission-node/src/integration/createConditionFactory.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/permission-node/src/integration/createConditionFactory.ts b/plugins/permission-node/src/integration/createConditionFactory.ts index 066233bf41..8ee94091e1 100644 --- a/plugins/permission-node/src/integration/createConditionFactory.ts +++ b/plugins/permission-node/src/integration/createConditionFactory.ts @@ -26,7 +26,7 @@ import { PermissionRule } from '../types'; * 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. + * they're intended to integrate. * * @public */ From aded9bbc22444e1da75c0d60947096e49d7e8154 Mon Sep 17 00:00:00 2001 From: Mike Lewis Date: Wed, 24 Nov 2021 14:20:56 +0000 Subject: [PATCH 22/24] permission-node: set version to 0.0.0 before initial release Signed-off-by: Mike Lewis --- plugins/permission-node/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/permission-node/package.json b/plugins/permission-node/package.json index 4a9c837eb6..e22e2d2de4 100644 --- a/plugins/permission-node/package.json +++ b/plugins/permission-node/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-permission-node", "description": "Common permission and authorization utilities for backend plugins", - "version": "0.1.0", + "version": "0.0.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", From 44b46644d996c82aa1aa611e539db3844661fe5d Mon Sep 17 00:00:00 2001 From: Mike Lewis Date: Wed, 24 Nov 2021 14:22:32 +0000 Subject: [PATCH 23/24] permission-node: add changeset for initial release Signed-off-by: Mike Lewis --- .changeset/heavy-tools-hang.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/heavy-tools-hang.md 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). From 370da15e7b225de270c8fc90f6cb8b817a02c859 Mon Sep 17 00:00:00 2001 From: Mike Lewis Date: Wed, 24 Nov 2021 14:30:21 +0000 Subject: [PATCH 24/24] permission-node: separate doc comment summaries with @remarks tag Signed-off-by: Mike Lewis --- plugins/permission-node/src/index.ts | 1 - .../src/integration/createConditionExports.ts | 6 +++++- .../src/integration/createConditionFactory.ts | 2 ++ .../src/integration/createPermissionIntegrationRouter.ts | 2 ++ plugins/permission-node/src/policy/types.ts | 9 +++++++++ plugins/permission-node/src/types.ts | 3 +++ 6 files changed, 21 insertions(+), 2 deletions(-) diff --git a/plugins/permission-node/src/index.ts b/plugins/permission-node/src/index.ts index 697c02ab22..39527bac71 100644 --- a/plugins/permission-node/src/index.ts +++ b/plugins/permission-node/src/index.ts @@ -19,7 +19,6 @@ * * @packageDocumentation */ - export * from './integration'; export * from './policy'; export * from './types'; diff --git a/plugins/permission-node/src/integration/createConditionExports.ts b/plugins/permission-node/src/integration/createConditionExports.ts index d69064a56c..30cff6628a 100644 --- a/plugins/permission-node/src/integration/createConditionExports.ts +++ b/plugins/permission-node/src/integration/createConditionExports.ts @@ -49,7 +49,11 @@ export type Conditions< /** * 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 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. diff --git a/plugins/permission-node/src/integration/createConditionFactory.ts b/plugins/permission-node/src/integration/createConditionFactory.ts index 8ee94091e1..84a8dc86a6 100644 --- a/plugins/permission-node/src/integration/createConditionFactory.ts +++ b/plugins/permission-node/src/integration/createConditionFactory.ts @@ -19,6 +19,8 @@ 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. diff --git a/plugins/permission-node/src/integration/createPermissionIntegrationRouter.ts b/plugins/permission-node/src/integration/createPermissionIntegrationRouter.ts index 7757678128..99e282b144 100644 --- a/plugins/permission-node/src/integration/createPermissionIntegrationRouter.ts +++ b/plugins/permission-node/src/integration/createPermissionIntegrationRouter.ts @@ -97,6 +97,8 @@ const applyConditions = ( * 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 diff --git a/plugins/permission-node/src/policy/types.ts b/plugins/permission-node/src/policy/types.ts index 36186d5e57..44380bad26 100644 --- a/plugins/permission-node/src/policy/types.ts +++ b/plugins/permission-node/src/policy/types.ts @@ -25,9 +25,12 @@ 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; @@ -35,6 +38,8 @@ 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. @@ -54,6 +59,7 @@ export type ConditionalPolicyResult = { /** * The result of evaluating an authorization request with a {@link PermissionPolicy}. + * * @public */ export type PolicyResult = @@ -63,6 +69,8 @@ export type PolicyResult = /** * 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 @@ -71,6 +79,7 @@ export type PolicyResult = * 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 { diff --git a/plugins/permission-node/src/types.ts b/plugins/permission-node/src/types.ts index a4e95fbd61..678befc99c 100644 --- a/plugins/permission-node/src/types.ts +++ b/plugins/permission-node/src/types.ts @@ -20,6 +20,8 @@ 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. @@ -27,6 +29,7 @@ import type { PermissionCriteria } from '@backstage/plugin-permission-common'; * 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<