When RBAC Stops Being Enough: Role + Unit + Context Without Role Explosion
Separate capability, organizational scope, and runtime context in a practical authorization model, with interactive diagrams, a role simulator, and policy traces.
In this essay
RBAC is clean until the same capability is valid in one organizational scope, invalid in another, temporary somewhere else, and still constrained by the state of the resource. The design problem is not “how many roles do I need?” It is “which dimensions actually belong to the role?”
Sanitized case study: every role name, unit label, identifier, token value, workflow state, and example in this article is synthetic. It contains no organization names, employee names, internal URLs, production IDs, proprietary endpoint names, or unpublished operational data.
Where flat RBAC reached its boundary
I started with conventional flat RBAC: User → Role → Permission. Roles were reusable capability profiles, permissions were never assigned directly to users, and a trusted identity source could map a person's job classification to one or more default roles.
The model stopped being sufficient when organizational boundaries became part of the decision. A user was no longer just a Supervisor; the role was valid inside a specific scope. The same person might hold a different role elsewhere, and even a correct role in a correct unit could still fail because of workflow state, ownership, or separation-of-duties rules.
Encoding those dimensions into increasingly specific role names would make the role catalogue represent the organization chart—not authorization.
Move context out of role identity
Compare encoded roles with scoped assignments. The permission model stays RBAC; organizational authority moves to the assignment.
The architectural change was to define three separate concepts:
Role = WHAT operations may be performed?
Scope = WHERE is that role assignment valid?
Context = WHAT runtime facts must also be true?
The user-to-role relationship therefore became an assignment with scope and lifecycle:
RoleAssignment(
principalId,
roleId,
scopeType,
scopeId,
source,
validFrom,
validTo,
status
)
A job classification remains useful for provisioning a default assignment, but it is not an authorization condition inside business services. That keeps HR taxonomy, authorization policy, and domain logic from collapsing into one another.
The authorization function I wanted the model to support
The important quantifier is “there exists one active assignment” that grants the operation and covers the resource scope.
authorize(p, op, r, ctx) =
validToken(ctx.token)
∧ ∃ a ∈ activeAssignments(p, ctx.time):
op ∈ permissions(a.role)
∧ covers(a.scope, scopeOf(r))
∧ runtimeRules(p, op, r, ctx)
ALLOW / DENY — a single explicit decision.
Rules the implementation must never violate
These are more useful than “best practices” because they can be reviewed, tested, and enforced.
- I1: Permissions are never assigned directly to principals.
- I2: Role and scope remain bound in the same assignment.
- I3: Client-provided unit identifiers are never authorization evidence.
- I4: Expired or revoked assignments never participate in a decision.
- I5: Unknown provisioning inputs produce no operational privilege.
- I6: Workflow and maker/checker constraints are evaluated on the real resource.
Why scope should not become role identity
This is a combinatorial upper bound, not a claim that every combination will exist. It shows how quickly the namespace can grow.
A second failure mode appears at the token boundary. If roles and scopes are emitted as independent arrays, downstream code can accidentally interpret them as a Cartesian product.
Preserve role ↔ scope binding
The first structure is ambiguous. The second retains exactly which role is valid in which scope.
Ambiguous claim shape
{
"roles": [
"SUPERVISOR",
"OPERATOR"
],
"scopes": [
"UNIT-A",
"UNIT-B"
]
}
Bound assignment shape
{
"roleAssignments": [
{
"role": "SUPERVISOR",
"scope": {
"type": "ORG_UNIT",
"id": "UNIT-A"
}
},
{
"role": "OPERATOR",
"scope": {
"type": "ORG_UNIT",
"id": "UNIT-B"
}
}
]
}
Once role and scope are explicit, the next question is architectural: where is the decision made? I use PEP/PDP/PIP as logical responsibilities, not as a claim that they must be separate deployable services.
PEP, PDP, and trusted policy information
The service may physically host more than one responsibility; the separation is conceptual so ownership remains clear.
PEP
Policy Enforcement Point
Intercepts the operation and enforces ALLOW or DENY. Gateway checks can be coarse; resource operations need a domain-aware enforcement point.
PDP
Policy Decision Point
Evaluates required permission, active role assignment, scope coverage, and runtime constraints.
PIP
Policy Information Point
Supplies authoritative facts such as the resource's real organizational owner, workflow state, creator, or delegation data.
Important boundary: request.unitId is input, not evidence. The protected resource must be resolved from an authoritative source before scope matching.
What belongs at each layer
Select each stage. This prevents a gateway from becoming a domain-policy engine and prevents services from trusting unverified client context.
The final decision is not “does the token contain APPROVE?” It is closer to:
| Check | Required predicate |
|---|---|
| Token | validToken |
| Capability | op ∈ permissions(assignment.role) |
| Scope | covers(assignment.scope, scopeOf(resource)) |
| State | workflowAllows(op, resource.state) |
| SoD | principal ≠ forbiddenActor(resource) |
// Never authorize against a client assertion
unsafe = request.unitId == token.scopeId
// Resolve the protected resource first
resource = repository.load(request.resourceId)
actualScope = scopeOf(resource)
allow =
validToken(token)
&& exists activeAssignment where
grants(activeAssignment.role, operation)
&& covers(activeAssignment.scope, actualScope)
&& runtimeRules(principal, operation, resource)
This is also the boundary where authorization stops being a static token problem and becomes a freshness problem. A signed JWT is a snapshot. If an assignment is revoked after token issuance, a purely self-contained token cannot learn that fact by itself.
A valid JWT can still contain stale authorization state
The acceptable revocation delay must be an explicit architecture decision.
- T0 — assignment active: Token issued with scoped role assignment.
- T1 — assignment revoked: Authoritative authorization state changes.
- T2 — old token presented: Signature and expiry may still be valid.
- T3 — freshness control: Short TTL, version check, introspection, or revocation-aware cache prevents stale privilege from surviving too long.
Possible controls
- Short-lived access tokens: bounded stale-access window.
- Authorization version: reject tokens older than the principal's current policy version.
- Introspection / central PDP: fresher decisions at the cost of runtime dependency.
- Revocation-aware cache: reduce repeated lookups while retaining invalidation semantics.
I would not hide this trade-off behind “JWT is stateless.” Stateless validation is an implementation property; revocation latency is a security requirement. The right mechanism depends on how quickly a privilege change must become effective and what runtime dependency the system can tolerate.
The same discipline applies to testing. Authorization rules are good candidates for deterministic automated tests because each invariant can be expressed as a small decision case.
Authorization trace: why was this request allowed or denied?
Choose a case. A production test suite should assert the decision and the failed predicate—not only the HTTP status.
I deliberately did not jump from RBAC straight to a fully general ABAC or relationship-graph model. The problem had stable functional capabilities, explicit organizational boundaries, and a limited set of runtime constraints. Scoped assignments solved that structure without forcing every policy into a generic language.
Why scoped RBAC was the right level of complexity
Use a richer model when the problem actually requires it—not because the vocabulary is newer.
Scoped RBAC
Good fit when capabilities are stable, organizational scope is explicit, and runtime constraints are limited and domain-specific.
ABAC
Becomes attractive when authorization depends on many independently varying subject, resource, and environmental attributes.
ReBAC
Becomes attractive when access is primarily a graph problem: ownership, delegation, membership, sharing, or nested relationships.
The core solution was not to make the role model more complicated. It was to move complexity to the dimensions where it actually belonged.
That keeps the role catalogue reusable, makes organizational transfers a data change instead of a code change, preserves exact role-to-scope semantics in tokens, gives the service an authoritative resource boundary, and turns the final policy into something that can be reasoned about and tested.
The design rule I kept
When an RBAC model starts producing roles that describe who, what, where, and when all at once, the answer is probably not another role. One of those dimensions belongs somewhere else.