Authorization
How a plugin declares and enforces its own permissions inside butler-portal.
Read Architecture: Permissions first for the framework overview. This page is the plugin-author contract: what you write in your plugin so the adopter's RBAC policy can gate your surface.
If you are following the end-to-end sequence (install → wire → enforce → verify), see the Getting Started with Permissions walkthrough. This page is the reference material that page cites.
Runnable reference: examples/adopter-plugin. Every pattern below has a working counterpart there.
Contract at a glance
- Pick a permission-name prefix for your plugin. End it with a
separator so it cannot accidentally collide (
myplugin.notmyplugin). - Declare your permissions with
createPermission({ name: 'myplugin.<resource>.<action>', attributes: { action: '...' } }). - In each route you want to gate, resolve credentials via
httpAuth, callpermissions.authorize(), and 403 on non-ALLOW. - Tell adopters to list your plugin ID in
permission.rbac.pluginsWithPermission. Without it, yourauthorize()calls silently pass through — no boot warning, no audit trail, no signal. - Ship a
PERMISSIONS.mdin your plugin repo listing every permission, what surface it gates, and the shipped default role that grants it.
Nothing in butler-portal core changes when you add or modify a plugin's permissions. RBAC handles evaluation centrally.
Minimal example
This section shows the pattern for UNCONDITIONAL gating: a role
either has the permission or it does not, regardless of properties of
the target resource. The permissions declared below are
BasicPermission values from @backstage/plugin-permission-common.
For CONDITIONAL gating (permission granted only when the caller
satisfies some property of the target resource — "is the owner",
"is in the workspace", etc.), skip ahead to
Conditional policies and permission rules.
A plugin can and often does declare both kinds — pick per permission,
not per plugin. Both live in the same permissions.ts.
Two files inside a plugin at my-plugin/backend/src/:
permissions.ts (declarations):
import { createPermission } from '@backstage/plugin-permission-common';
// Unconditional gates. A role either has these or does not.
export const myPluginThingReadPermission = createPermission({
name: 'myplugin.thing.read',
attributes: { action: 'read' },
});
export const myPluginThingWritePermission = createPermission({
name: 'myplugin.thing.write',
attributes: { action: 'update' },
});
router.ts (enforcement at request time):
import express from 'express';
import Router from 'express-promise-router';
import {
HttpAuthService,
LoggerService,
PermissionsService,
} from '@backstage/backend-plugin-api';
import {
AuthorizeResult,
BasicPermission,
} from '@backstage/plugin-permission-common';
import { NotAllowedError } from '@backstage/errors';
import { myPluginThingWritePermission } from './permissions';
type Deps = {
logger: LoggerService;
httpAuth: HttpAuthService;
permissions: PermissionsService;
};
async function requirePermission(
req: express.Request,
permission: BasicPermission,
permissions: PermissionsService,
httpAuth: HttpAuthService,
): Promise<void> {
const credentials = await httpAuth.credentials(req);
const [decision] = await permissions.authorize(
[{ permission }],
{ credentials },
);
if (decision.result !== AuthorizeResult.ALLOW) {
throw new NotAllowedError(`Permission denied: ${permission.name}`);
}
}
export async function createRouter(deps: Deps): Promise<express.Router> {
const { httpAuth, permissions } = deps;
const router = Router();
router.post('/things/:id', async (req, res, next) => {
try {
await requirePermission(
req,
myPluginThingWritePermission,
permissions,
httpAuth,
);
// ... write
res.json({ ok: true });
} catch (e) {
next(e);
}
});
return router;
}
module.ts (thread permissions into the router and register the
plugin's permissions with the framework):
import {
coreServices,
createBackendPlugin,
} from '@backstage/backend-plugin-api';
import { createRouter } from './router';
import {
myPluginThingReadPermission,
myPluginThingWritePermission,
} from './permissions';
export const myPlugin = createBackendPlugin({
pluginId: 'my-plugin',
register(env) {
env.registerInit({
deps: {
logger: coreServices.logger,
httpRouter: coreServices.httpRouter,
httpAuth: coreServices.httpAuth,
permissions: coreServices.permissions,
permissionsRegistry: coreServices.permissionsRegistry,
},
async init({ logger, httpRouter, httpAuth, permissions, permissionsRegistry }) {
// Register every permission this plugin declares with the
// framework's permissions registry. This is what populates
// /.well-known/backstage/permissions/metadata, the endpoint
// RBAC's discovery walks to find each plugin's permissions.
// A plugin that skips this registration is invisible to RBAC's
// own tooling AND to the pluginsWithPermission runtime audit;
// its permissions still work at authorize()-time but nothing
// else can enumerate them. Always register.
permissionsRegistry.addPermissions([
myPluginThingReadPermission,
myPluginThingWritePermission,
]);
const router = await createRouter({ logger, httpAuth, permissions });
httpRouter.use(router);
// Backstage's default HTTP auth policy for backend routes does
// NOT accept the browser's user session cookie. Without this,
// browser-originating requests to /api/my-plugin/things/:id
// return 401 BEFORE authorize() runs, and the caller sees an
// auth failure they cannot resolve. Declare the paths that
// accept the user cookie explicitly.
httpRouter.addAuthPolicy({ path: '/things/:id', allow: 'user-cookie' });
},
});
},
});
NotAllowedError from @backstage/errors serializes as HTTP 403 by
default when thrown from an Express handler.
Why
permissionsRegistry.addPermissionsmatters even for unconditional permissions: RBAC's discovery endpoint walks each plugin's/.well-known/backstage/permissions/metadata. A plugin that never callsaddPermissionsnever populates that endpoint, so RBAC cannot introspect what the plugin gates. Registration is the plugin-authoring convention that makes discovery work end to end. Butler Portal also runs a startup audit (pluginsWithPermissionAuditModuleinpackages/backend) that reads the same endpoint to detect plugins declared but missing frompermission.rbac.pluginsWithPermission; a plugin that skipsaddPermissionsis invisible to that audit too. Always register.
Conditional policies and permission rules
This section covers a DIFFERENT pattern from the minimal example above — pick one per permission, not one per plugin:
BasicPermission(the minimal example): unconditional gating. A role either has the permission or it does not. Suitable for reads and writes that do not depend on any property of the target resource.ResourcePermission+ rule (this section): conditional gating. Permission granted only when the caller satisfies some property of the target resource ("is the owner", "is in the workspace"). The plugin declares a rule that returns a boolean per (resource, params) pair; the adopter references the rule from a conditional policy YAML.
Both kinds coexist in the same permissions.ts. The example below
adds a NEW conditional permission (myplugin.thing.delete) alongside
the two unconditional permissions from the minimal example — it does
not replace them. Delete is a natural fit for conditional gating
because delete permissions typically scope to the resource's owner:
any user can list things they can see, but only the owner can delete
one.
For the samples below, add these dependencies to your plugin's
backend/package.json. The versions match the working reference at
examples/adopter-plugin/backend/package.json:
"dependencies": {
"@backstage/backend-plugin-api": "^1.9.3",
"@backstage/plugin-permission-common": "^0.9.9",
"@backstage/plugin-permission-node": "^0.11.2",
"@backstage/errors": "^1.3.1"
}
@backstage/errors supplies NotAllowedError used by the minimal
example's requirePermission helper.
If you install with npm rather than yarn, add an
.npmrcnext to yourpackage.jsonwithlegacy-peer-deps=true.@backstage/[email protected]declares apeerOptional jsdom@^27.1.0that conflicts with its own transitive[email protected](which bringsjsdom@29). Yarn (used everywhere else in butler- portal) resolves this loosely; npm 9+ rejects it asERESOLVE. Same accommodation the runnable reference at examples/adopter-plugin/backend/.npmrc ships. Adopters writing a plugin from scratch inherit the same constraint until@backstage/cliships a version that either relaxes the peer range or matches the transitive jsdom.
Reproducing the CI compile-check locally: the docs-compile-check harness reads the dependency snippet above and adds
express,express-promise-router,typescript, and@types/expresson top so the router.ts sample resolves. If you build a local harness by copying the snippet verbatim, include those four packages too or tsc will fail withCannot find module 'express'.Fenced-block annotations used throughout this doc are read by the compile-check CI.
title="foo.ts"extracts the block asfoo.tsin the harness and compiles it (withappendif the file is split across multiple blocks).noCompile="<reason>"marks an illustrative fragment the CI should skip, with a mandatory reason so a reviewer sees why. Unannotatedts/tsxblocks fail the check — silent skipping is how a broken sample hides. Seescripts/docs-compile-check.mjsfor the annotation contract.
Declare the rule with createPermissionRule (typically in a
backend/src/rule.ts). The example below types the callback
parameters explicitly rather than deriving them from a
paramsSchema, because [email protected]'s inference
through zod's type outputs currently produces TS2589: Type instantiation is excessively deep on both the working reference and
any adopter code following the reference shape. Tracked in
butlerdotdev/butler-portal#52.
Explicit generic parameters on createPermissionRule are required
under [email protected]; without them TypeScript picks
the deprecated overload and infers TParams as undefined, which
breaks the callback signatures. The shape below is compile-checked by
docs-compile-check CI workflow on every push (see
.github/workflows/docs-compile-check.yml):
import {
createPermissionResourceRef,
createPermissionRule,
} from '@backstage/plugin-permission-node';
// owner is optional so a resource with a missing owner field does
// not throw when apply() dereferences it. Match the working
// reference at examples/adopter-plugin/backend/src/rule.ts:7.
export type Thing = { id: string; owner?: string };
export type ThingQuery = { ownerFilter?: string };
export const thingResourceRef = createPermissionResourceRef<
Thing,
ThingQuery
>().with({ pluginId: 'my-plugin', resourceType: 'my-thing' });
export const isThingOwnerRule = createPermissionRule<
typeof thingResourceRef,
{ expectedOwner: string }
>({
name: 'IS_THING_OWNER',
description: 'Allow when the caller owns the thing',
resourceRef: thingResourceRef,
apply: (resource, params) =>
resource?.owner === params.expectedOwner,
toQuery: params => ({ ownerFilter: params.expectedOwner }),
});
If #52
lands and you want runtime schema validation of the rule params,
add "zod": "^3.25.76" to your dependencies and a paramsSchema: z.object({ expectedOwner: z.string() }) field to the rule above.
Until #52 lands, adding those reintroduces the TS2589 error the
explicit-generics shape works around.
If the rule stops compiling in a way you cannot resolve, treat
that as an early warning rather than a nuisance to cast past. The
compile error is the friction that surfaces a type mismatch between
your rule and the plugin-permission-node contract. Silencing it with
@ts-ignore or an as any cast lets the plugin build but leaves the
rule in a broken state — the callback types no longer match what the
framework passes at runtime, so apply() may see undefined where
you expected the resource, or the wrong shape for params. Combined
with the opt-in guard from
#42 being
OFF for a plugin the adopter did not explicitly enable, the failure
is silent: permissions are declared, authorize() runs, and RBAC
either evaluates the wrong result or passes through as ALLOW because
the rule never registers correctly.
Add the conditional permission to your existing permissions.ts
alongside the unconditional ones. resourceType is what makes this a
ResourcePermission — the type the framework needs to route the
permission to the resource type registered below:
// Add these lines to the same permissions.ts alongside the
// unconditional ones from the minimal example.
export const myPluginThingDeletePermission = createPermission({
name: 'myplugin.thing.delete',
attributes: { action: 'delete' },
resourceType: 'my-thing',
});
Register the rule and its resource type in your plugin's init, then
call authorize() with a resourceRef. getResources is the plugin-
supplied loader that resolves a batch of resource refs to their live
values so apply() can inspect them:
async function loadThingsById(
refs: string[],
): Promise<Array<Thing | undefined>> {
// Your storage / API lookup, keyed by the resource ref you pass
// in the authorize() call. Return one entry per input ref;
// undefined for refs that do not resolve. The rule's apply()
// above uses `resource?.owner` so an undefined resource returns
// false; a false result means the rule does not authorize the
// caller, so this permission ends up DENY unless another policy
// row grants it unconditionally.
return refs.map(id => YOUR_STORE.get(id));
}
env.registerInit({
deps: {
/* ...as before... */
permissionsRegistry: coreServices.permissionsRegistry,
},
async init({ /* ... */ permissions, permissionsRegistry }) {
permissionsRegistry.addResourceType({
resourceRef: thingResourceRef,
permissions: [myPluginThingDeletePermission],
rules: [isThingOwnerRule],
getResources: loadThingsById,
});
router.delete('/things/:id', async (req, res) => {
const credentials = await httpAuth.credentials(req);
const [decision] = await permissions.authorize(
[
{
permission: myPluginThingDeletePermission,
resourceRef: req.params.id,
},
],
{ credentials },
);
if (decision.result === AuthorizeResult.DENY) {
res.status(403).json({ error: 'forbidden' });
return;
}
// ... proceed
});
},
});
Adopters then reference the rule from a conditional policy file, one
YAML document per policy separated by ---. The --- separator is
load-bearing; the wrong shape fails with a misleading error — see
Failure modes:
---
result: CONDITIONAL
roleEntityRef: role:default/thing-reader
pluginId: my-plugin
resourceType: my-thing
permissionMapping:
- read
conditions:
rule: IS_THING_OWNER
resourceType: my-thing
params:
expectedOwner: $currentUser
$currentUser and $ownerRefs are built-in RBAC aliases substituted
at authorize-time with the caller's user ref and ownership refs.
Point adopters at
permission.rbac.conditionalPoliciesFile
for where the file mounts.
Working end-to-end reference (permission + rule + resource type + gated route + conditional policy YAML): examples/adopter-plugin/backend/src/plugin.ts and examples/adopter-plugin/backend/src/rule.ts.
Adopter configuration
Two chart-values entries are required for your plugin's permissions
to be evaluated. Document both in your plugin's PERMISSIONS.md.
Register the plugin with RBAC so authorize() calls are
evaluated instead of passing through:
permission:
enabled: true
rbac:
pluginsWithPermission:
- catalog
- scaffolder
- permission
- my-plugin # your plugin ID
Bind the permission to a role via the policy CSV or the RBAC admin UI:
permission:
rbac:
policy:
csv: |
p, role:default/butler-portal-admin, myplugin.thing.write, update, allow
g, group:default/your-admin-group, role:default/butler-portal-admin
The p, ... row grants the permission to a role. The g, ... row
binds a group to that role. role:default/butler-portal-admin is
the privileged role the chart ships by default; adopters who split
roles further (e.g. role:default/myplugin-writer) reference their
custom roles the same way.
Frontend visibility
Gate UI controls with usePermission() from
@backstage/plugin-permission-react so users do not see buttons
they cannot use:
// Frontend snippet — lives in your plugin's frontend package, not
// the backend package the samples above target. `Button` here is
// a stand-in for your app's button component.
import { usePermission } from '@backstage/plugin-permission-react';
import { myPluginThingWritePermission } from './permissions';
export const WriteButton = () => {
const { allowed } = usePermission({
permission: myPluginThingWritePermission,
});
if (!allowed) return null;
return <Button>Save</Button>;
};
Frontend gating is UX, not security. The backend authorize() call
is the security boundary. Do not skip backend enforcement because
the button is hidden.
Testing
The two tests worth writing:
- Handler-level authorize integration: mock the
PermissionsServiceto return DENY, hit the route, assert 403. Then flip to ALLOW, assert 200. Covers therequirePermissionwiring without depending on RBAC's policy engine. - End-to-end policy test against a fixture RBAC config: boot RBAC with a fixture CSV that grants your permission to a fixture role bound to a fixture user, sign in as that user, hit the route. Repeat with a user not in the role, assert 403. Slower but covers the full stack.
Shared helpers for both patterns (mockPermissionsService() and a
fixture-CSV RBAC boot harness) are tracked in
butlerdotdev/butler-portal#43.
Until they land, adopters write the mock and fixture boot from scratch
per plugin.
Prefix conventions
- One prefix per plugin, terminated with a separator (
.conventional). - Short and specific:
myplugin.,pe., notbackstage.myplugin.. - Do not shadow another plugin's prefix. Check other plugins'
PERMISSIONS.mdfiles before choosing. - Use
<subject>.<action>for the tail (thing.read,thing.write,admin.settings.update).
Failure modes to know
- Silent pass-through when
pluginsWithPermissionomits your plugin ID: yourauthorize()call returns ALLOW regardless of policy. No warning is emitted at boot. This is a real near-security failure: adopters can forget the list entry and believe they are enforced when they are not. Document the required entry loudly in your plugin'sPERMISSIONS.md. Adopters using the butler-portal chart can opt each plugin entry into a structural guard from butlerdotdev/butler-portal#42 (permissions.enforced: true+permissions.pluginId) that failshelm templatewhen the id is missing frompluginsWithPermission. The guard is OPT-IN per plugin entry, not default-on: an adopter who does not set it gets the same silent pass-through this bullet describes. Recommend it in your plugin'sPERMISSIONS.mdas the wiring adopters should apply for your plugin specifically. - Superusers bypass everything: an identity resolved into
permission.rbac.admin.superUsersskips all evaluation. If your denial test uses a superuser, it will spuriously pass. - Guest identity resolves to
user:development/guest: local dev with the guest auth provider uses namespacedevelopment, notdefault. A superusers list withuser:default/guestwill not match. This bites only local dev. - Conditional policies YAML uses
---separators, not a list: if you introduce acreatePermissionRule()and adopters want to wire conditional policies against it, the file format is YAML documents separated by---, not a top-level YAML list. The list form fails with a misleading'roleEntityRef' must be specifiederror even when the field is present. See Conditional policies and permission rules above for a working example of the YAML shape.
PERMISSIONS.md contract for your plugin
Every plugin that declares permissions should ship a top-level
PERMISSIONS.md in its repository. The format:
# my-plugin permissions
## Permissions
| Permission name | Action | UI/API surface gated | Default role granting |
|---|---|---|---|
| `myplugin.thing.write` | update | POST /api/my-plugin/things/:id | role:default/butler-portal-admin |
## Enforcement mechanism
Route handlers call `requirePermission(req, <perm>, permissions, httpAuth)` before mutating state.
## Adopter configuration
Add `my-plugin` to `permission.rbac.pluginsWithPermission` and bind
the desired role to `role:default/butler-portal-admin` (or override).
The doc lands in the same PR as the permission declaration. Reviewers
should block PRs that add a permission without updating the plugin's
PERMISSIONS.md.