diff --git a/docs/golden-path/plugins/integrations/003-permissions.md b/docs/golden-path/plugins/integrations/003-permissions.md index 642133eef3..bd042a3e97 100644 --- a/docs/golden-path/plugins/integrations/003-permissions.md +++ b/docs/golden-path/plugins/integrations/003-permissions.md @@ -9,16 +9,302 @@ description: How to integrate your plugin with the Backstage Permission framewor ### What is the Permissions framework? - +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 - +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 - +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 { + 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 - +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. diff --git a/docs/golden-path/plugins/integrations/__meta__.md b/docs/golden-path/plugins/integrations/__meta__.md index 2d8df8136e..de9c90befc 100644 --- a/docs/golden-path/plugins/integrations/__meta__.md +++ b/docs/golden-path/plugins/integrations/__meta__.md @@ -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.