Permission tutorial: update to recent changes

Signed-off-by: Vincenzo Scamporlino <me@vinzscam.dev>
This commit is contained in:
Vincenzo Scamporlino
2022-04-08 22:14:55 +02:00
committed by Joon Park
parent 74d59fc59b
commit efa4afc661
3 changed files with 124 additions and 97 deletions
+44 -32
View File
@@ -38,17 +38,22 @@ Like many other parts of Backstage, the permissions framework relies on informat
The permissions framework uses a new `permission-backend` plugin to accept authorization requests from other plugins across your Backstage instance. The Backstage backend does not include this permission backend by default, so you will need to add it:
1. Add `@backstage/plugin-permission-backend` as a dependency of your Backstage backend.
1. Add `@backstage/plugin-permission-backend` as a dependency of your Backstage backend:
```bash
$ yarn workspace backend add @backstage/plugin-permission-backend
```
2. Add the following to a new file, `packages/backend/src/plugins/permission.ts`. This adds the permission-backend router, and configures it with a policy which allows everything.
```typescript
import { IdentityClient } from '@backstage/plugin-auth-node';
import { createRouter } from '@backstage/plugin-permission-backend';
import { AuthorizeResult } from '@backstage/plugin-permission-common';
import {
PermissionPolicy,
AuthorizeResult,
PolicyDecision,
} from '@backstage/plugin-permission-node';
} from '@backstage/plugin-permission-common';
import { PermissionPolicy } from '@backstage/plugin-permission-node';
import { Router } from 'express';
import { PluginEnvironment } from '../types';
@@ -61,21 +66,41 @@ class TestPermissionPolicy implements PermissionPolicy {
export default async function createPlugin(
env: PluginEnvironment,
): Promise<Router> {
const { config, logger, discovery } = env;
return await createRouter({
config,
logger,
discovery,
config: env.config,
logger: env.logger,
discovery: env.discovery,
policy: new TestPermissionPolicy(),
identity: IdentityClient.create({
discovery,
issuer: await discovery.getExternalBaseUrl('auth'),
discovery: env.discovery,
issuer: await env.discovery.getExternalBaseUrl('auth'),
}),
});
}
```
3. Wire up the permission policy in `packages/backend/src/index.ts`. [The index in the example backend](https://github.com/backstage/backstage/blob/master/packages/backend/src/index.ts) shows how to do this. Youll need to import the module from the previous step, create a plugin environment, and add the router to the express app.
3. Wire up the permission policy in `packages/backend/src/index.ts`. [The index in the example backend](https://github.com/backstage/backstage/blob/master/packages/backend/src/index.ts) shows how to do this. Youll need to import the module from the previous step, create a plugin environment, and add the router to the express app:
```diff
import proxy from './plugins/proxy';
import techdocs from './plugins/techdocs';
import search from './plugins/search';
+ import permission from './plugins/permission';
...
const techdocsEnv = useHotMemoize(module, () => createEnv('techdocs'));
const searchEnv = useHotMemoize(module, () => createEnv('search'));
const appEnv = useHotMemoize(module, () => createEnv('app'));
+ const permissionEnv = useHotMemoize(module, () => createEnv('permission'));
...
apiRouter.use('/techdocs', await techdocs(techdocsEnv));
apiRouter.use('/proxy', await proxy(proxyEnv));
apiRouter.use('/search', await search(searchEnv));
+ apiRouter.use('/permission', await permission(permissionEnv));
```
### 2. Enable and test the permissions system
@@ -93,18 +118,21 @@ permission:
```diff
import { IdentityClient } from '@backstage/plugin-auth-node';
import { createRouter } from '@backstage/plugin-permission-backend';
import { AuthorizeResult } from '@backstage/plugin-permission-common';
import {
PermissionPolicy,
+ PolicyAuthorizeQuery,
AuthorizeResult,
PolicyDecision,
} from '@backstage/plugin-permission-node';
} from '@backstage/plugin-permission-common';
- import { PermissionPolicy } from '@backstage/plugin-permission-node';
+ import {
+ PermissionPolicy,
+ PolicyQuery,
+ } from '@backstage/plugin-permission-node';
import { Router } from 'express';
import { PluginEnvironment } from '../types';
class TestPermissionPolicy implements PermissionPolicy {
- async handle(): Promise<PolicyDecision> {
+ async handle(request: PolicyAuthorizeQuery): Promise<PolicyDecision> {
+ async handle(request: PolicyQuery): Promise<PolicyDecision> {
+ if (request.permission.name === 'catalog.entity.delete') {
+ return {
+ result: AuthorizeResult.DENY,
@@ -114,22 +142,6 @@ permission:
return { result: AuthorizeResult.ALLOW };
}
}
export default async function createPlugin(
env: PluginEnvironment,
): Promise<Router> {
const { config, logger, discovery } = env;
return await createRouter({
config,
logger,
discovery,
policy: new TestPermissionPolicy(),
identity: IdentityClient.create({
discovery,
issuer: await discovery.getExternalBaseUrl('auth'),
}),
});
}
```
3. Now that youve made this change, you should find that the unregister entity menu option on the catalog entity page is disabled.
@@ -16,7 +16,7 @@ Install the following module:
```
$ yarn workspace @internal/plugin-todo-list-backend \
add @backstage/plugin-permission-common
add @backstage/plugin-permission-common
```
## Creating a new permission
@@ -24,14 +24,12 @@ $ yarn workspace @internal/plugin-todo-list-backend \
Let's create a new file `plugins/todo-list-backend/src/service/permissions.ts` with the following content:
```typescript
import { Permission } from '@backstage/plugin-permission-common';
import { createPermission } from '@backstage/plugin-permission-common';
export const todosListCreate: Permission = {
export const todosListCreate = createPermission({
name: 'todos.list.create',
attributes: {
action: 'create',
},
};
attributes: { action: 'create' },
});
```
We recommend exporting all permissions from your plugin, so that Backstage integrators can import them when writing policies.
@@ -45,7 +43,7 @@ Edit `plugins/todo-list-backend/src/service/router.ts`:
- import { InputError } from '@backstage/errors';
+ import { InputError, NotAllowedError } from '@backstage/errors';
+ import { PermissionAuthorizer, AuthorizeResult } from '@backstage/plugin-permission-common';
+ import { PermissionEvaluator, AuthorizeResult } from '@backstage/plugin-permission-common';
+ import { todosListCreate } from './permissions';
...
@@ -53,7 +51,7 @@ Edit `plugins/todo-list-backend/src/service/router.ts`:
export interface RouterOptions {
logger: Logger;
identity: IdentityClient;
+ permissions: PermissionAuthorizer;
+ permissions: PermissionEvaluator;
}
export async function createRouter(
@@ -117,24 +115,28 @@ That's it! Now your plugin is fully configured. Let's try to test the logic by d
## Test the authorized create endpoint
Before running this step, please make sure you followed the steps described in [Getting started](../getting-started) section.
In order to test the logic above, the integrators of your backstage instance need to change their permission policy to return `DENY` for our newly-created permission:
```diff
// packages/backend/src/plugins/permission.ts
- import { IdentityClient } from '@backstage/plugin-auth-node';
+ import { BackstageIdentityResponse, IdentityClient } from '@backstage/plugin-auth-node';
+ import {
+ BackstageIdentityResponse,
+ IdentityClient
+ } from '@backstage/plugin-auth-node';
import {
PermissionPolicy,
+ PolicyAuthorizeQuery,
PolicyDecision,
+ PolicyQuery,
} from '@backstage/plugin-permission-node';
- class AllowAllPermissionPolicy implements PermissionPolicy
+ class MyPermissionPolicy implements PermissionPolicy {
- class TestPermissionPolicy implements PermissionPolicy
+ class TestPermissionPolicy implements PermissionPolicy {
- async handle(): Promise<PolicyDecision> {
+ async handle(
+ request: PolicyAuthorizeQuery,
+ request: PolicyQuery,
+ user?: BackstageIdentityResponse,
+ ): Promise<PolicyDecision> {
+ if (request.permission.name === 'todos.list.create') {
@@ -147,16 +149,6 @@ In order to test the logic above, the integrators of your backstage instance nee
result: AuthorizeResult.ALLOW,
};
}
export default async function createPlugin({
discovery,
logger,
}: PluginEnvironment) {
- const policy = new AllowAllPermissionPolicy();
+ const policy = new MyPermissionPolicy();
...
}
```
Now the frontend should show an error whenever you try to create a new Todo item.
+63 -40
View File
@@ -12,7 +12,7 @@ That policy looked like this:
// packages/backend/src/plugins/permission.ts
class TestPermissionPolicy implements PermissionPolicy {
async handle(request: PolicyAuthorizeQuery): Promise<PolicyDecision> {
async handle(request: PolicyQuery): Promise<PolicyDecision> {
if (request.permission.name === 'catalog.entity.delete') {
return {
result: AuthorizeResult.DENY,
@@ -26,9 +26,9 @@ class TestPermissionPolicy implements PermissionPolicy {
## What's in a policy?
Let's break this down a bit further. The request object of type [PolicyAuthorizeQuery](https://backstage.io/docs/reference/plugin-permission-node.policyauthorizequery) is a simple wrapper around [the Permission object](https://backstage.io/docs/reference/plugin-permission-common.permission). This permission object encapsulates information about the action that the user is attemping to perform (See [the Concepts page](./concepts.md) for more details).
Let's break this down a bit further. The request object of type [PolicyQuery](https://backstage.io/docs/reference/plugin-permission-node.policyquery) is a simple wrapper around [the Permission object](https://backstage.io/docs/reference/plugin-permission-common.permission). This permission object encapsulates information about the action that the user is attemping to perform (See [the Concepts page](./concepts.md) for more details).
In the policy above, we are checking to see if the provided action is a catalog entity delete action, which is the permission that the catalog plugin authors have created to represent the action of unregistering a catalog entity. If this is the case, we return a [Definitive Policy Decision](https://backstage.io/docs/reference/plugin-permission-node.definitivepolicydecision) of DENY. In all other cases, we return ALLOW (resulting in an allow-by-default behavior).
In the policy above, we are checking to see if the provided action is a catalog entity delete action, which is the permission that the catalog plugin authors have created to represent the action of unregistering a catalog entity. If this is the case, we return a [Definitive Policy Decision](https://backstage.io/docs/reference/plugin-permission-common.definitivepolicydecision) of DENY. In all other cases, we return ALLOW (resulting in an allow-by-default behavior).
As we confirmed in the previous section, we know that this now prevents us from unregistering catalog components. Hooray! But you may notice that this prevents _anyone_ from unregistering a component, which is not a very realistic policy. Let's improve this policy by disabling the unregister action _unless you are the owner of this component_.
@@ -38,36 +38,50 @@ Let's change the policy to the following:
```diff
- import { IdentityClient } from '@backstage/plugin-auth-node';
+ import { BackstageIdentityResponse, IdentityClient } from '@backstage/plugin-auth-node';
+ import { catalogConditions, createCatalogPolicyDecision } from '@backstage/plugin-catalog-backend';
+ import {
+ BackstageIdentityResponse,
+ IdentityClient
+ } from '@backstage/plugin-auth-node';
import {
AuthorizeResult,
PolicyDecision,
+ isResourcePermission,
} from '@backstage/plugin-permission-common';
+ import {
+ catalogConditions,
+ createCatalogConditionalDecision,
+ } from '@backstage/plugin-catalog-backend';
...
class TestPermissionPolicy implements PermissionPolicy {
- async handle(request: PolicyAuthorizeQuery): Promise<PolicyDecision> {
- async handle(request: PolicyQuery): Promise<PolicyDecision> {
+ async handle(
+ request: PolicyAuthorizeQuery,
+ request: PolicyQuery,
+ user?: BackstageIdentityResponse,
+ ): Promise<PolicyDecision> {
if (request.permission.name === 'catalog.entity.delete') {
- return {
- result: AuthorizeResult.DENY,
- };
+ return createCatalogPolicyDecision(
+ catalogConditions.isEntityOwner(
+ user?.identity.ownershipEntityRefs ?? [],
+ ),
+ );
}
+ if (isResourcePermission(request.permission, 'catalog-entity')) {
if (request.permission.name === 'catalog.entity.delete') {
- return {
- result: AuthorizeResult.DENY,
- };
+ return createCatalogConditionalDecision(
+ request.permission,
+ catalogConditions.isEntityOwner(
+ user?.identity.ownershipEntityRefs ?? [],
+ ),
+ );
}
+ }
return { result: AuthorizeResult.ALLOW };
}
}
```
Let's walk through the new code that we just added. Inside of the if statement, instead of returning a Definitive Policy Decision of DENY, we now return a [Conditional Policy Decision](https://backstage.io/docs/reference/plugin-permission-node.conditionalpolicydecision) (See the [Concepts page](./concepts.md) for more details). This is a way for policies to defer the evaulation of the decision back to the plugin which owns the permission. This allows the framework to support cases in which the policy does not have all the information required to make a decision.
Let's walk through the new code that we just added. Inside of the if statement, instead of returning a Definitive Policy Decision of DENY, we now return a [Conditional Policy Decision](https://backstage.io/docs/reference/plugin-permission-common.conditionalpolicydecision) (See the [Concepts page](./concepts.md) for more details). This is a way for policies to defer the evaluation of the decision back to the plugin which owns the permission. This allows the framework to support cases in which the policy does not have all the information required to make a decision.
In the policy above, there's no way for the handle method to determine whether the user who is trying to unregister the entity is the owner of that entity. So we use the `createCatalogPolicyDecision` helper provided by the catalog backend to craft a conditional decision, which allows us to tell the catalog backend that it should only return ALLOW if the user owns the entity.
In the policy above, there's no way for the handle method to determine whether the user who is trying to unregister the entity is the owner of that entity. So we use the `createCatalogConditionalDecision` helper provided by the catalog backend to craft a conditional decision, which allows us to tell the catalog backend that it should only return ALLOW if the user owns the entity. Keep in mind that, in order to return a Conditional Policy Decision, the permission needs to be of type [ResourcePermission](https://backstage.io/docs/reference/plugin-permission-common.resourcepermission): the [isResourcePermission](https://backstage.io/docs/reference/plugin-permission-common.isresourcepermission) method guarantees the correct type in the scope of the condition.
The `catalogConditions` object contains various conditions that the catalog plug authors have provided for us to use in authoring our policy. Thankfully, they have provided the `isEntityOwner` rule, which is exactly what we need.
@@ -84,12 +98,14 @@ In addition to the conditions provided by the catalog plugin, you can write your
Now let's say we would also like to restrict users from viewing catalog entities that they do not own, just like we did for unregistering entities. One way to achieve this may be to simply duplicate our if statement and check for the `catalog.entity.read` permission:
```diff
async handle(
request: PolicyAuthorizeQuery,
user?: BackstageIdentityResponse,
): Promise<PolicyDecision> {
if (request.permission.name === 'catalog.entity.delete') {
return createCatalogPolicyDecision(
async handle(
request: PolicyQuery,
user?: BackstageIdentityResponse,
): Promise<PolicyDecision> {
if (isResourcePermission(request.permission, 'catalog-entity')) {
if (request.permission.name === 'catalog.entity-delete') {
return createCatalogConditionalDecision(
request.permission,
catalogConditions.isEntityOwner(
user?.identity.ownershipEntityRefs ?? [],
),
@@ -97,27 +113,33 @@ Now let's say we would also like to restrict users from viewing catalog entities
}
+ if (request.permission.name === 'catalog.entity.read') {
+ return createCatalogPolicyDecision(
+ return createCatalogConditionalDecision(
+ request.permission,
+ catalogConditions.isEntityOwner(
+ user?.identity.ownershipEntityRefs ?? [],
+ ),
+ );
+ }
return { result: AuthorizeResult.ALLOW };
}
return { result: AuthorizeResult.ALLOW };
}
```
If you choose to write your policy this way, it will certainly work! You should be able to verify this by saving this policy and seeing that the catalog now only shows the entities that you own. However, you can imagine that as policies grow to handle many different permissions, these conditionals can quickly become repetitive. We can author this same policy in a more scalable way by using resource types.
If you choose to write your policy this way, it will certainly work! You should be able to verify this by saving this policy and seeing that the catalog now only shows the entities that you own. However, you can imagine that as policies grow to handle many different permissions, these conditionals can quickly become repetitive. We can author this same policy in a more scalable way by using permission attributes.
```diff
async handle(
request: PolicyAuthorizeQuery,
user?: BackstageIdentityResponse,
): Promise<PolicyDecision> {
- if (request.permission.name === 'catalog.entity.delete') {
+ if (request.permission.resourceType === 'catalog-entity') {
return createCatalogPolicyDecision(
async handle(
request: PolicyQuery,
user?: BackstageIdentityResponse,
): Promise<PolicyDecision> {
if (isResourcePermission(request.permission, 'catalog-entity')) {
- if (request.permission.name === 'catalog.entity-delete') {
+ if (
+ request.permission.attributes.action === 'delete' ||
+ request.permission.attributes.action === 'read'
+ ) {
return createCatalogConditionalDecision(
request.permission,
catalogConditions.isEntityOwner(
user?.identity.ownershipEntityRefs ?? [],
),
@@ -125,18 +147,19 @@ If you choose to write your policy this way, it will certainly work! You should
}
- if (request.permission.name === 'catalog.entity.read') {
- return createCatalogPolicyDecision(
- return createCatalogConditionalDecision(
- request.permission,
- catalogConditions.isEntityOwner(
- user?.identity.ownershipEntityRefs ?? [],
- ),
- );
- }
-
return { result: AuthorizeResult.ALLOW };
}
return { result: AuthorizeResult.ALLOW };
}
```
In this example, we use the `catalog-entity` resource type to catch all authorization requests that have to do with resources from the catalog. Now, you should be able to see the same functionality as before (only see the catalog entities that you own) - success!
In this example, we use the `action` permission attribute to catch all authorization requests that have to do with `read` and `delete` permissions. Now, you should be able to see the same functionality as before (only see the catalog entities that you own) - success!
_Note:_ Notice that while the `catalogEntityDeletePermission` and the `catalogEntityReadPermission` used here have the `'catalog-entity'` resource type, the [`catalogEntityCreatePermission`](https://github.com/backstage/backstage/blob/1e5e9fb9de9856a49e60fc70c38a4e4e94c69570/plugins/catalog-common/src/permissions.ts#L49) does not have a resource type associated with it, as it does not make sense to apply conditions to an entity that has not yet been created.