diff --git a/.changeset/afraid-kids-jog.md b/.changeset/afraid-kids-jog.md new file mode 100644 index 0000000000..39e4e5c5a1 --- /dev/null +++ b/.changeset/afraid-kids-jog.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-permission-node': patch +--- + +The returned router from `createPermissionIntegrationRouter` is now mutable, allowing for permissions and resources to be added after creation of the router. diff --git a/.changeset/curvy-ways-play.md b/.changeset/curvy-ways-play.md new file mode 100644 index 0000000000..c69b855098 --- /dev/null +++ b/.changeset/curvy-ways-play.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-test-utils': minor +--- + +Added mocks for the new `PermissionsRegistryService`. diff --git a/.changeset/green-jokes-provide.md b/.changeset/green-jokes-provide.md new file mode 100644 index 0000000000..f9fb78252f --- /dev/null +++ b/.changeset/green-jokes-provide.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend-module-unprocessed': patch +--- + +Use new `PermissionsRegistryService` instead of the deprecated `catalogPermissionExtensionPoint`. diff --git a/.changeset/hungry-mirrors-sniff.md b/.changeset/hungry-mirrors-sniff.md new file mode 100644 index 0000000000..f855c1e1a9 --- /dev/null +++ b/.changeset/hungry-mirrors-sniff.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-defaults': patch +--- + +Added default implementation for the new `PermissionsRegistryService`. diff --git a/.changeset/loud-walls-build.md b/.changeset/loud-walls-build.md new file mode 100644 index 0000000000..0ddad96f2f --- /dev/null +++ b/.changeset/loud-walls-build.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': minor +--- + +The catalog backend now supports the new `PermissionsRegistryService`, which can be used to add custom permission rules. diff --git a/.changeset/old-moons-end.md b/.changeset/old-moons-end.md new file mode 100644 index 0000000000..d550ae6325 --- /dev/null +++ b/.changeset/old-moons-end.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-node': patch +--- + +Deprecated the alpha `catalogPermissionExtensionPoint` and related types, since the same functionality is now available via the new `PermissionsRegistryService`. diff --git a/.changeset/sharp-vans-protect.md b/.changeset/sharp-vans-protect.md new file mode 100644 index 0000000000..651c38be8b --- /dev/null +++ b/.changeset/sharp-vans-protect.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-plugin-api': patch +--- + +Added new `PermissionsRegistryService` that is used by plugins to register permissions, resource types, and rules into the permission system. This replaces the existing `createPermissionIntegrationRouter` from `@backstage/plugin-permission-node`. diff --git a/docs/backend-system/core-services/permissionsRegistry.md b/docs/backend-system/core-services/permissionsRegistry.md new file mode 100644 index 0000000000..4b0b87b894 --- /dev/null +++ b/docs/backend-system/core-services/permissionsRegistry.md @@ -0,0 +1,80 @@ +--- +id: permissions +title: Permissions Registry Service +sidebar_label: Permissions Registry +description: Documentation for the Permissions Registry service +--- + +This service allows your plugins to register new permissions, rules, and resource types and integrate with [the permissions framework](../../permissions/overview.md). + +## Using the service + +For a deep dive into how to use the `permissionsRegistry` service, see the [permission guide for plugin authors](../../permissions/plugin-authors/01-setup.md). + +If all you want to do is add new custom permission rules to an existing plugin, you can instead refer to the [custom permission rules guide](../../permissions/custom-rules.md). + +## Migrating from `createPermissionIntegrationRouter` + +Before this service was introduced, plugins would use +`createPermissionIntegrationRouter` to implement the same functionality. To +migrate a plugin, locate the `createPermissionIntegrationRouter` call for your +router and remove it, but copy all options that are passed to it, for example: + +```ts +export async function createRouter() { + const router = Router(); + + /* highlight-remove-start */ + const permissionIntegrationRouter = createPermissionIntegrationRouter({ + resourceType: RESOURCE_TYPE_MY_RESOURCE, + permissions: [myResourcePermissions], + rules: [myResourceRule], + }); + + router.use(permissionIntegrationRouter); + /* highlight-remove-end */ + + // ... +} +``` + +Next, add a dependency on the `PermissionsRegistryService` to your plugin, +and pass it the same options: + +```ts +export const examplePlugin = createBackendPlugin({ + pluginId: 'example', + register(env) { + env.registerInit({ + deps: { + logger: coreServices.logger, + /* highlight-add-next-line */ + permissionsRegistry: coreServices.permissionsRegistry, + }, + /* highlight-remove-next-line */ + async init({ logger }) { + /* highlight-add-next-line */ + async init({ logger, permissionsRegistry }) { + logger.log('This is a silly example plugin with no functionality'); + + /* highlight-add-start */ + permissionsRegistry.addResourceType({ + resourceType: RESOURCE_TYPE_MY_RESOURCE, + permissions: [myResourcePermissions], + rules: [myResourceRule], + }); + /* highlight-add-end */ + }, + }); + }, +}); +``` + +If you only passed the `permissions` option to +`createPermissionIntegrationRouter`, you will want to use +`permissionsRegistry.addPermissions` instead. + +If you passed multiple resources types to `createPermissionIntegrationRouter` +via the `resources` option, you will want to call +`permissionsRegistry.addResourceType` multiple times for each of those +resource types. diff --git a/docs/permissions/custom-rules.md b/docs/permissions/custom-rules.md index 00f4d81939..ca93652f73 100644 --- a/docs/permissions/custom-rules.md +++ b/docs/permissions/custom-rules.md @@ -139,7 +139,13 @@ class CustomPermissionPolicy implements PermissionPolicy { Now that we have a custom rule defined and added to our policy, we need provide it to the catalog plugin. This step is important because the catalog plugin will use the rule's `toQuery` and `apply` methods while evaluating conditional authorize results. There's no guarantee that the catalog and permission backends are running on the same server, so we must explicitly link the rule to ensure that it's available at runtime. -The api for providing custom rules may differ between plugins, but there should typically be an [extension point](../backend-system/architecture/05-extension-points.md) that you can use in your created module to add your rule. For the catalog, this extension point is exposed via `catalogPermissionExtensionPoint`. Here's the steps you'll need to take to add the `isInSystemRule` we created above to the catalog: +:::warning Warning + +The `PermissionsRegistryService` is a fairly new addition and not yet supported by all plugins as they might still be using the old `createPermissionIntegrationRouter` that cannot be extended. If you encounter errors when installing custom rules for a plugin, the plugin may need to be switched to using the `PermissionsRegistryService` first. + +::: + +To install custom rules in a plugin, we need to use the [`PermissionsRegistryService`](../backend-system/core-services/permissionsRegistry.md). Here's the steps you'll need to take to add the `isInSystemRule` we created above to the catalog: 1. We will be using the `@backstage/plugin-catalog-node` package as it contains the extension point we need. Run this to add it: @@ -147,10 +153,10 @@ The api for providing custom rules may differ between plugins, but there should yarn --cwd packages/backend add @backstage/plugin-catalog-node ``` -2. Next create a `catalogPermissionRules.ts` file in the `packages/backend/src/extensions` folder. +2. Next create a `catalogPermissionRules.ts` file in the `packages/backend/src/modules` folder. 3. Then add this as the contents of the new `catalogPermissionRules.ts` file: - ```typescript title="packages/backend/src/extensions/catalogPermissionRules.ts" + ```typescript title="packages/backend/src/modules/catalogPermissionRules.ts" import { createBackendModule } from '@backstage/backend-plugin-api'; import { catalogPermissionExtensionPoint } from '@backstage/plugin-catalog-node/alpha'; import { isInSystemRule } from './permissionPolicyExtension'; @@ -160,9 +166,9 @@ The api for providing custom rules may differ between plugins, but there should moduleId: 'permission-rules', register(reg) { reg.registerInit({ - deps: { catalog: catalogPermissionExtensionPoint }, - async init({ catalog }) { - catalog.addPermissionRules(isInSystemRule); + deps: { permissionsRegistry: coreServices.permissionsRegistry }, + async init({ permissionsRegistry }) { + permissionsRegistry.addPermissionRules([isInSystemRule]); }, }); }, diff --git a/docs/permissions/plugin-authors/02-adding-a-basic-permission-check.md b/docs/permissions/plugin-authors/02-adding-a-basic-permission-check.md index 2d39d5cef3..4053a81f99 100644 --- a/docs/permissions/plugin-authors/02-adding-a-basic-permission-check.md +++ b/docs/permissions/plugin-authors/02-adding-a-basic-permission-check.md @@ -67,8 +67,6 @@ import { LoggerService, HttpAuthService } from '@backstage/backend-plugin-api'; import { InputError, NotAllowedError } from '@backstage/errors'; import { LoggerService, HttpAuthService, PermissionsService } from '@backstage/backend-plugin-api'; import { AuthorizeResult } from '@backstage/plugin-permission-common'; -import { createPermissionIntegrationRouter } from '@backstage/plugin-permission-node'; -import { todoListCreatePermission } from '@internal/plugin-todo-list-common'; /* highlight-add-end */ export interface RouterOptions { @@ -86,12 +84,6 @@ export async function createRouter( /* highlight-add-next-line */ const { logger, httpAuth, permissions } = options; - /* highlight-add-start */ - const permissionIntegrationRouter = createPermissionIntegrationRouter({ - permissions: [todoListCreatePermission], - }); - /* highlight-add-end */ - const router = Router(); router.use(express.json()); @@ -100,9 +92,6 @@ export async function createRouter( response.json({ status: 'ok' }); }); - /* highlight-add-next-line */ - router.use(permissionIntegrationRouter); - router.get('/todos', async (_req, res) => { res.json(getAll()); }); @@ -137,11 +126,13 @@ export async function createRouter( // ... ``` -Pass the `permissions` object to the plugin in `plugins/todo-list-backend/src/plugin.ts`: +Pass the `permissions` service and register the new permission to the plugin in `plugins/todo-list-backend/src/plugin.ts`: ```ts title="plugins/todo-list-backend/src/plugin.ts" import { coreServices, createBackendPlugin } from '@backstage/backend-plugin-api'; import { createRouter } from './service/router'; +/* highlight-add-next-line */ +import { todoListCreatePermission } from '@internal/plugin-todo-list-common'; export const exampleTodoListPlugin = createBackendPlugin({ pluginId: 'todolist', @@ -153,11 +144,16 @@ export const exampleTodoListPlugin = createBackendPlugin({ httpRouter: coreServices.httpRouter, /* highlight-add-next-line */ permissions: coreServices.permissions, + /* highlight-add-next-line */ + permissionsRegistry: coreServices.permissionsRegistry, }, /* highlight-remove-next-line */ async init({ logger, httpAuth, httpRouter }) { /* highlight-add-next-line */ - async init({ logger, httpAuth, httpRouter, permissions }) { + async init({ httpAuth, logger, httpRouter, permissions, permissionsRegistry }) { + /* highlight-add-next-line */ + permissionsRegistry.addPermissions([todoListCreatePermission]); + httpRouter.use( await createRouter({ logger, diff --git a/docs/permissions/plugin-authors/03-adding-a-resource-permission-check.md b/docs/permissions/plugin-authors/03-adding-a-resource-permission-check.md index 3f5a9777b3..8d0e5c75e9 100644 --- a/docs/permissions/plugin-authors/03-adding-a-resource-permission-check.md +++ b/docs/permissions/plugin-authors/03-adding-a-resource-permission-check.md @@ -47,9 +47,9 @@ Notice that unlike `todoListCreatePermission`, the `todoListUpdatePermission` pe ## Setting up authorization for the update permission -To start, let's edit `plugins/todo-list-backend/src/service/router.ts` in the same manner as we did in the previous section: +To start, let's edit `plugins/todo-list-backend/src/plugin.ts` to add the new permission to our plugin: -```ts title="plugins/todo-list-backend/src/service/router.ts" +```ts title="plugins/todo-list-backend/src/plugin.ts" /* highlight-remove-next-line */ import { todoListCreatePermission } from '@internal/plugin-todo-list-common'; /* highlight-add-start */ @@ -61,14 +61,29 @@ import { // ... -const permissionIntegrationRouter = createPermissionIntegrationRouter({ - /* highlight-remove-next-line */ - permissions: [todoListCreatePermission], - /* highlight-add-next-line */ - permissions: [todoListCreatePermission, todoListUpdatePermission], -}); +/* highlight-remove-next-line */ +permissionsRegistry.addPermissions([todoListCreatePermission]); +/* highlight-add-start */ +permissionsRegistry.addPermissions([ + todoListCreatePermission, + todoListUpdatePermission, +]); +/* highlight-add-end */ // ... +``` + +Then let's edit `plugins/todo-list-backend/src/service/router.ts` in the same manner as we did in the previous section: + +```ts title="plugins/todo-list-backend/src/service/router.ts" +/* highlight-remove-next-line */ +import { todoListCreatePermission } from '@internal/plugin-todo-list-common'; +/* highlight-add-start */ +import { + todoListCreatePermission, + todoListUpdatePermission, +} from '@internal/plugin-todo-list-common'; +/* highlight-add-end */ router.put('/todos', async (req, res) => { /* highlight-add-start */ @@ -155,49 +170,50 @@ Specifically, the `apply` function is used to understand whether the passed reso Let's skip the `toQuery` function for now, we'll come back to that in the next section. -Now, let's create the new endpoint by editing `plugins/todo-list-backend/src/service/router.ts`. This uses the `createPermissionIntegrationRouter` helper to add the APIs needed by the permission framework to your plugin. You'll need to supply: +Now, let's add the new resource type to the permissions system via the +`PermissionsRegistryService`. You'll need to supply: - `getResources`: a function that accepts an array of `resourceRefs` in the same format you expect to be passed to `authorize`, and returns an array of the corresponding resources. - `resourceType`: the same value used in the permission rule above. - `permissions`: the list of permissions that your plugin accepts. - `rules`: an array of all the permission rules you want to support in conditional decisions. -```ts title="plugins/todo-list-backend/src/service/router.ts" +```ts title="plugins/todo-list-backend/src/plugin.ts" // ... +import { + coreServices, + createBackendPlugin, +} from '@backstage/backend-plugin-api'; +import { createRouter } from './service/router'; import { /* highlight-add-next-line */ TODO_LIST_RESOURCE_TYPE, todoListCreatePermission, todoListUpdatePermission, } from '@internal/plugin-todo-list-common'; -/* highlight-remove-next-line */ -import { add, getAll, update } from './todos'; /* highlight-add-start */ -import { add, getAll, getTodo, update } from './todos'; +import { getTodo } from './todos'; import { rules } from './rules'; /* highlight-add-end */ -export async function createRouter( - options: RouterOptions, -): Promise { - const { logger, identity, permissions } = options; +// ... - const permissionIntegrationRouter = createPermissionIntegrationRouter({ - permissions: [todoListCreatePermission, todoListUpdatePermission], - /* highlight-add-start */ - getResources: async resourceRefs => { - return resourceRefs.map(getTodo); - }, - resourceType: TODO_LIST_RESOURCE_TYPE, - rules: Object.values(rules), - /* highlight-add-end */ - }); - - const router = Router(); - router.use(express.json()); - - // ... -} +/* highlight-remove-start */ +permissionsRegistry.addPermissions([ + todoListCreatePermission, + todoListUpdatePermission, +]); +/* highlight-remove-end */ +/* highlight-add-start */ +permissionsRegistry.addResourceType({ + resourceType: TODO_LIST_RESOURCE_TYPE, + permissions: [todoListCreatePermission, todoListUpdatePermission], + rules: Object.values(rules), + getResources: async resourceRefs => { + return Promise.all(resourceRefs.map(getTodo)); + }, +}); +/* highlight-add-end */ ``` ## Provide utilities for policy authors diff --git a/docs/permissions/plugin-authors/04-authorizing-access-to-paginated-data.md b/docs/permissions/plugin-authors/04-authorizing-access-to-paginated-data.md index 883884b6e5..3a0b358c85 100644 --- a/docs/permissions/plugin-authors/04-authorizing-access-to-paginated-data.md +++ b/docs/permissions/plugin-authors/04-authorizing-access-to-paginated-data.md @@ -84,14 +84,41 @@ export const todoListPermissions = [ ## Using conditional policy decisions +As usual, we'll start by updating the permission integration to include the new permission: + +```ts title="plugins/todo-list-backend/src/plugin.ts" +import { + TODO_LIST_RESOURCE_TYPE, + todoListCreatePermission, + todoListUpdatePermission, + /* highlight-add-next-line */ + todoListReadPermission, +} from '@internal/plugin-todo-list-common'; + +// ... + +permissionsRegistry.addResourceType({ + resourceType: TODO_LIST_RESOURCE_TYPE, + /* highlight-remove-next-line */ + permissions: [todoListCreatePermission, todoListUpdatePermission], + /* highlight-add-next-line */ + permissions: [ + todoListCreatePermission, + todoListUpdatePermission, + todoListReadPermission, + ], + rules: Object.values(rules), + getResources: async resourceRefs => { + return Promise.all(resourceRefs.map(getTodo)); + }, +}); +``` + So far we've only used the `PermissionsService.authorize` method, which will evaluate conditional decisions before returning a result. In this step, we want to evaluate conditional decisions within our plugin, so we'll use `PermissionsService.authorizeConditional` instead. ```ts title="plugins/todo-list-backend/src/service/router.ts" -/* highlight-remove-next-line */ -import { createPermissionIntegrationRouter } from '@backstage/plugin-permission-node'; /* highlight-add-start */ import { - createPermissionIntegrationRouter, createConditionTransformer, ConditionTransformer, } from '@backstage/plugin-permission-node'; @@ -101,7 +128,6 @@ import { add, getAll, getTodo, update } from './todos'; /* highlight-add-next-line */ import { add, getAll, getTodo, TodoFilter, update } from './todos'; import { - TODO_LIST_RESOURCE_TYPE, todoListCreatePermission, todoListUpdatePermission, /* highlight-add-next-line */ @@ -110,20 +136,6 @@ import { // ... -const permissionIntegrationRouter = createPermissionIntegrationRouter({ - /* highlight-remove-next-line */ - permissions: [todoListCreatePermission, todoListUpdatePermission], - /* highlight-add-next-line */ - permissions: [todoListCreatePermission, todoListUpdatePermission, todoListReadPermission], - getResources: async resourceRefs => { - return resourceRefs.map(getTodo); - }, - resourceType: TODO_LIST_RESOURCE_TYPE, - rules: Object.values(rules), -}); - -// ... - /* highlight-add-next-line */ const transformConditions: ConditionTransformer = createConditionTransformer(Object.values(rules)); diff --git a/packages/backend-defaults/package.json b/packages/backend-defaults/package.json index 1778c71c47..b9452d05ca 100644 --- a/packages/backend-defaults/package.json +++ b/packages/backend-defaults/package.json @@ -28,6 +28,7 @@ "./httpRouter": "./src/entrypoints/httpRouter/index.ts", "./lifecycle": "./src/entrypoints/lifecycle/index.ts", "./logger": "./src/entrypoints/logger/index.ts", + "./permissionsRegistry": "./src/entrypoints/permissionsRegistry/index.ts", "./permissions": "./src/entrypoints/permissions/index.ts", "./rootConfig": "./src/entrypoints/rootConfig/index.ts", "./rootHealth": "./src/entrypoints/rootHealth/index.ts", @@ -67,6 +68,9 @@ "logger": [ "src/entrypoints/logger/index.ts" ], + "permissionsRegistry": [ + "src/entrypoints/permissionsRegistry/index.ts" + ], "permissions": [ "src/entrypoints/permissions/index.ts" ], @@ -185,14 +189,6 @@ "yn": "^4.0.0", "zod": "^3.22.4" }, - "peerDependencies": { - "@google-cloud/cloud-sql-connector": "^1.4.0" - }, - "peerDependenciesMeta": { - "@google-cloud/cloud-sql-connector": { - "optional": true - } - }, "devDependencies": { "@aws-sdk/util-stream-node": "^3.350.0", "@backstage/backend-plugin-api": "workspace:^", @@ -214,5 +210,13 @@ "supertest": "^7.0.0", "wait-for-expect": "^3.0.2" }, + "peerDependencies": { + "@google-cloud/cloud-sql-connector": "^1.4.0" + }, + "peerDependenciesMeta": { + "@google-cloud/cloud-sql-connector": { + "optional": true + } + }, "configSchema": "config.d.ts" } diff --git a/packages/backend-defaults/report-permissionsRegistry.api.md b/packages/backend-defaults/report-permissionsRegistry.api.md new file mode 100644 index 0000000000..c453ca6a1b --- /dev/null +++ b/packages/backend-defaults/report-permissionsRegistry.api.md @@ -0,0 +1,17 @@ +## API Report File for "@backstage/backend-defaults" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { PermissionsRegistryService } from '@backstage/backend-plugin-api'; +import { ServiceFactory } from '@backstage/backend-plugin-api'; + +// @public +export const permissionsRegistryServiceFactory: ServiceFactory< + PermissionsRegistryService, + 'plugin', + 'singleton' +>; + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/backend-defaults/src/CreateBackend.ts b/packages/backend-defaults/src/CreateBackend.ts index 4b0f497980..de53409658 100644 --- a/packages/backend-defaults/src/CreateBackend.ts +++ b/packages/backend-defaults/src/CreateBackend.ts @@ -24,6 +24,7 @@ import { httpRouterServiceFactory } from '@backstage/backend-defaults/httpRouter import { lifecycleServiceFactory } from '@backstage/backend-defaults/lifecycle'; import { loggerServiceFactory } from '@backstage/backend-defaults/logger'; import { permissionsServiceFactory } from '@backstage/backend-defaults/permissions'; +import { permissionsRegistryServiceFactory } from '@backstage/backend-defaults/permissionsRegistry'; import { rootConfigServiceFactory } from '@backstage/backend-defaults/rootConfig'; import { rootHealthServiceFactory } from '@backstage/backend-defaults/rootHealth'; import { rootHttpRouterServiceFactory } from '@backstage/backend-defaults/rootHttpRouter'; @@ -45,6 +46,7 @@ export const defaultServiceFactories = [ lifecycleServiceFactory, loggerServiceFactory, permissionsServiceFactory, + permissionsRegistryServiceFactory, rootHealthServiceFactory, rootHttpRouterServiceFactory, rootLifecycleServiceFactory, diff --git a/packages/backend-defaults/src/entrypoints/permissionsRegistry/index.ts b/packages/backend-defaults/src/entrypoints/permissionsRegistry/index.ts new file mode 100644 index 0000000000..abe04d7941 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/permissionsRegistry/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2023 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 { permissionsRegistryServiceFactory } from './permissionsRegistryServiceFactory'; diff --git a/packages/backend-defaults/src/entrypoints/permissionsRegistry/permissionsRegistryServiceFactory.ts b/packages/backend-defaults/src/entrypoints/permissionsRegistry/permissionsRegistryServiceFactory.ts new file mode 100644 index 0000000000..17bcb21091 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/permissionsRegistry/permissionsRegistryServiceFactory.ts @@ -0,0 +1,75 @@ +/* + * Copyright 2022 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 { + coreServices, + createServiceFactory, +} from '@backstage/backend-plugin-api'; +import { createPermissionIntegrationRouter } from '@backstage/plugin-permission-node'; + +/** + * Permission system integration for registering resources and permissions. + * + * See {@link @backstage/core-plugin-api#PermissionsRegistryService} + * and {@link https://backstage.io/docs/backend-system/core-services/permission-integrations | the service docs} + * for more information. + * + * @public + */ +export const permissionsRegistryServiceFactory = createServiceFactory({ + service: coreServices.permissionsRegistry, + deps: { + lifecycle: coreServices.lifecycle, + httpRouter: coreServices.httpRouter, + }, + async factory({ httpRouter, lifecycle }) { + const router = createPermissionIntegrationRouter(); + + httpRouter.use(router); + + let started = false; + lifecycle.addStartupHook(() => { + started = true; + }); + + return { + addResourceType(resource) { + if (started) { + throw new Error( + 'Cannot add permission resource types after the plugin has started', + ); + } + router.addResourceType(resource); + }, + addPermissions(permissions) { + if (started) { + throw new Error( + 'Cannot add permissions after the plugin has started', + ); + } + router.addPermissions(permissions); + }, + addPermissionRules(rules) { + if (started) { + throw new Error( + 'Cannot add permission rules after the plugin has started', + ); + } + router.addPermissionRules(rules); + }, + }; + }, +}); diff --git a/packages/backend-plugin-api/package.json b/packages/backend-plugin-api/package.json index 586dc6b3d2..ca051e3bc0 100644 --- a/packages/backend-plugin-api/package.json +++ b/packages/backend-plugin-api/package.json @@ -58,6 +58,7 @@ "@backstage/errors": "workspace:^", "@backstage/plugin-auth-node": "workspace:^", "@backstage/plugin-permission-common": "workspace:^", + "@backstage/plugin-permission-node": "workspace:^", "@backstage/types": "workspace:^", "@types/express": "^4.17.6", "@types/luxon": "^3.0.0", diff --git a/packages/backend-plugin-api/report.api.md b/packages/backend-plugin-api/report.api.md index 8416c6c200..82b62b4de0 100644 --- a/packages/backend-plugin-api/report.api.md +++ b/packages/backend-plugin-api/report.api.md @@ -16,8 +16,10 @@ import { isChildPath } from '@backstage/cli-common'; import { JsonObject } from '@backstage/types'; import { JsonValue } from '@backstage/types'; import { Knex } from 'knex'; +import { Permission } from '@backstage/plugin-permission-common'; import { PermissionAttributes } from '@backstage/plugin-permission-common'; import { PermissionEvaluator } from '@backstage/plugin-permission-common'; +import { PermissionRule } from '@backstage/plugin-permission-node'; import { QueryPermissionRequest } from '@backstage/plugin-permission-common'; import { QueryPermissionResponse } from '@backstage/plugin-permission-common'; import { Readable } from 'stream'; @@ -184,6 +186,11 @@ export namespace coreServices { const lifecycle: ServiceRef; const logger: ServiceRef; const permissions: ServiceRef; + const permissionsRegistry: ServiceRef< + PermissionsRegistryService, + 'plugin', + 'singleton' + >; const pluginMetadata: ServiceRef< PluginMetadataService, 'plugin', @@ -425,6 +432,29 @@ export interface LoggerService { warn(message: string, meta?: Error | JsonObject): void; } +// @public +export interface PermissionsRegistryService { + addPermissionRules(rules: PermissionRule[]): void; + addPermissions(permissions: Permission[]): void; + addResourceType( + options: PermissionsRegistryServiceAddResourceTypeOptions< + TResourceType, + TResource + >, + ): void; +} + +// @public +export type PermissionsRegistryServiceAddResourceTypeOptions< + TResourceType extends string, + TResource, +> = { + resourceType: TResourceType; + permissions?: Array; + rules: PermissionRule>[]; + getResources?(resourceRefs: string[]): Promise>; +}; + // @public export interface PermissionsService extends PermissionEvaluator { authorize( diff --git a/packages/backend-plugin-api/src/services/definitions/PermissionsRegistryService.ts b/packages/backend-plugin-api/src/services/definitions/PermissionsRegistryService.ts new file mode 100644 index 0000000000..46236aa6cc --- /dev/null +++ b/packages/backend-plugin-api/src/services/definitions/PermissionsRegistryService.ts @@ -0,0 +1,131 @@ +/* + * Copyright 2022 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 { Permission } from '@backstage/plugin-permission-common'; +import { PermissionRule } from '@backstage/plugin-permission-node'; + +/** + * Prevent use of type parameter from contributing to type inference. + * + * https://github.com/Microsoft/TypeScript/issues/14829#issuecomment-980401795 + * @ignore + */ +type NoInfer = T extends infer S ? S : never; + +/** + * Options for adding a resource type to the permission system. + * + * @public + */ +export type PermissionsRegistryServiceAddResourceTypeOptions< + TResourceType extends string, + TResource, +> = { + /** + * The identifier for the resource type. + */ + resourceType: TResourceType; + + /** + * Permissions that are available for this resource type. + */ + permissions?: Array; + + /** + * Permission rules that are available for this resource type. + */ + rules: PermissionRule>[]; + + /** + * The function used to load associated resources based in the provided + * references. + * + * @remarks + * + * If this function is not provided the permission system will not be able to + * resolve conditional decisions except when requesting resources directly + * from the plugin. + */ + getResources?(resourceRefs: string[]): Promise>; +}; + +/** + * Permission system integration for registering resources and permissions. + * + * See the {@link https://backstage.io/docs/permissions/overview | permissions documentation} + * and the {@link https://backstage.io/docs/backend-system/core-services/permission-integrations | service documentation} + * for more details. + * + * @public + */ +export interface PermissionsRegistryService { + /** + * Add permissions for this plugin to the permission system. + */ + addPermissions(permissions: Permission[]): void; + + /** + * Adds a set of permission rules to the permission system for a resource type + * that is owned by this plugin. + * + * @remarks + * + * Rules should be created using corresponding `create*PermissionRule` + * functions exported by plugins, who in turn are created with + * `makeCreatePermissionRule`. + * + * Rules can be added either directly by the plugin itself or through a plugin + * module. + */ + addPermissionRules(rules: PermissionRule[]): void; + + /** + * Add a new resource type that is owned by this plugin to the permission + * system. + * + * @remarks + * + * To make this concrete, we can use the Backstage software catalog as an + * example. The catalog has conditional rules around access to specific + * _entities_ in the catalog. The _type_ of resource is captured here as + * `resourceType`, a string identifier (`catalog-entity` in this example) that + * can be provided with permission definitions. This is merely a _type_ to + * verify that conditions in an authorization policy are constructed + * correctly, not a reference to a specific resource. + * + * The `rules` parameter is an array of + * {@link @backstage/plugin-permission-node#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 `getResources` argument should load resources based on a reference + * identifier. For the catalog, this is an + * [entity reference](https://backstage.io/docs/features/software-catalog/references#string-references). + * For other plugins, this can be any serialized format. This is used to add a + * permissions registry API via the HTTP router service. This API will be + * called by the `permission-backend` when authorization conditions relating + * to this plugin need to be evaluated. + */ + addResourceType( + options: PermissionsRegistryServiceAddResourceTypeOptions< + TResourceType, + TResource + >, + ): void; +} diff --git a/packages/backend-plugin-api/src/services/definitions/coreServices.ts b/packages/backend-plugin-api/src/services/definitions/coreServices.ts index f87bff023c..52adaefd53 100644 --- a/packages/backend-plugin-api/src/services/definitions/coreServices.ts +++ b/packages/backend-plugin-api/src/services/definitions/coreServices.ts @@ -174,6 +174,19 @@ export namespace coreServices { import('./PermissionsService').PermissionsService >({ id: 'core.permissions' }); + /** + * Permission system integration for registering resources and permissions. + * + * See {@link PermissionsRegistryService} + * and {@link https://backstage.io/docs/backend-system/core-services/permission-integrations | the service docs} + * for more information. + * + * @public + */ + export const permissionsRegistry = createServiceRef< + import('./PermissionsRegistryService').PermissionsRegistryService + >({ id: 'core.permissionsRegistry' }); + /** * Built-in service for accessing metadata about the current plugin. * diff --git a/packages/backend-plugin-api/src/services/definitions/index.ts b/packages/backend-plugin-api/src/services/definitions/index.ts index 1df00bdead..95cd37a089 100644 --- a/packages/backend-plugin-api/src/services/definitions/index.ts +++ b/packages/backend-plugin-api/src/services/definitions/index.ts @@ -50,6 +50,10 @@ export type { PermissionsService, PermissionsServiceRequestOptions, } from './PermissionsService'; +export type { + PermissionsRegistryService, + PermissionsRegistryServiceAddResourceTypeOptions, +} from './PermissionsRegistryService'; export type { PluginMetadataService } from './PluginMetadataService'; export type { RootHttpRouterService } from './RootHttpRouterService'; export type { RootLifecycleService } from './RootLifecycleService'; diff --git a/packages/backend-test-utils/report.api.md b/packages/backend-test-utils/report.api.md index a79bd9d03c..e6f0667535 100644 --- a/packages/backend-test-utils/report.api.md +++ b/packages/backend-test-utils/report.api.md @@ -33,6 +33,7 @@ import { LifecycleService } from '@backstage/backend-plugin-api'; import { LoggerService } from '@backstage/backend-plugin-api'; import { ParamsDictionary } from 'express-serve-static-core'; import { ParsedQs } from 'qs'; +import { PermissionsRegistryService } from '@backstage/backend-plugin-api'; import { PermissionsService } from '@backstage/backend-plugin-api'; import { RootConfigService } from '@backstage/backend-plugin-api'; import { RootHealthService } from '@backstage/backend-plugin-api'; @@ -264,6 +265,19 @@ export namespace mockServices { ) => ServiceMock; } // (undocumented) + export namespace permissionsRegistry { + const // (undocumented) + factory: () => ServiceFactory< + PermissionsRegistryService, + 'plugin', + 'singleton' + >; + const // (undocumented) + mock: ( + partialImpl?: Partial | undefined, + ) => ServiceMock; + } + // (undocumented) export function rootConfig(options?: rootConfig.Options): RootConfigService; // (undocumented) export namespace rootConfig { diff --git a/packages/backend-test-utils/src/next/services/mockServices.ts b/packages/backend-test-utils/src/next/services/mockServices.ts index b9bca62767..91b53c4acc 100644 --- a/packages/backend-test-utils/src/next/services/mockServices.ts +++ b/packages/backend-test-utils/src/next/services/mockServices.ts @@ -53,6 +53,7 @@ import { MockRootLoggerService } from './MockRootLoggerService'; import { MockUserInfoService } from './MockUserInfoService'; import { mockCredentials } from './mockCredentials'; import { Knex } from 'knex'; +import { permissionsRegistryServiceFactory } from '@backstage/backend-defaults/permissionsRegistry'; /** @internal */ function createLoggerMock() { @@ -468,6 +469,15 @@ export namespace mockServices { })); } + export namespace permissionsRegistry { + export const factory = () => permissionsRegistryServiceFactory; + export const mock = simpleMock(coreServices.permissionsRegistry, () => ({ + addPermissionRules: jest.fn(), + addPermissions: jest.fn(), + addResourceType: jest.fn(), + })); + } + export namespace rootLifecycle { export const factory = () => rootLifecycleServiceFactory; export const mock = simpleMock(coreServices.rootLifecycle, () => ({ diff --git a/packages/backend-test-utils/src/next/wiring/TestBackend.ts b/packages/backend-test-utils/src/next/wiring/TestBackend.ts index 4471f6ed24..0c46c2cd5f 100644 --- a/packages/backend-test-utils/src/next/wiring/TestBackend.ts +++ b/packages/backend-test-utils/src/next/wiring/TestBackend.ts @@ -75,6 +75,7 @@ export const defaultServiceFactories = [ mockServices.lifecycle.factory(), mockServices.logger.factory(), mockServices.permissions.factory(), + mockServices.permissionsRegistry.factory(), mockServices.rootHealth.factory(), mockServices.rootLifecycle.factory(), mockServices.rootLogger.factory(), diff --git a/plugins/catalog-backend-module-unprocessed/src/module.ts b/plugins/catalog-backend-module-unprocessed/src/module.ts index dabbd60d6f..bf1d50a634 100644 --- a/plugins/catalog-backend-module-unprocessed/src/module.ts +++ b/plugins/catalog-backend-module-unprocessed/src/module.ts @@ -19,7 +19,6 @@ import { createBackendModule, } from '@backstage/backend-plugin-api'; import { UnprocessedEntitiesModule } from './UnprocessedEntitiesModule'; -import { catalogPermissionExtensionPoint } from '@backstage/plugin-catalog-node/alpha'; import { unprocessedEntitiesDeletePermission } from '@backstage/plugin-catalog-unprocessed-entities-common'; /** @@ -39,7 +38,7 @@ export const catalogModuleUnprocessedEntities = createBackendModule({ httpAuth: coreServices.httpAuth, discovery: coreServices.discovery, permissions: coreServices.permissions, - catalogPermissions: catalogPermissionExtensionPoint, + permissionsRegistry: coreServices.permissionsRegistry, }, async init({ database, @@ -48,7 +47,7 @@ export const catalogModuleUnprocessedEntities = createBackendModule({ permissions, httpAuth, discovery, - catalogPermissions, + permissionsRegistry, }) { const module = UnprocessedEntitiesModule.create({ database: await database.getClient(), @@ -58,7 +57,9 @@ export const catalogModuleUnprocessedEntities = createBackendModule({ httpAuth, }); - catalogPermissions.addPermissions(unprocessedEntitiesDeletePermission); + permissionsRegistry.addPermissions([ + unprocessedEntitiesDeletePermission, + ]); module.registerRoutes(); diff --git a/plugins/catalog-backend/report.api.md b/plugins/catalog-backend/report.api.md index 7b1e173f82..f4bd1bae1f 100644 --- a/plugins/catalog-backend/report.api.md +++ b/plugins/catalog-backend/report.api.md @@ -52,6 +52,7 @@ import { Permission } from '@backstage/plugin-permission-common'; import { PermissionAuthorizer } from '@backstage/plugin-permission-common'; import { PermissionRule } from '@backstage/plugin-permission-node'; import { PermissionRuleParams } from '@backstage/plugin-permission-common'; +import { PermissionsRegistryService } from '@backstage/backend-plugin-api'; import { PermissionsService } from '@backstage/backend-plugin-api'; import { PlaceholderResolver as PlaceholderResolver_2 } from '@backstage/plugin-catalog-node'; import { PlaceholderResolverParams as PlaceholderResolverParams_2 } from '@backstage/plugin-catalog-node'; @@ -200,6 +201,7 @@ export type CatalogEnvironment = { config: RootConfigService; reader: UrlReaderService; permissions: PermissionsService | PermissionAuthorizer; + permissionsRegistry?: PermissionsRegistryService; scheduler?: SchedulerService; discovery?: DiscoveryService; auth?: AuthService; diff --git a/plugins/catalog-backend/src/service/CatalogBuilder.ts b/plugins/catalog-backend/src/service/CatalogBuilder.ts index 7ce308e796..13347edf7c 100644 --- a/plugins/catalog-backend/src/service/CatalogBuilder.ts +++ b/plugins/catalog-backend/src/service/CatalogBuilder.ts @@ -114,6 +114,7 @@ import { RootConfigService, UrlReaderService, SchedulerService, + PermissionsRegistryService, } from '@backstage/backend-plugin-api'; import { entitiesResponseToObjects } from './response'; @@ -136,6 +137,7 @@ export type CatalogEnvironment = { config: RootConfigService; reader: UrlReaderService; permissions: PermissionsService | PermissionAuthorizer; + permissionsRegistry?: PermissionsRegistryService; scheduler?: SchedulerService; discovery?: DiscoveryService; auth?: AuthService; @@ -478,6 +480,7 @@ export class CatalogBuilder { logger, permissions, scheduler, + permissionsRegistry, discovery = HostDiscovery.fromConfig(config), } = this.env; @@ -554,7 +557,8 @@ export class CatalogBuilder { permissionsService, createConditionTransformer(this.permissionRules), ); - const permissionIntegrationRouter = createPermissionIntegrationRouter({ + + const catalogPermissionResource = { resourceType: RESOURCE_TYPE_CATALOG_ENTITY, getResources: async (resourceRefs: string[]) => { const { entities } = await unauthorizedEntitiesCatalog.entities({ @@ -584,7 +588,18 @@ export class CatalogBuilder { }, permissions: this.permissions, rules: this.permissionRules, - }); + } as const; + + let permissionIntegrationRouter: + | ReturnType + | undefined; + if (permissionsRegistry) { + permissionsRegistry.addResourceType(catalogPermissionResource); + } else { + permissionIntegrationRouter = createPermissionIntegrationRouter( + catalogPermissionResource, + ); + } const locationStore = new DefaultLocationStore(dbClient); const configLocationProvider = new ConfigLocationEntityProvider(config); diff --git a/plugins/catalog-backend/src/service/CatalogPlugin.test.ts b/plugins/catalog-backend/src/service/CatalogPlugin.test.ts new file mode 100644 index 0000000000..9cec22322e --- /dev/null +++ b/plugins/catalog-backend/src/service/CatalogPlugin.test.ts @@ -0,0 +1,71 @@ +/* + * Copyright 2022 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 request from 'supertest'; +import { startTestBackend } from '@backstage/backend-test-utils'; +import { catalogPlugin } from './CatalogPlugin'; +import { + coreServices, + createBackendModule, +} from '@backstage/backend-plugin-api'; +import { createCatalogPermissionRule } from '../permissions'; + +describe('catalogPlugin', () => { + it('should support custom permission rules', async () => { + const { server } = await startTestBackend({ + features: [ + catalogPlugin, + createBackendModule({ + pluginId: 'catalog', + moduleId: 'custom-rules', + register(reg) { + reg.registerInit({ + deps: { + permissionsRegistry: coreServices.permissionsRegistry, + }, + async init({ permissionsRegistry }) { + permissionsRegistry.addPermissionRules([ + createCatalogPermissionRule({ + name: 'test', + resourceType: 'catalog-entity', + description: 'Test permission rule', + apply() { + return true; + }, + toQuery() { + return { key: 'test' }; + }, + }), + ]); + }, + }); + }, + }), + ], + }); + + const res = await request(server).get( + '/api/catalog/.well-known/backstage/permissions/metadata', + ); + + expect(res.status).toBe(200); + expect(res.body.rules).toContainEqual({ + name: 'test', + resourceType: 'catalog-entity', + paramsSchema: expect.any(Object), + description: 'Test permission rule', + }); + }); +}); diff --git a/plugins/catalog-backend/src/service/CatalogPlugin.ts b/plugins/catalog-backend/src/service/CatalogPlugin.ts index 4da0b612cd..ffa9a2f1b9 100644 --- a/plugins/catalog-backend/src/service/CatalogPlugin.ts +++ b/plugins/catalog-backend/src/service/CatalogPlugin.ts @@ -227,6 +227,7 @@ export const catalogPlugin = createBackendPlugin({ config: coreServices.rootConfig, reader: coreServices.urlReader, permissions: coreServices.permissions, + permissionsRegistry: coreServices.permissionsRegistry, database: coreServices.database, httpRouter: coreServices.httpRouter, lifecycle: coreServices.rootLifecycle, @@ -242,6 +243,7 @@ export const catalogPlugin = createBackendPlugin({ reader, database, permissions, + permissionsRegistry, httpRouter, lifecycle, scheduler, @@ -254,6 +256,7 @@ export const catalogPlugin = createBackendPlugin({ config, reader, permissions, + permissionsRegistry, database, scheduler, logger, diff --git a/plugins/catalog-node/report-alpha.api.md b/plugins/catalog-node/report-alpha.api.md index 044bc14de3..92dfaefb7a 100644 --- a/plugins/catalog-node/report-alpha.api.md +++ b/plugins/catalog-node/report-alpha.api.md @@ -51,7 +51,7 @@ export interface CatalogModelExtensionPoint { // @alpha (undocumented) export const catalogModelExtensionPoint: ExtensionPoint; -// @alpha (undocumented) +// @alpha @deprecated (undocumented) export interface CatalogPermissionExtensionPoint { // (undocumented) addPermissionRules( @@ -63,10 +63,10 @@ export interface CatalogPermissionExtensionPoint { addPermissions(...permissions: Array>): void; } -// @alpha (undocumented) +// @alpha @deprecated (undocumented) export const catalogPermissionExtensionPoint: ExtensionPoint; -// @alpha (undocumented) +// @alpha @deprecated (undocumented) export type CatalogPermissionRuleInput< TParams extends PermissionRuleParams = PermissionRuleParams, > = PermissionRule; diff --git a/plugins/catalog-node/src/extensions.ts b/plugins/catalog-node/src/extensions.ts index a971e1588d..b52835b1a9 100644 --- a/plugins/catalog-node/src/extensions.ts +++ b/plugins/catalog-node/src/extensions.ts @@ -138,6 +138,7 @@ export const catalogModelExtensionPoint = /** * @alpha + * @deprecated Use the `coreServices.permissionsRegistry` instead. */ export type CatalogPermissionRuleInput< TParams extends PermissionRuleParams = PermissionRuleParams, @@ -145,6 +146,7 @@ export type CatalogPermissionRuleInput< /** * @alpha + * @deprecated Use the `coreServices.permissionsRegistry` instead. */ export interface CatalogPermissionExtensionPoint { addPermissions(...permissions: Array>): void; @@ -157,6 +159,7 @@ export interface CatalogPermissionExtensionPoint { /** * @alpha + * @deprecated Use the `coreServices.permissionsRegistry` instead. */ export const catalogPermissionExtensionPoint = createExtensionPoint({ diff --git a/plugins/permission-node/report.api.md b/plugins/permission-node/report.api.md index 83730c6050..a55cbbf9c5 100644 --- a/plugins/permission-node/report.api.md +++ b/plugins/permission-node/report.api.md @@ -124,7 +124,7 @@ export function createPermissionIntegrationRouter< TResourceType3 extends string, TResource3, >( - options: + options?: | { permissions: Array; } @@ -140,7 +140,16 @@ export function createPermissionIntegrationRouter< TResourceType3, TResource3 >, -): express.Router; +): express.Router & { + addPermissions(permissions: Permission[]): void; + addPermissionRules(rules: PermissionRule[]): void; + addResourceType( + resource: CreatePermissionIntegrationRouterResourceOptions< + TResourceType, + TResource + >, + ): void; +}; // @public export type CreatePermissionIntegrationRouterResourceOptions< diff --git a/plugins/permission-node/src/integration/createPermissionIntegrationRouter.test.ts b/plugins/permission-node/src/integration/createPermissionIntegrationRouter.test.ts index 0062ef03b6..dbb5647157 100644 --- a/plugins/permission-node/src/integration/createPermissionIntegrationRouter.test.ts +++ b/plugins/permission-node/src/integration/createPermissionIntegrationRouter.test.ts @@ -981,6 +981,99 @@ describe('createPermissionIntegrationRouter', () => { ], }); }); + + it('returns a list of basic permissions together with permissions and rules from multiple resource types with mutation', async () => { + const aPermission = createPermission({ + name: 'a.permission', + attributes: {}, + }); + + const router = createPermissionIntegrationRouter(); + + const responseBefore = await request(express().use(router)).get( + '/.well-known/backstage/permissions/metadata', + ); + + expect(responseBefore.status).toEqual(200); + expect(responseBefore.body).toEqual({ + permissions: [], + rules: [], + }); + + router.addPermissions([aPermission, testPermission]); + + router.addResourceType({ + resourceType: 'test-resource', + permissions: [testPermission], + getResources: defaultMockedGetResources1, + rules: [testRule1], + }); + + router.addPermissionRules([testRule2]); + + // This one is for the resource added below, it should be possible to add rules before the resource typeof + router.addPermissionRules([testRule3]); + + router.addResourceType({ + resourceType: 'test-resource-2', + permissions: [testPermission2], + getResources: defaultMockedGetResources2, + rules: [], + }); + + const responseAfter = await request(express().use(router)).get( + '/.well-known/backstage/permissions/metadata', + ); + + expect(responseAfter.status).toEqual(200); + expect(responseAfter.body).toEqual({ + permissions: [aPermission, testPermission, testPermission2], + rules: [ + { + name: testRule1.name, + description: testRule1.description, + resourceType: testRule1.resourceType, + paramsSchema: { + $schema: 'http://json-schema.org/draft-07/schema#', + additionalProperties: false, + properties: { + foo: { + type: 'string', + }, + bar: { + description: 'bar', + type: 'number', + }, + }, + required: ['foo', 'bar'], + type: 'object', + }, + }, + { + name: testRule2.name, + description: testRule2.description, + resourceType: testRule2.resourceType, + paramsSchema: { + $schema: 'http://json-schema.org/draft-07/schema#', + additionalProperties: false, + properties: {}, + type: 'object', + }, + }, + { + name: testRule3.name, + description: testRule3.description, + resourceType: testRule3.resourceType, + paramsSchema: { + $schema: 'http://json-schema.org/draft-07/schema#', + additionalProperties: false, + properties: {}, + type: 'object', + }, + }, + ], + }); + }); }); describe('createConditionAuthorizer', () => { diff --git a/plugins/permission-node/src/integration/createPermissionIntegrationRouter.ts b/plugins/permission-node/src/integration/createPermissionIntegrationRouter.ts index de1e32169a..dbd7e45c74 100644 --- a/plugins/permission-node/src/integration/createPermissionIntegrationRouter.ts +++ b/plugins/permission-node/src/integration/createPermissionIntegrationRouter.ts @@ -250,6 +250,110 @@ export type PermissionIntegrationRouterOptions< >; }; +class PermissionIntegrationMetadataStore { + readonly #rulesByTypeByName = new Map< + string, + Map> + >(); + readonly #permissionsByName = new Map(); + readonly #resourcesByType = new Map< + string, + CreatePermissionIntegrationRouterResourceOptions + >(); + readonly #serializedRules = new Array(); + + getSerializedMetadata(): MetadataResponse { + return { + permissions: Array.from(this.#permissionsByName.values()), + rules: this.#serializedRules, + }; + } + + hasResourceType(type: string): boolean { + return this.#resourcesByType.has(type); + } + + async getResources( + resourceType: string, + refs: string[], + ): Promise> { + const resource = this.#resourcesByType.get(resourceType); + if (!resource?.getResources) { + throw new NotImplementedError( + `This plugin does not expose any permission rule or can't evaluate the conditions request for ${resourceType}`, + ); + } + + const uniqueRefs = Array.from(new Set(refs)); + const resources = await resource.getResources(uniqueRefs); + return Object.fromEntries( + uniqueRefs.map((ref, index) => [ref, resources[index]]), + ); + } + + getRuleMapper(resourceType: string) { + return (name: string): PermissionRule => { + const rule = this.#rulesByTypeByName.get(resourceType)?.get(name); + if (!rule) { + throw new Error( + `Permission rule '${name}' does not exist for resource type '${resourceType}'`, + ); + } + return rule; + }; + } + + addPermissions(permissions: Permission[]) { + for (const permission of permissions) { + // Permission naming conflicts are silently ignored + this.#permissionsByName.set(permission.name, permission); + } + } + + addPermissionRules(rules: PermissionRule[]) { + for (const rule of rules) { + const rulesByName = + this.#rulesByTypeByName.get(rule.resourceType) ?? new Map(); + this.#rulesByTypeByName.set(rule.resourceType, rulesByName); + + if (rulesByName.has(rule.name)) { + throw new Error( + `Refused to add permission rule for type '${rule.resourceType}' with name '${rule.name}' because it already exists`, + ); + } + rulesByName.set(rule.name, rule); + + this.#serializedRules.push({ + name: rule.name, + description: rule.description, + resourceType: rule.resourceType, + paramsSchema: zodToJsonSchema(rule.paramsSchema ?? z.object({})), + }); + } + } + + addResourceType( + resource: CreatePermissionIntegrationRouterResourceOptions, + ) { + const { resourceType } = resource; + + if (this.#resourcesByType.has(resourceType)) { + throw new Error( + `Refused to add permission resource with type '${resourceType}' because it already exists`, + ); + } + this.#resourcesByType.set(resourceType, resource); + + if (resource.rules) { + this.addPermissionRules(resource.rules); + } + + if (resource.permissions) { + this.addPermissions(resource.permissions); + } + } +} + /** * Create an express Router which provides an authorization route to allow * integration between the permission backend and other Backstage backend @@ -299,7 +403,7 @@ export function createPermissionIntegrationRouter< TResourceType3 extends string, TResource3, >( - options: + options?: | { permissions: Array } | CreatePermissionIntegrationRouterResourceOptions< TResourceType1, @@ -313,167 +417,81 @@ export function createPermissionIntegrationRouter< TResourceType3, TResource3 >, -): express.Router { - const optionsWithResources = options as PermissionIntegrationRouterOptions; - const allOptions = [ - optionsWithResources.resources ? optionsWithResources.resources : options, - ].flat(); - const allRules = allOptions.flatMap( - option => - ( - option as CreatePermissionIntegrationRouterResourceOptions< - TResourceType1, - TResource1 - > - ).rules || [], - ); +): express.Router & { + addPermissions(permissions: Permission[]): void; + addPermissionRules(rules: PermissionRule[]): void; + addResourceType( + resource: CreatePermissionIntegrationRouterResourceOptions< + TResourceType, + TResource + >, + ): void; +} { + const store = new PermissionIntegrationMetadataStore(); - const allPermissions = Array.from( - new Map( - [ - ...((options as { permissions: Permission[] }).permissions || []), - ...(optionsWithResources.resources?.flatMap(o => o.permissions || []) || - []), - ].map(i => [i.name, i]), - ).values(), - ); + if (options) { + if ('resources' in options) { + // Not technically allowed by types, but it's historically been covered by tests + if ('permissions' in options) { + store.addPermissions(options.permissions as Permission[]); + } - const allResourceTypes = allOptions.reduce((acc, option) => { - if ( - isCreatePermissionIntegrationRouterResourceOptions( - option as - | { permissions: Array } - | CreatePermissionIntegrationRouterResourceOptions< - TResourceType1, - TResource1 - >, - ) - ) { - acc.push( - ( - option as CreatePermissionIntegrationRouterResourceOptions< - TResourceType1, - TResource1 - > - ).resourceType, - ); + for (const resource of options.resources) { + store.addResourceType(resource); + } + } else if ('resourceType' in options) { + store.addResourceType(options); + } else { + store.addPermissions(options.permissions); } - return acc; - }, [] as string[]); + } const router = Router(); - router.use(express.json()); + + router.use('/.well-known/backstage/permissions/', express.json()); router.get('/.well-known/backstage/permissions/metadata', (_, res) => { - const serializedRules: MetadataResponseSerializedRule[] = allRules.map( - rule => ({ - name: rule.name, - description: rule.description, - resourceType: rule.resourceType, - paramsSchema: zodToJsonSchema(rule.paramsSchema ?? z.object({})), - }), - ); - - const responseJson: MetadataResponse = { - permissions: allPermissions, - rules: serializedRules, - }; - - return res.json(responseJson); + res.json(store.getSerializedMetadata()); }); router.post( '/.well-known/backstage/permissions/apply-conditions', async (req, res: Response) => { - const ruleMapByResourceType: Record< - string, - ReturnType - > = {}; - const getResourcesByResourceType: Record< - string, - CreatePermissionIntegrationRouterResourceOptions< - TResourceType1, - TResource1 - >['getResources'] - > = {}; - - for (let option of allOptions) { - option = option as - | { permissions: Array } - | CreatePermissionIntegrationRouterResourceOptions< - TResourceType1, - TResource1 - >; - if (isCreatePermissionIntegrationRouterResourceOptions(option)) { - ruleMapByResourceType[option.resourceType] = createGetRule( - option.rules, - ); - - getResourcesByResourceType[option.resourceType] = option.getResources; - } - } - - const assertValidResourceTypes = ( - requests: ApplyConditionsRequestEntry[], - ) => { - const invalidResourceTypes = requests - .filter(request => !allResourceTypes.includes(request.resourceType)) - .map(request => request.resourceType); - - if (invalidResourceTypes.length) { - throw new InputError( - `Unexpected resource types: ${invalidResourceTypes.join(', ')}.`, - ); - } - }; - const parseResult = applyConditionsRequestSchema.safeParse(req.body); - if (!parseResult.success) { throw new InputError(parseResult.error.toString()); } - const body = parseResult.data; + const { items: requests } = parseResult.data; - assertValidResourceTypes(body.items); - - const resourceRefsByResourceType = body.items.reduce< - Record> - >((acc, item) => { - if (!acc[item.resourceType]) { - acc[item.resourceType] = new Set(); - } - acc[item.resourceType].add(item.resourceRef); - return acc; - }, {}); - - const resourcesByResourceType: Record> = {}; - for (const resourceType of Object.keys(resourceRefsByResourceType)) { - const getResources = getResourcesByResourceType[resourceType]; - if (!getResources) { - throw new NotImplementedError( - `This plugin does not expose any permission rule or can't evaluate the conditions request for ${resourceType}`, - ); - } - const resourceRefs = Array.from( - resourceRefsByResourceType[resourceType], + const invalidResourceTypes = requests.filter( + i => !store.hasResourceType(i.resourceType), + ); + if (invalidResourceTypes.length) { + throw new InputError( + `Unexpected resource types: ${invalidResourceTypes + .map(i => i.resourceType) + .join(', ')}.`, ); - const resources = await getResources(resourceRefs); - resourceRefs.forEach((resourceRef, index) => { - if (!resourcesByResourceType[resourceType]) { - resourcesByResourceType[resourceType] = {}; - } - resourcesByResourceType[resourceType][resourceRef] = resources[index]; - }); } - return res.json({ - items: body.items.map(request => ({ + const resourcesByType: Record> = {}; + for (const requestedType of new Set(requests.map(i => i.resourceType))) { + resourcesByType[requestedType] = await store.getResources( + requestedType, + requests + .filter(r => r.resourceType === requestedType) + .map(i => i.resourceRef), + ); + } + + res.json({ + items: requests.map(request => ({ id: request.id, result: applyConditions( request.conditions, - resourcesByResourceType[request.resourceType][request.resourceRef], - ruleMapByResourceType[request.resourceType], + resourcesByType[request.resourceType][request.resourceRef], + store.getRuleMapper(request.resourceType), ) ? AuthorizeResult.ALLOW : AuthorizeResult.DENY, @@ -482,29 +500,20 @@ export function createPermissionIntegrationRouter< }, ); - return router; -} - -function isCreatePermissionIntegrationRouterResourceOptions< - TResourceType extends string, - TResource, ->( - options: - | { permissions: Array } - | CreatePermissionIntegrationRouterResourceOptions< + return Object.assign(router, { + addPermissions(permissions: Permission[]) { + store.addPermissions(permissions); + }, + addPermissionRules(rules: PermissionRule[]) { + store.addPermissionRules(rules); + }, + addResourceType( + resource: CreatePermissionIntegrationRouterResourceOptions< TResourceType, TResource >, -): options is CreatePermissionIntegrationRouterResourceOptions< - TResourceType, - TResource -> { - return ( - ( - options as CreatePermissionIntegrationRouterResourceOptions< - TResourceType, - TResource - > - ).resourceType !== undefined - ); + ) { + store.addResourceType(resource); + }, + }); } diff --git a/yarn.lock b/yarn.lock index 4087ec2562..1be953f509 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3734,6 +3734,7 @@ __metadata: "@backstage/errors": "workspace:^" "@backstage/plugin-auth-node": "workspace:^" "@backstage/plugin-permission-common": "workspace:^" + "@backstage/plugin-permission-node": "workspace:^" "@backstage/types": "workspace:^" "@types/express": ^4.17.6 "@types/luxon": ^3.0.0