Permissions
Butler Portal 0.6.0+ uses the community RBAC backend
(@backstage-community/plugin-rbac-backend)
as the single authorization surface. This page covers the framework
choice, the shipped default policy shape, and where adopters plug in.
Plugin authors writing a permission-declaring plugin should read Plugin Authoring: Authorization after this page.
If you are new to Butler Portal permissions, start with the Getting Started with Permissions walkthrough — it sequences install, configuration, plugin wiring, and audit-verified enforcement in one runnable path. Come back here for the reference detail.
The runnable reference for everything on this page lives at
examples/adopter-plugin —
a minimal end-to-end plugin declaring a permission, registering a
permission rule via permissionsRegistry.addResourceType, and
exposing a gated route. Every code snippet below has a working
counterpart there.
Framework
Backstage's upstream permission framework enforces one
PermissionPolicy per instance. Butler Portal delegates that policy
to RBAC — a Casbin-backed engine that stores role assignments as CSV
rows and exposes an admin UI at /rbac for policy admins to create,
edit, and delete role bindings.
Every authorize() call from any plugin flows through RBAC's central
policy check. RBAC decides ALLOW / DENY / CONDITIONAL based on:
- Whether the caller's identity resolves into an entity ref listed
under
permission.rbac.admin.superUsers(bypass everything); - Whether any role the caller holds has an ALLOW row for the
requested permission in
permission.rbac.defaultPermissionsor the loaded policy CSV; - Otherwise DENY.
Plugins register their permissions by declaring them with
createPermission() and calling permissions.authorize() in their
request handlers. No plugin-level policy object is needed. RBAC
evaluates the calls centrally.
Frontend + backend packages
RBAC ships as two separate packages that must both be installed and
wired for the /rbac UI to appear:
- Backend:
@backstage-community/plugin-rbac-backend, added inpackages/backend/src/index.ts(butler-portal already does this atpackages/backend/src/index.ts:62). Handles authorization evaluation. - Frontend:
@backstage-community/plugin-rbac, added inpackages/app/and wired as a route. Butler-portal wires it atpackages/app/src/baselineRoutes.tsx:44(import) and mounts<RbacPage />at path/rbac.
Installing only the backend gives you policy evaluation with no admin
UI — signed-in admins have no way to inspect or edit role bindings
outside the CSV. Adopters starting from butler-portal's shipped
packages/app/ inherit the frontend wiring automatically; adopters
who bring their own Backstage app must add both packages themselves.
Two admin tiers
RBAC distinguishes two admin populations:
permission.rbac.admin.users— policy admins. Manage RBAC via the/rbacUI and API. Can create, edit, and delete role bindings. Do NOT get automatic access to other plugins' write permissions; they still need role assignments like any other user.permission.rbac.admin.superUsers— unrestricted. Bypass policy evaluation entirely for every plugin. Bootstrap identities live here so an operator can configure RBAC through the UI before the policy CSV is populated.
Both fields accept user or group refs. Keep both lists tight; the
superUsers list in particular should contain one or two identities,
not a broad group.
Shipped default policy
The chart's default values ship a fail-safe policy that any adopter can override:
permission.enabled: true— permission enforcement is on.permission.rbac.admin.users: []andpermission.rbac.admin.superUsers: []— both empty by default.permission.rbac.defaultPermissions.defaultRole: role:default/butler-portal-user— every authenticated user is implicitly a member ofbutler-portal-user.permission.rbac.defaultPermissions.basicPermissions— grantsbutler-portal-userfive reads:catalog.entity.read,catalog.location.read,scaffolder.task.read,scaffolder.template.parameter.read,scaffolder.template.step.read. Read-only self-service surface works for any signed-in user out of the box; every write requires a role binding via the CSV.permission.rbac.policy.csv— CSV rows grantingrole:default/butler-portal-adminthe privileged Backstage-core writes:scaffolder.task.create,scaffolder.task.cancel,scaffolder.action.execute,scaffolder.template.management,catalog.location.create.- The shipped CSV binds
group:default/PLACEHOLDER-ADMIN-GROUPtorole:default/butler-portal-admin. That group does not exist in any tenant's directory; the binding is a placeholder.
The two-role split (butler-portal-user for reads,
butler-portal-admin for writes) is the reference shape. Adopters can
flatten it, extend it, or replace it wholesale — the role names are
conventions the chart establishes, not hard-coded framework names.
First-boot state and two paths to unblock writes
Out of the box, superUsers is empty AND the PLACEHOLDER-ADMIN-GROUP
binding names a group that no directory resolves. In that state,
signed-in users can read via defaultPermissions but nobody can
perform gated writes AND nobody can reach the /rbac admin UI to
grant themselves a role (because admin.users is also empty). Safe
by default, bricked for bootstrap. You cannot unbrick from the UI —
you edit the chart values.
Two separate blocks of work, in this order. Do NOT treat them as one checklist — Block A unbricks access; Block B is what makes enforcement actually work.
A. Bootstrap access (recovery mechanics — makes the portal usable at all):
There are two independent levers that unblock writes: populating
superUsers (bypass everything for a break-glass identity) or
replacing PLACEHOLDER-ADMIN-GROUP in the policy CSV with a real
group your directory resolves. Either alone is sufficient. The
typical setup uses both, plus admin.users for policy management:
- Set
permission.rbac.admin.superUsersto one break-glass identity (a service-team on-call user ref). This is bypass access, so keep it to one or two refs. - Replace
PLACEHOLDER-ADMIN-GROUPin the policy CSV with your real admin group ref (e.g. an SSO-provisioned platform team). Members inheritrole:default/butler-portal-admin's privileged writes. - Add the same admin group to
permission.rbac.admin.usersso its members can manage RBAC through/rbac. Step 2 alone does NOT grant UI access; step 3 is what does.
After block A, admins can sign in and perform gated writes. It is tempting to stop here — the portal appears to work. It is not enforced.
B. Wire plugin enforcement (required for enforcement to work at all — skipping this leaves every plugin's writes silently allowed regardless of your CSV):
Block B assumes Block A is complete. Step 5 in particular requires signing in as an admin-group member, which only exists after Block A steps 2-3.
- REQUIRED. List every plugin that declares permissions under
permission.rbac.pluginsWithPermission— including your own adopter dynamic plugins. Unlisted plugins return[]from RBAC's discovery API and everyauthorize()call for them passes through as ALLOW no matter what the CSV says. No boot warning is emitted. This is a near-security failure, not a convenience item. - Deploy. Sign in as an admin-group member. Confirm
GET /api/permission/plugins/condition-rulesreturns every plugin ID you listed. Missing IDs from the response = enforcement is off for those plugins. Blocking the rollout on this check is a norm today, not a mechanism — no chart-side gate stops a deploy wherepluginsWithPermissionis missing entries unless the operator opts each plugin entry into the structural guard from butlerdotdev/butler-portal#42 (permissions.enforced: trueper plugin entry).
Block A without block B = "safe by default, but every plugin's writes are unenforced." Block B alone (skipping A) leaves the portal locked down but adopters cannot administer it.
What adopters must configure
For any plugin's permissions to be evaluated, the plugin ID MUST
appear under permission.rbac.pluginsWithPermission in
app-config.yaml. RBAC does not auto-discover plugins that declare
permissions or rules. If the list is empty or missing, RBAC's
discovery endpoint returns [] and every authorize() call for
that plugin silently passes through as ALLOW, regardless of what
the policy CSV says. Confusingly, no boot warning is emitted.
The chart values expose this as an array:
permission:
enabled: true
rbac:
admin:
superUsers:
- name: group:default/your-admin-group
pluginsWithPermission:
- catalog
- scaffolder
- permission
# Add every additional first-party or dynamic plugin
# whose permissions should be enforced.
The chart default lists catalog, scaffolder, and permission.
Adopter dynamic plugins that declare permissions (e.g. pe.*,
myplugin.*) must add their own plugin ID.
Role binding: policy CSV vs admin UI
Two paths ship a CSV binding of a group to a role:
- In-chart values — the operator writes CSV rows directly under
permission.rbac.policy.csv. Gitops-friendly; changes flow through the standard chart-values MR path. Recommended for production adopters. - RBAC admin UI — a policy admin or superuser edits role
assignments through the
/rbacUI. State lives in the RBAC database. Useful for experimentation; less auditable than gitops.
Both paths compose. CSV changes hot-reload without a pod restart:
Helm rewrites the ConfigMap when values change, kubelet updates the
mounted file at permission.rbac.policies-csv-file (chart default
/etc/butler-portal-rbac/policy.csv), and RBAC's file watcher
re-reads the file because permission.rbac.policyFileReload is
true by chart default. UI-created entries are stored in the RBAC
database independently of the CSV mount.
To make gitops the sole source of truth, keep
permission.rbac.admin.users: []. Only identities listed under
admin.superUsers can then reach /rbac, so day-to-day operators
cannot make policy edits that would drift from the CSV. RBAC has no
dedicated toggle to disable UI writes; empty admin.users is the
lever.
Migration from the pre-0.6.0 adjudicator seam
Pre-upgrade warning. Before bumping Butler Portal past 0.5.x, migrate any plugin that imports
authAdjudicatorExtensionPointfrompackages/backend/src/authAdjudicator.ts— that file is deleted in 0.6.0. In-tree plugins fail at TypeScript build. Pre-built dynamic- plugin OCI artifacts compiled against pre-0.6.0 fail at runtime when the dynamic-plugin loader tries to resolve the removed import; the loader error may not be prominent in boot output, so a plugin can appear to load and silently register no routes. Migrate the plugin and republish the OCI artifact BEFORE upgrading the portal.
Butler Portal versions prior to 0.6.0 shipped ButlerPortalDelegatingPolicy
in packages/backend/src/permissionPolicy.ts. Plugins registered a
per-namespace adjudicator function via authAdjudicatorExtensionPoint;
the delegating policy routed each authorize() to the matching
adjudicator based on the permission-name prefix.
That seam is removed as of 0.6.0. Plugins that used it must migrate to the RBAC pattern:
- Delete the
createBackendModulethat registered the adjudicator. - Move the role/group check logic out of the adjudicator function into RBAC policy CSV rows.
- Keep the
createPermission()declarations; they are still the canonical way to name what gets gated. - If your adjudicator did conditional decisions (e.g. "user can edit
only the entity they own"), express those via RBAC's
permission.rbac.conditionalPoliciesFile, and declare the rule itself viapermissionsRegistry.addResourceType({ resourceRef, permissions, rules, getResources })in your plugin'sinit. See examples/adopter-plugin/backend/src/plugin.ts for a workingcreatePermissionRule+addResourceType+ conditional-policy end-to-end.
The migration is mechanical for boolean allow/deny adjudicators.
Conditional adjudicators require a small refactor to expose the
condition as a createPermissionRule() and register it via
permissionsRegistry.addResourceType.
Runtime audit for pluginsWithPermission gaps
Butler Portal ships a startup audit module
(packages/backend/src/pluginsWithPermissionAuditModule.ts, wired
automatically from packages/backend/src/index.ts) that catches
plugins whose permissions are declared at runtime but not listed in
permission.rbac.pluginsWithPermission. This complements the
chart-time guard (permissions.enforced: true) and the NOTES.txt
warn: the chart mechanisms see values-file INTENT; the runtime audit
sees REALITY.
What it checks
Once, at backend startup after dynamic plugins finish loading, the audit:
- Enumerates every loaded backend dynamic plugin via
dynamicPluginsServiceRef.backendPlugins(). - For each plugin, HTTP-fetches its own
/.well-known/backstage/permissions/metadataendpoint (auto-wired by@backstage/plugin-permission-node'screatePermissionIntegrationRouterwhen the plugin callspermissionsRegistry.addPermissions()or.addResourceType()). - For each plugin that declared permissions at runtime AND is missing
from
permission.rbac.pluginsWithPermission, logs at level=error:[pluginsWithPermission audit] plugin '<id>' declared N permission(s) at runtime and is NOT in permission.rbac.pluginsWithPermission. Authz is NOT running for this plugin: RBAC's discovery API returns [] for it, and every authorize() call for its permissions passes through as ALLOW regardless of your policy CSV. Add '<id>' to permission.rbac.pluginsWithPermission. - For each plugin that IS listed but returned 404 on
/permission/metadata, logs at level=info: either the listing is stale, or the plugin loaded without callingaddPermissions.
Log lines survive every deployment model (helm interactive, Flux
HelmRelease, Argo CD, kubectl apply) — they land in the backend
container's stdout, which every observability stack captures.
If you see the log
If your backend logs contain:
[pluginsWithPermission audit] plugin '<id>' declared N permission(s) at runtime and is NOT in permission.rbac.pluginsWithPermission ...
add <id> to permission.rbac.pluginsWithPermission in your chart
values and redeploy. The audit will re-run at next boot and confirm
the gap closed (its "audit complete: no gaps found" info line).
If the log is:
[pluginsWithPermission audit] plugin '<id>' is listed in permission.rbac.pluginsWithPermission but its /permission/metadata returned 404 ...
either the listing is stale (remove <id> from
pluginsWithPermission) or the plugin is missing its
permissionsRegistry.addPermissions([...]) call at init (see the
plugin-authoring guide's
addPermissions convention).
Coverage limit
The audit reads /permission/metadata, which is auto-wired by
createPermissionIntegrationRouter only when the plugin calls
permissionsRegistry.addPermissions() or .addResourceType(). A
plugin that only does createPermission({name}) +
permissions.authorize() never populates that endpoint and is
invisible to the audit. The plugin-authoring guide's minimal example
now calls addPermissions() so the pattern the docs teach is visible
to the audit; older plugins written without the convention remain
blind spots.
Known gotchas
pluginsWithPermissionis required. Empty list means silent pass-through. Every rollout should verifyGET /api/permission/plugins/condition-rules(with a superuser identity) returns the expected plugin IDs — or rely on the runtime audit (see Runtime audit for pluginsWithPermission gaps) which does the check automatically at every backend start. The chart offers an opt-in structural guard from butlerdotdev/butler-portal#42: setpermissions.enforced: truewithpermissions.pluginId: <id>on each plugin entry indynamicPlugins.pluginsandhelm templatefails at install/upgrade when the id is missing frompluginsWithPermission. The guard defaults to OFF per plugin entry — an adopter who does not opt in gets no structural protection, only this doc.typeorm-adaptertransitively requiresmongodb. RBAC's Casbin adapter does a top-levelrequire('mongodb')even when configured for SQLite or PostgreSQL.packages/backend/package.jsonincludesmongodbfor this reason; the dep is not loaded at runtime.- Guest auth resolves to
user:development/guest(namespacedevelopment, notdefault). A superusers list containinguser:default/guestwill silently not match. This affects only local dev; SSO adopters see their own resolved identities. - Conditional policies file uses
---document separators, not a YAML list. The list form fails with a misleading'roleEntityRef' must be specifiederror even when the field is present.