Browse narratives

EN · · 3 min read

RBAC Is Simple Until Your Product Isn't

How roles become an authorization system involving tenants, resources, context, explanation, and auditability.

Deep DiveIntermediateEvolving

RBAC begins with an attractive sentence: users have roles, and roles have permissions. The model is easy to explain and often sufficient for a small system. Complexity arrives when a product asks where the role applies, which resource is involved, what relationship connects the actor to it, and why the decision was made.

A permission needs a scope

“Editor” is not a complete authorization statement in a multi-tenant product. An actor may edit documents in one organization, view another as a guest, and hold no membership elsewhere. Treating the role as a global claim risks leaking authority across tenant boundaries.

A decision needs at least an actor, action, resource, and scope:

Can(
  actor = user:42,
  action = document:update,
  resource = document:913,
  tenant = organization:7
)

The role assignment is evidence used by the decision, not the decision itself.

Role multiplication is a warning

Products often encode exceptions by adding roles: Editor, EditorWithoutPublish, RegionalEditor, and TemporaryRegionalEditor. This preserves the RBAC shape while moving policy complexity into names.

Before adding another role, ask whether the rule actually describes an attribute, relationship, resource state, or temporary condition. A role should group stable responsibility. It should not become a serialized conditional.

Combine models at explicit boundaries

RBAC, attribute-based access control, and relationship-based access control are not mutually exclusive identities. A system can use roles for broad capability, attributes for contextual constraints, and relationships for resource-specific authority.

flowchart LR Request["Actor, action, resource, tenant"] --> Role["Role capability"] Role --> Attribute["Context constraints"] Attribute --> Relation["Resource relationship"] Relation --> Decision["Allow or deny with reason"]

Composition needs defined precedence. A deny rule, missing tenant membership, or unavailable relationship data must have deliberate semantics.

The decision should be explainable

A boolean is enough for enforcement and too little for operation. Support teams, auditors, and engineers need to know which policy version ran, which facts were used, and which condition failed. The explanation must avoid leaking sensitive policy or resource data to an unauthorized caller.

public sealed record AuthorizationDecision(
    bool Allowed,
    string ReasonCode,
    string PolicyVersion);

The public response may remain 403 Forbidden; structured reason data can stay inside trusted telemetry and audit boundaries.

Audit is not ordinary logging

Application logs help diagnose behavior. An authorization audit trail answers who attempted which action, against what scope, when, under which policy, and with what result. It needs retention, integrity, access control, and privacy decisions of its own.

Recording every raw input is not automatically safer. Minimize sensitive data, preserve stable identifiers, and keep the audit event coupled to the decision without making the authorization path depend on an unreliable sink.

Failure semantics are part of security

What happens when policy data is stale, the relationship store is unavailable, or tenant context is missing? “Fail closed” sounds simple, but even denial has operational consequences. Define which reads may use bounded caches, how policy versions propagate, and which signals distinguish a legitimate denial from an evaluation failure.

RBAC remains useful. The mistake is asking roles to carry every dimension of a product. A durable authorization system keeps roles understandable while making scope, context, relationships, explanation, and audit explicit.