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.

Three inputs an ownership decision needs Permission framework enabled, catalog ownership populated, and ownership refs on the token enable the decision. permission.enabled spec.owner on entities ownershipEntityRefs Ownership decision possible owner ↔ caller refs
An empty ownership-refs claim is the usual reason owners get denied on entities they clearly own.
  1. Backstage >= 1.20.0 with @backstage/plugin-permission-backend >= 0.5.0 and @backstage/plugin-permission-node installed in packages/backend.
  2. Permission framework enabled in config (permission.enabled: true).
  3. Catalog ownership populated — every Component, API, and Resource has a spec.owner resolving to a Group the user can belong to.
  4. Identity with group membership, established by your OIDC & SSO Configuration so ownershipEntityRefs is present on the token.
  5. Node.js >= 20.x to 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.

Ownership-scoped permission decision A write returns a conditional owner-filter decision, a read is allowed, and unknown actions are denied. PolicyQuery action? write → conditional isEntityOwner filter read → ALLOW unknown → DENY Catalog filters by owner single check + list share it
Returning a conditional decision, not a fetched-and-checked boolean, is what makes list endpoints scale.

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.

Three properties to prove Owner allowed, non-owner denied on the same entity, and the custom policy is confirmed bundled. Owner → allow own component Non-owner → deny same entity Policy bundled not allow-all
The third check matters most: a policy that never loaded looks identical to a permissive one in casual testing.
# 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.

Policy failures: too open versus too closed Allow-all still registered and unfiltered lists are too-open failures; over-broad DENY and denied owners are too-closed failures. Too open allow-all registered → pass new policy list not filtered → conditional Too closed read denied → gate on action owner denied → fix refs claim
Decide open-versus-closed first; the fix is either registering the policy or narrowing the DENY.
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.