Merge pull request #28400 from backstage/rugvip/permission-integrations
permissions: add new PermissionIntegrationsService
This commit is contained in:
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-test-utils': minor
|
||||
---
|
||||
|
||||
Added mocks for the new `PermissionsRegistryService`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-backend-module-unprocessed': patch
|
||||
---
|
||||
|
||||
Use new `PermissionsRegistryService` instead of the deprecated `catalogPermissionExtensionPoint`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-defaults': patch
|
||||
---
|
||||
|
||||
Added default implementation for the new `PermissionsRegistryService`.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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]);
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<express.Router> {
|
||||
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
|
||||
|
||||
@@ -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<TodoFilter> = createConditionTransformer(Object.values(rules));
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
```
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
+75
@@ -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);
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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<LifecycleService, 'plugin', 'singleton'>;
|
||||
const logger: ServiceRef<LoggerService, 'plugin', 'singleton'>;
|
||||
const permissions: ServiceRef<PermissionsService, 'plugin', 'singleton'>;
|
||||
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<any, any, string>[]): void;
|
||||
addPermissions(permissions: Permission[]): void;
|
||||
addResourceType<const TResourceType extends string, TResource>(
|
||||
options: PermissionsRegistryServiceAddResourceTypeOptions<
|
||||
TResourceType,
|
||||
TResource
|
||||
>,
|
||||
): void;
|
||||
}
|
||||
|
||||
// @public
|
||||
export type PermissionsRegistryServiceAddResourceTypeOptions<
|
||||
TResourceType extends string,
|
||||
TResource,
|
||||
> = {
|
||||
resourceType: TResourceType;
|
||||
permissions?: Array<Permission>;
|
||||
rules: PermissionRule<TResource, any, NoInfer_2<TResourceType>>[];
|
||||
getResources?(resourceRefs: string[]): Promise<Array<TResource | undefined>>;
|
||||
};
|
||||
|
||||
// @public
|
||||
export interface PermissionsService extends PermissionEvaluator {
|
||||
authorize(
|
||||
|
||||
@@ -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> = 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>;
|
||||
|
||||
/**
|
||||
* Permission rules that are available for this resource type.
|
||||
*/
|
||||
rules: PermissionRule<TResource, any, NoInfer<TResourceType>>[];
|
||||
|
||||
/**
|
||||
* 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<Array<TResource | undefined>>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<any, any, string>[]): 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<const TResourceType extends string, TResource>(
|
||||
options: PermissionsRegistryServiceAddResourceTypeOptions<
|
||||
TResourceType,
|
||||
TResource
|
||||
>,
|
||||
): void;
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<PermissionsService>;
|
||||
}
|
||||
// (undocumented)
|
||||
export namespace permissionsRegistry {
|
||||
const // (undocumented)
|
||||
factory: () => ServiceFactory<
|
||||
PermissionsRegistryService,
|
||||
'plugin',
|
||||
'singleton'
|
||||
>;
|
||||
const // (undocumented)
|
||||
mock: (
|
||||
partialImpl?: Partial<PermissionsRegistryService> | undefined,
|
||||
) => ServiceMock<PermissionsRegistryService>;
|
||||
}
|
||||
// (undocumented)
|
||||
export function rootConfig(options?: rootConfig.Options): RootConfigService;
|
||||
// (undocumented)
|
||||
export namespace rootConfig {
|
||||
|
||||
@@ -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, () => ({
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<typeof createPermissionIntegrationRouter>
|
||||
| undefined;
|
||||
if (permissionsRegistry) {
|
||||
permissionsRegistry.addResourceType(catalogPermissionResource);
|
||||
} else {
|
||||
permissionIntegrationRouter = createPermissionIntegrationRouter(
|
||||
catalogPermissionResource,
|
||||
);
|
||||
}
|
||||
|
||||
const locationStore = new DefaultLocationStore(dbClient);
|
||||
const configLocationProvider = new ConfigLocationEntityProvider(config);
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -51,7 +51,7 @@ export interface CatalogModelExtensionPoint {
|
||||
// @alpha (undocumented)
|
||||
export const catalogModelExtensionPoint: ExtensionPoint<CatalogModelExtensionPoint>;
|
||||
|
||||
// @alpha (undocumented)
|
||||
// @alpha @deprecated (undocumented)
|
||||
export interface CatalogPermissionExtensionPoint {
|
||||
// (undocumented)
|
||||
addPermissionRules(
|
||||
@@ -63,10 +63,10 @@ export interface CatalogPermissionExtensionPoint {
|
||||
addPermissions(...permissions: Array<Permission | Array<Permission>>): void;
|
||||
}
|
||||
|
||||
// @alpha (undocumented)
|
||||
// @alpha @deprecated (undocumented)
|
||||
export const catalogPermissionExtensionPoint: ExtensionPoint<CatalogPermissionExtensionPoint>;
|
||||
|
||||
// @alpha (undocumented)
|
||||
// @alpha @deprecated (undocumented)
|
||||
export type CatalogPermissionRuleInput<
|
||||
TParams extends PermissionRuleParams = PermissionRuleParams,
|
||||
> = PermissionRule<Entity, EntitiesSearchFilter, 'catalog-entity', TParams>;
|
||||
|
||||
@@ -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<Permission | Array<Permission>>): void;
|
||||
@@ -157,6 +159,7 @@ export interface CatalogPermissionExtensionPoint {
|
||||
|
||||
/**
|
||||
* @alpha
|
||||
* @deprecated Use the `coreServices.permissionsRegistry` instead.
|
||||
*/
|
||||
export const catalogPermissionExtensionPoint =
|
||||
createExtensionPoint<CatalogPermissionExtensionPoint>({
|
||||
|
||||
@@ -124,7 +124,7 @@ export function createPermissionIntegrationRouter<
|
||||
TResourceType3 extends string,
|
||||
TResource3,
|
||||
>(
|
||||
options:
|
||||
options?:
|
||||
| {
|
||||
permissions: Array<Permission>;
|
||||
}
|
||||
@@ -140,7 +140,16 @@ export function createPermissionIntegrationRouter<
|
||||
TResourceType3,
|
||||
TResource3
|
||||
>,
|
||||
): express.Router;
|
||||
): express.Router & {
|
||||
addPermissions(permissions: Permission[]): void;
|
||||
addPermissionRules(rules: PermissionRule<unknown, unknown, string>[]): void;
|
||||
addResourceType<const TResourceType extends string, TResource>(
|
||||
resource: CreatePermissionIntegrationRouterResourceOptions<
|
||||
TResourceType,
|
||||
TResource
|
||||
>,
|
||||
): void;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type CreatePermissionIntegrationRouterResourceOptions<
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -250,6 +250,110 @@ export type PermissionIntegrationRouterOptions<
|
||||
>;
|
||||
};
|
||||
|
||||
class PermissionIntegrationMetadataStore {
|
||||
readonly #rulesByTypeByName = new Map<
|
||||
string,
|
||||
Map<string, PermissionRule<unknown, unknown, string>>
|
||||
>();
|
||||
readonly #permissionsByName = new Map<string, Permission>();
|
||||
readonly #resourcesByType = new Map<
|
||||
string,
|
||||
CreatePermissionIntegrationRouterResourceOptions<string, unknown>
|
||||
>();
|
||||
readonly #serializedRules = new Array<MetadataResponseSerializedRule>();
|
||||
|
||||
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<Record<string, unknown>> {
|
||||
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<unknown, unknown, string> => {
|
||||
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<unknown, unknown, string>[]) {
|
||||
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<string, unknown>,
|
||||
) {
|
||||
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<Permission> }
|
||||
| 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<unknown, unknown, string>[]): void;
|
||||
addResourceType<const TResourceType extends string, TResource>(
|
||||
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<Permission> }
|
||||
| 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<ApplyConditionsResponse | string>) => {
|
||||
const ruleMapByResourceType: Record<
|
||||
string,
|
||||
ReturnType<typeof createGetRule>
|
||||
> = {};
|
||||
const getResourcesByResourceType: Record<
|
||||
string,
|
||||
CreatePermissionIntegrationRouterResourceOptions<
|
||||
TResourceType1,
|
||||
TResource1
|
||||
>['getResources']
|
||||
> = {};
|
||||
|
||||
for (let option of allOptions) {
|
||||
option = option as
|
||||
| { permissions: Array<Permission> }
|
||||
| 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<string, Set<string>>
|
||||
>((acc, item) => {
|
||||
if (!acc[item.resourceType]) {
|
||||
acc[item.resourceType] = new Set();
|
||||
}
|
||||
acc[item.resourceType].add(item.resourceRef);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const resourcesByResourceType: Record<string, Record<string, any>> = {};
|
||||
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<string, Record<string, any>> = {};
|
||||
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<Permission> }
|
||||
| CreatePermissionIntegrationRouterResourceOptions<
|
||||
return Object.assign(router, {
|
||||
addPermissions(permissions: Permission[]) {
|
||||
store.addPermissions(permissions);
|
||||
},
|
||||
addPermissionRules(rules: PermissionRule<unknown, unknown, string>[]) {
|
||||
store.addPermissionRules(rules);
|
||||
},
|
||||
addResourceType<const TResourceType extends string, TResource>(
|
||||
resource: CreatePermissionIntegrationRouterResourceOptions<
|
||||
TResourceType,
|
||||
TResource
|
||||
>,
|
||||
): options is CreatePermissionIntegrationRouterResourceOptions<
|
||||
TResourceType,
|
||||
TResource
|
||||
> {
|
||||
return (
|
||||
(
|
||||
options as CreatePermissionIntegrationRouterResourceOptions<
|
||||
TResourceType,
|
||||
TResource
|
||||
>
|
||||
).resourceType !== undefined
|
||||
);
|
||||
) {
|
||||
store.addResourceType(resource);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user