From f34dc2a6f38a4572c45882939f3055a64fd232aa Mon Sep 17 00:00:00 2001 From: Peter Macdonald Date: Tue, 24 Mar 2026 14:48:51 +0100 Subject: [PATCH 1/3] update golden paths permissions section Signed-off-by: Peter Macdonald --- .../plugins/integrations/003-permissions.md | 294 +++++++++++++++++- .../plugins/integrations/__meta__.md | 10 +- 2 files changed, 295 insertions(+), 9 deletions(-) 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. From d65d6d6d503644f97568aee55716d430e4cfae3a Mon Sep 17 00:00:00 2001 From: Peter Macdonald Date: Tue, 24 Mar 2026 16:10:35 +0100 Subject: [PATCH 2/3] updates based on style new style guide Signed-off-by: Peter Macdonald --- docs/golden-path/plugins/integrations/003-permissions.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/golden-path/plugins/integrations/003-permissions.md b/docs/golden-path/plugins/integrations/003-permissions.md index bd042a3e97..2da086e7f2 100644 --- a/docs/golden-path/plugins/integrations/003-permissions.md +++ b/docs/golden-path/plugins/integrations/003-permissions.md @@ -13,9 +13,9 @@ The [Backstage permissions framework](../../../permissions/overview.md) gives yo 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. +_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. +_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`. @@ -25,7 +25,7 @@ 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. +**A common package** (for example, `@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. From 6c22088daa38243800a453c7456288ac5446ed55 Mon Sep 17 00:00:00 2001 From: Peter Macdonald Date: Tue, 24 Mar 2026 16:13:35 +0100 Subject: [PATCH 3/3] updates based on feedback Signed-off-by: Peter Macdonald --- docs/golden-path/plugins/integrations/003-permissions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/golden-path/plugins/integrations/003-permissions.md b/docs/golden-path/plugins/integrations/003-permissions.md index 2da086e7f2..3cbba161fb 100644 --- a/docs/golden-path/plugins/integrations/003-permissions.md +++ b/docs/golden-path/plugins/integrations/003-permissions.md @@ -15,7 +15,7 @@ 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. +_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. In addition to the basic ALLOW or DENY, the policy can return a CONDITIONAL decision. CONDITIONAL decisions are required to be evaluated against a specific resource and will produce a per-resource ALLOW or DENY. 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`.