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; +};