update golden paths permissions section
Signed-off-by: Peter Macdonald <peterm4c@pm.me>
This commit is contained in:
@@ -9,16 +9,302 @@ description: How to integrate your plugin with the Backstage Permission framewor
|
||||
|
||||
### What is the Permissions framework?
|
||||
|
||||
<!--TODO-->
|
||||
The [Backstage permissions framework](../../../permissions/overview.md) gives you a structured way to control who can do what inside your plugin. Rather than scattering authorization logic across your route handlers, you define permissions declaratively and let a central policy decide whether to allow or deny each action.
|
||||
|
||||
There are two kinds of permissions:
|
||||
|
||||
**Basic permissions** apply to actions that don't relate to a specific resource. Creating a todo is a good example: the action either is or isn't allowed, regardless of which todo you're creating. The policy returns a definitive ALLOW or DENY.
|
||||
|
||||
**Resource permissions** apply to actions on a specific resource. Reading a particular todo is a good example: whether you're allowed might depend on whether you created it. The policy can return a CONDITIONAL decision, meaning the framework evaluates the condition against the actual resource before deciding.
|
||||
|
||||
The framework sits between your route handlers and your business logic. Your handler asks "is this allowed?", the framework consults the active policy, and your handler either proceeds or throws a `NotAllowedError`.
|
||||
|
||||
### Common integration points
|
||||
|
||||
<!--TODO-->
|
||||
Most plugins integrate at two levels:
|
||||
|
||||
**The backend plugin** is where you define your permissions, register them with the framework, and enforce them inside your route handlers.
|
||||
|
||||
**A common package** (e.g. `@internal/plugin-todo-common`) is where you export the permission definitions so they can be referenced from anywhere: your backend, your frontend, and any policy that an adopter writes.
|
||||
|
||||
The split matters because policy authors need to reference your permission objects when writing their own policies. If those definitions live inside your backend package, you're forcing a dependency on backend code where it doesn't belong.
|
||||
|
||||
## Creating private TODOs
|
||||
|
||||
<!--TODO-->
|
||||
The goal here is to ensure users can only read their own todos. This is a resource permission because the decision depends on a property of the resource itself.
|
||||
|
||||
### Define the permission
|
||||
|
||||
In your common package, define a resource permission for reading todos:
|
||||
|
||||
```ts
|
||||
// plugins/todo-common/src/permissions.ts
|
||||
import { createPermission } from '@backstage/plugin-permission-common';
|
||||
|
||||
export const TODO_RESOURCE_TYPE = 'todo-item';
|
||||
|
||||
export const todoReadPermission = createPermission({
|
||||
name: 'todo.read',
|
||||
attributes: { action: 'read' },
|
||||
resourceType: TODO_RESOURCE_TYPE,
|
||||
});
|
||||
|
||||
export const todoPermissions = [todoReadPermission];
|
||||
```
|
||||
|
||||
The `resourceType` field ties this permission to a specific kind of resource. Exporting the string as a named constant (`TODO_RESOURCE_TYPE`) means you can import it in your backend rules rather than repeating the raw string, which prevents subtle mismatches.
|
||||
|
||||
### Define a permission rule
|
||||
|
||||
Rules are the conditions that the framework evaluates against a resource. Each rule has two parts: `apply`, which checks an in-memory resource, and `toQuery`, which converts the condition to a filter your database can use.
|
||||
|
||||
```ts
|
||||
// plugins/todo-backend/src/service/rules.ts
|
||||
import {
|
||||
createPermissionResourceRef,
|
||||
createPermissionRule,
|
||||
} from '@backstage/plugin-permission-node';
|
||||
import { TODO_RESOURCE_TYPE } from '@internal/plugin-todo-common';
|
||||
import { z } from 'zod/v3';
|
||||
import type { TodoItem } from './services/TodoListService';
|
||||
|
||||
export const todoResourceRef = createPermissionResourceRef<
|
||||
TodoItem,
|
||||
{ createdBy: string }
|
||||
>().with({
|
||||
pluginId: 'todo',
|
||||
resourceType: TODO_RESOURCE_TYPE,
|
||||
});
|
||||
|
||||
export const isCreator = createPermissionRule({
|
||||
name: 'IS_CREATOR',
|
||||
description: 'Allow if the todo was created by the current user',
|
||||
resourceRef: todoResourceRef,
|
||||
paramsSchema: z.object({
|
||||
userRef: z.string().describe('The entity ref of the user'),
|
||||
}),
|
||||
apply(todo, { userRef }) {
|
||||
return todo.createdBy === userRef;
|
||||
},
|
||||
toQuery({ userRef }) {
|
||||
return { property: 'createdBy', values: [userRef] };
|
||||
},
|
||||
});
|
||||
|
||||
export const rules = { isCreator };
|
||||
```
|
||||
|
||||
The `apply` and `toQuery` functions must always have logically identical outcomes. If they diverge, users will see inconsistent results depending on whether the framework checks the database or a loaded resource.
|
||||
|
||||
### Register the resource type
|
||||
|
||||
In your plugin setup, register the resource type alongside its rules:
|
||||
|
||||
```ts
|
||||
// plugins/todo-backend/src/plugin.ts
|
||||
import {
|
||||
coreServices,
|
||||
createBackendPlugin,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { todoReadPermission } from '@internal/plugin-todo-common';
|
||||
import { todoResourceRef, rules } from './service/rules';
|
||||
import { todoListServiceRef } from './services/TodoListService';
|
||||
|
||||
export const todoPlugin = createBackendPlugin({
|
||||
pluginId: 'todo',
|
||||
register(env) {
|
||||
env.registerInit({
|
||||
deps: {
|
||||
httpRouter: coreServices.httpRouter,
|
||||
httpAuth: coreServices.httpAuth,
|
||||
permissions: coreServices.permissions,
|
||||
permissionsRegistry: coreServices.permissionsRegistry,
|
||||
todoList: todoListServiceRef,
|
||||
},
|
||||
async init({
|
||||
httpRouter,
|
||||
httpAuth,
|
||||
permissions,
|
||||
permissionsRegistry,
|
||||
todoList,
|
||||
}) {
|
||||
permissionsRegistry.addResourceType({
|
||||
resourceRef: todoResourceRef,
|
||||
permissions: [todoReadPermission],
|
||||
rules: Object.values(rules),
|
||||
getResources: async resourceRefs => {
|
||||
return Promise.all(
|
||||
resourceRefs.map(ref =>
|
||||
todoList.getTodo({ id: ref }).catch(() => undefined),
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const router = await createRouter({ httpAuth, permissions, todoList });
|
||||
httpRouter.use(router);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`getResources` is called by the framework when it needs to load a resource to evaluate a conditional decision. Return `undefined` for any ref that doesn't exist.
|
||||
|
||||
### Enforce the permission in a route handler
|
||||
|
||||
In your route handler, use `authorizeConditional` for resource permissions. Unlike `authorize`, this can return a conditional decision that you apply as a filter rather than a hard stop:
|
||||
|
||||
```ts
|
||||
// plugins/todo-backend/src/service/router.ts
|
||||
import {
|
||||
HttpAuthService,
|
||||
PermissionsService,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { NotAllowedError } from '@backstage/errors';
|
||||
import { AuthorizeResult } from '@backstage/plugin-permission-common';
|
||||
import { todoReadPermission } from '@internal/plugin-todo-common';
|
||||
import { todoListServiceRef } from './services/TodoListService';
|
||||
|
||||
export async function createRouter({
|
||||
httpAuth,
|
||||
permissions,
|
||||
todoList,
|
||||
}: {
|
||||
httpAuth: HttpAuthService;
|
||||
permissions: PermissionsService;
|
||||
todoList: typeof todoListServiceRef.T;
|
||||
}): Promise<express.Router> {
|
||||
const router = Router();
|
||||
router.use(express.json());
|
||||
|
||||
router.get('/todos', async (req, res) => {
|
||||
const credentials = await httpAuth.credentials(req, { allow: ['user'] });
|
||||
|
||||
const decision = (
|
||||
await permissions.authorizeConditional(
|
||||
[{ permission: todoReadPermission }],
|
||||
{ credentials },
|
||||
)
|
||||
)[0];
|
||||
|
||||
if (decision.result === AuthorizeResult.DENY) {
|
||||
throw new NotAllowedError();
|
||||
}
|
||||
|
||||
// If CONDITIONAL, pass the conditions to your data layer as a filter.
|
||||
// If ALLOW, pass no filter (return everything).
|
||||
const result = await todoList.listTodos(
|
||||
decision.result === AuthorizeResult.CONDITIONAL
|
||||
? decision.conditions
|
||||
: undefined,
|
||||
);
|
||||
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// ... other routes
|
||||
return router;
|
||||
}
|
||||
```
|
||||
|
||||
The conditional path means users only see the data the policy allows, without the handler needing to know what the policy actually is. The policy is the adopter's concern.
|
||||
|
||||
### Export condition helpers for policy authors
|
||||
|
||||
Adopters who write their own permission policy need to be able to express conditions using your rules. Export helpers from your backend package:
|
||||
|
||||
```ts
|
||||
// plugins/todo-backend/src/conditionExports.ts
|
||||
import { createConditionExports } from '@backstage/plugin-permission-node';
|
||||
import { todoResourceRef, rules } from './service/rules';
|
||||
|
||||
const { conditions, createConditionalDecision } = createConditionExports({
|
||||
resourceRef: todoResourceRef,
|
||||
rules,
|
||||
});
|
||||
|
||||
export const todoConditions = conditions;
|
||||
export const createTodoConditionalDecision = createConditionalDecision;
|
||||
```
|
||||
|
||||
Re-export these from your package's `src/index.ts`. An adopter can then write a policy like this:
|
||||
|
||||
```ts
|
||||
import {
|
||||
todoConditions,
|
||||
createTodoConditionalDecision,
|
||||
} from '@internal/plugin-todo-backend';
|
||||
import { todoReadPermission } from '@internal/plugin-todo-common';
|
||||
|
||||
class MyPolicy implements PermissionPolicy {
|
||||
async handle(request: PolicyQuery, user?: PolicyQueryUser) {
|
||||
if (isPermission(request.permission, todoReadPermission)) {
|
||||
return createTodoConditionalDecision(
|
||||
request.permission,
|
||||
todoConditions.isCreator({ userRef: user?.info.userEntityRef ?? '' }),
|
||||
);
|
||||
}
|
||||
return { result: AuthorizeResult.ALLOW };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This gives adopters a typed, discoverable API for customizing your plugin's access control without having to understand the internals of your data layer.
|
||||
|
||||
## Restricting who can create TODOs
|
||||
|
||||
<!--TODO-->
|
||||
Restricting who can create todos is simpler. There is no resource involved yet, so this is a basic permission. The policy returns a definitive ALLOW or DENY.
|
||||
|
||||
### Define the create permission
|
||||
|
||||
Add a create permission to your common package:
|
||||
|
||||
```ts
|
||||
// plugins/todo-common/src/permissions.ts
|
||||
export const todoCreatePermission = createPermission({
|
||||
name: 'todo.create',
|
||||
attributes: { action: 'create' },
|
||||
});
|
||||
|
||||
export const todoPermissions = [todoReadPermission, todoCreatePermission];
|
||||
```
|
||||
|
||||
### Register the permission with the framework
|
||||
|
||||
In your plugin setup, register basic permissions with `addPermissions` rather than `addResourceType`:
|
||||
|
||||
```ts
|
||||
permissionsRegistry.addPermissions([todoCreatePermission]);
|
||||
```
|
||||
|
||||
### Enforce the permission in the create handler
|
||||
|
||||
For basic permissions, use `authorize` instead of `authorizeConditional`. The result is always definitive:
|
||||
|
||||
```ts
|
||||
router.post('/todos', async (req, res) => {
|
||||
const parsed = todoSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new InputError(parsed.error.toString());
|
||||
}
|
||||
|
||||
const credentials = await httpAuth.credentials(req, { allow: ['user'] });
|
||||
|
||||
const decision = (
|
||||
await permissions.authorize([{ permission: todoCreatePermission }], {
|
||||
credentials,
|
||||
})
|
||||
)[0];
|
||||
|
||||
if (decision.result !== AuthorizeResult.ALLOW) {
|
||||
throw new NotAllowedError('You are not permitted to create todos');
|
||||
}
|
||||
|
||||
const result = await todoList.createTodo(parsed.data, { credentials });
|
||||
|
||||
res.status(201).json(result);
|
||||
});
|
||||
```
|
||||
|
||||
An adopter's policy can now control this permission however they like: restrict it to a specific group, require a certain annotation on the user entity, or leave it open to everyone. Your plugin does not need to know.
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
## Integrations
|
||||
# Integrations
|
||||
|
||||
Now that our plugin is ready for prime time, let's see how we can really leverage the rest of the Backstage ecosystem. Backstage provides a set of core features out of the box, namely, the Software Catalog, Search, Permissions, and Notifications.
|
||||
|
||||
### Catalog
|
||||
## Catalog
|
||||
|
||||
We want to show our todos as separate Catalog entities. How can we make this happen?
|
||||
|
||||
### Search
|
||||
## Search
|
||||
|
||||
We want to make our todos searchable.
|
||||
|
||||
### Permissions
|
||||
## Permissions
|
||||
|
||||
We only want users to be able to find their own todos.
|
||||
|
||||
### Notifications
|
||||
## Notifications
|
||||
|
||||
We want to set an alarm time for todos that sends a notification when the time is met.
|
||||
|
||||
Reference in New Issue
Block a user