Defining a Least-Privilege RBAC Policy for Service Owners
Service owners need to manage the components they own — and nothing else. This guide implements a Backstage permission policy in TypeScript that grants write access only to entities the requesting user owns, denying everything outside their ownership graph by default.
This is the concrete enforcement layer beneath your Role-Based Access Control Setup within the broader Authentication, RBAC & Security Governance strategy. Backstage ships an allow-all policy out of the box; replacing it with an ownership-scoped policy is what turns “everyone is an admin” into genuine least privilege.
Prerequisites
- Backstage
>= 1.20.0with@backstage/plugin-permission-backend>= 0.5.0and@backstage/plugin-permission-nodeinstalled inpackages/backend. - Permission framework enabled in config (
permission.enabled: true). - Catalog ownership populated — every
Component,API, andResourcehas aspec.ownerresolving to aGroupthe user can belong to. - Identity with group membership, established by your OIDC & SSO Configuration so
ownershipEntityRefsis present on the token. - Node.js
>= 20.xto match the Backstage backend runtime.
# app-config.yaml — Requires Backstage >= 1.20.0
permission:
enabled: true
Exact Configuration
1. Resolve the user’s ownership references
The policy decision hinges on ownershipEntityRefs — the set of group and user refs the caller belongs to. These come from the identity layer at sign-in.
// packages/backend/src/plugins/permission.ts
// Requires @backstage/plugin-permission-node >= 0.7.0
import { BackstageIdentityResponse } from '@backstage/plugin-auth-node';
import {
PolicyDecision,
AuthorizeResult,
} from '@backstage/plugin-permission-common';
import {
PermissionPolicy,
PolicyQuery,
} from '@backstage/plugin-permission-node';
import {
catalogConditions,
createCatalogConditionalDecision,
} from '@backstage/plugin-catalog-backend/alpha';
import {
isPermission,
isResourcePermission,
} from '@backstage/plugin-permission-common';
import {
catalogEntityDeletePermission,
catalogEntityUpdatePermission,
} from '@backstage/plugin-catalog-common/alpha';
2. Implement the ownership-scoped policy
The policy returns a conditional decision for write actions: Backstage filters to entities whose owner is in the caller’s ownership refs, so the same rule covers both single-entity checks and list filtering.
// Requires @backstage/plugin-permission-node >= 0.7.0
class ServiceOwnerPolicy implements PermissionPolicy {
async handle(
request: PolicyQuery,
user?: BackstageIdentityResponse,
): Promise<PolicyDecision> {
const ownershipRefs = user?.identity.ownershipEntityRefs ?? [];
// Mutating actions on catalog entities are scoped to owned entities.
if (
isPermission(request.permission, catalogEntityUpdatePermission) ||
isPermission(request.permission, catalogEntityDeletePermission)
) {
if (isResourcePermission(request.permission, 'catalog-entity')) {
return createCatalogConditionalDecision(request.permission, {
anyOf: [
catalogConditions.isEntityOwner({
claims: ownershipRefs,
}),
],
});
}
}
// Everything else (reads) is allowed; deny-by-default for unknown writes.
return { result: AuthorizeResult.ALLOW };
}
}
3. Register the policy in the backend
// packages/backend/src/plugins/permission.ts
// Requires @backstage/plugin-permission-backend >= 0.5.0
import { createRouter } from '@backstage/plugin-permission-backend';
export default async function createPlugin(env: PluginEnvironment) {
return await createRouter({
config: env.config,
logger: env.logger,
discovery: env.discovery,
policy: new ServiceOwnerPolicy(),
identity: env.identity,
});
}
4. Tighten the default for sensitive writes
Switch the trailing ALLOW to a DENY for any permission you have not explicitly reasoned about, so a newly added permission is closed until you scope it. This mirrors the deny-by-default posture you set in your Team Permission Models.
// Replace the fallthrough for write-class permissions
if (request.permission.attributes.action !== 'read') {
return { result: AuthorizeResult.DENY };
}
return { result: AuthorizeResult.ALLOW };
Validation
# 1. Owner can update their own component (expect 200/allow)
curl -s -o /dev/null -w "%{http_code}\n" -X POST \
https://${PORTAL_DOMAIN}/api/permission/authorize \
-H "Authorization: Bearer ${OWNER_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"items":[{"id":"1","permission":{"type":"resource","resourceType":"catalog-entity","name":"catalog.entity.update","attributes":{"action":"update"}},"resourceRef":"component:default/owned-svc"}]}'
# 2. Non-owner update on the same entity (expect DENY in body)
curl -s -X POST https://${PORTAL_DOMAIN}/api/permission/authorize \
-H "Authorization: Bearer ${OTHER_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"items":[{"id":"1","permission":{"type":"resource","resourceType":"catalog-entity","name":"catalog.entity.update","attributes":{"action":"update"}},"resourceRef":"component:default/owned-svc"}]}' \
| jq '.items[0].result'
# Expect: "DENY" or "CONDITIONAL" that filters this entity out
# 3. Confirm the backend loaded the custom policy, not allow-all
grep -R "ServiceOwnerPolicy" packages/backend/dist 2>/dev/null && echo "custom policy bundled"
Edge Cases & Troubleshooting
| Symptom | Root Cause | Resolution |
|---|---|---|
| Every request allowed regardless of owner | Default allow-all policy still registered | Confirm policy: new ServiceOwnerPolicy() is passed to createRouter |
| Owners denied on their own entities | ownershipEntityRefs empty on token |
Verify the IdP groups claim maps to catalog Group refs during sign-in |
| List views show entities the user cannot edit | Conditional decision applied only to single checks | Ensure the policy returns the conditional decision so list queries are filtered |
403 on read-only catalog browsing |
Fallthrough switched to DENY too broadly | Gate the DENY on attributes.action !== 'read' |
| Policy compiles but ignores deletes | Missing catalogEntityDeletePermission branch |
Add the delete permission to the isPermission check |
Frequently Asked Questions
Why use a conditional decision instead of fetching the entity and checking the owner?
A conditional decision pushes the ownership filter into the catalog’s database query, so it works identically for a single authorization check and for filtering a list of hundreds of entities. Fetching and checking manually would not scale to list endpoints and would duplicate logic the catalog already implements.
How do I let a platform team bypass ownership scoping?
Add an early branch that returns ALLOW when ownershipRefs includes a designated platform group ref, before the ownership-scoped block. Keep that group small and audited, since it is an explicit escalation above least privilege.