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
The policy can only make an ownership decision if three inputs are present: the permission framework is on, every entity carries an owner, and the caller’s token carries the group refs that owner resolves to.
- 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
The policy’s logic is a three-way branch: a write action returns a conditional decision that filters to owned entities, a read is allowed, and anything unreasoned-about is denied. That conditional decision is the key — it pushes the ownership filter into the catalog query so one rule covers both single checks and list filtering.
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
Prove three properties: an owner can update their own entity, a non-owner is denied on the same entity, and the backend actually bundled your policy rather than the default allow-all.
# 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
The failures split into two buckets: the policy is too open (allow-all still registered, refs empty the wrong way) or too closed (DENY gated too broadly). The diagram sorts the symptoms so you know which direction to correct.
| 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.