Skip to content

Scoped RBAC

Sometimes tenant-wide roles are too coarse: a user should be Branch Manager in the Cape Town branch but only Viewer in Johannesburg. Scopes solve this without IAM needing to know what a “branch” is.

A scope is (scope_type, external_scope_id) inside a tenant — ("branch", "cpt-001"), ("portfolio", "growth-fund"), ("project", "apollo"). Your domain defines the meaning; IAM only enforces the boundary.

Terminal window
curl -X POST localhost:8080/api/iam/v1/scopes \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"scopeType": "branch", "externalScopeId": "cpt-001", "name": "Cape Town"}'

A role assignment with no scope is tenant-wide (fully backward compatible). With a scope, it applies only there:

Terminal window
curl -X POST "localhost:8080/api/iam/v1/users/$USER/roles/$ROLE/scopes/$SCOPE" \
-H "Authorization: Bearer $TOKEN"

The same (user, role) pair can exist in multiple scopes; duplicates within one scope are rejected.

Pass the scope when it matters — on the annotation via scopeParam, or programmatically:

@GetMapping("/branches/{branchScopeId}/loans")
@RequiresPermission(resource = "loans", action = "read", scopeParam = "branchScopeId")
fun loansInBranch(@PathVariable branchScopeId: UUID): List<LoanDto> = ...

Resolution merges tenant-wide assignments with the requested scope’s assignments — a tenant-wide DENY still beats a scoped ALLOW. Scoped resolutions are cached per (tenant, user, scope).

IAM proves role-in-scope; YOUR endpoint must prove resource-in-scope

Section titled “IAM proves role-in-scope; YOUR endpoint must prove resource-in-scope”

This is the one thing to get right about scopes. scopeParam makes IAM prove the caller holds the role IN the scope they present — a grant boundary. It does not prove that the resource the request targets actually belongs to that scope. IAM cannot: external_scope_id is your domain’s vocabulary, opaque to the library.

A scope is a grant boundary, not a row filter.

Concretely, with @RequiresPermission(resource="loans", action="approve", scopeParam="branchScopeId"): a branch-scoped approver presenting their own branch id passes the IAM check for any loan id in the path — including a loan in a different branch. Binding the requested resource to the scope is host work, and it must run in your handler (or a policy it calls):

  1. Resolve the presented scope to your domain key — scopeService.findById(branchScopeId) gives you back the externalScopeId (“cpt-001”).
  2. Confirm the requested resource sits inside it — e.g. this loan’s branch == that external id.
  3. If not, answer 404 — the scoped view hides existence rather than confirming the resource lives in another scope (a 403 would leak that it exists elsewhere).

The school-platform reference does exactly this with a ScopedPersonReadPolicy behind a small host port: a teacher scoped to school A reading a school-B student gets 404; a guardian reading a stranger through her own child’s scope gets 404. The kotlin-loan-app example ships the same shape as ScopedLoanApprovalPolicy. See the Scoped Approval recipe for the full walkthrough.

Without this host-side check, every scoped grant behaves scope-type-wide the moment the caller presents any scope they legitimately hold.

Deleting a scope cascades its role assignments away (ON DELETE CASCADE) — no orphaned grants. Deactivate instead (is_active: false) to suspend a boundary without losing its assignment history.