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.
Scopes are generic boundaries
Section titled “Scopes are generic boundaries”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.
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"}'Assigning roles in a scope
Section titled “Assigning roles in a scope”A role assignment with no scope is tenant-wide (fully backward compatible). With a scope, it applies only there:
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.
Resolving scoped permissions
Section titled “Resolving scoped permissions”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):
- Resolve the presented scope to your domain key —
scopeService.findById(branchScopeId)gives you back theexternalScopeId(“cpt-001”). - Confirm the requested resource sits inside it — e.g. this loan’s branch == that external id.
- 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.
Lifecycle
Section titled “Lifecycle”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.