Authentication answers which principal is making a request. Authorization begins after that answer: may this principal perform this action on this resource under the current policy and context? Conflating the two leads to checks such as “the user is logged in” guarding operations that should depend on tenant, ownership, delegation, data sensitivity, or resource ancestry.
RBAC, ABAC, and ReBAC are not competing acronyms from which every system must select one winner. They are policy-modeling tools. Role-based access control summarizes organizational responsibility, attribute-based access control evaluates properties and context, and relationship-based access control follows facts between subjects and resources. Most substantial products need a deliberate composition, with one decision boundary and explicit conflict semantics.
This article builds that boundary for a multi-tenant document service. It focuses on policy representation, evaluation, caching, revocation, auditability, and tests. How a session or token establishes the principal is outside the scope; the authorization engine treats authenticated identity and assurance claims as untrusted inputs until the application validates and normalizes them.
Define the Decision Contract First
Before choosing a model, enumerate protected resource types and actions. Avoid a generic access permission. The document service has actions such as workspace.manage, folder.create_document, document.read, document.edit, document.share, and document.delete. Each action has different consequences and may need different policy.
Every enforcement point should call one logical interface:
type AuthorizationRequest = Readonly<{
subject: { type: 'user' | 'service'; id: string; tenantId: string };
action: string;
resource: { type: string; id: string; tenantId: string };
context: {
requestTime: string;
networkZone: 'trusted' | 'external';
assurance: 'standard' | 'elevated';
};
}>;
type AuthorizationDecision = Readonly<{
allowed: boolean;
reasonCode: string;
policyVersion: string;
dependencyRevision: string;
}>;
The real implementation may return internal explanation data separately, but the core result is allow or deny under one policy version and one view of policy data. Default deny applies when the action is unknown, required data is missing, a resource cannot be resolved, or evaluation exceeds its deadline. A transient policy-store error is not evidence of permission.
Put the check beside the operation that has authority to commit the effect. A UI hiding a button is useful feedback, not enforcement. An API gateway can reject broad classes, but a document service still needs the loaded document’s tenant, classification, and ancestry before deciding. Background workers and administrative tools need the same contract; bypasses named “internal” become durable privilege paths.
State invariants above model details. For example: no relation or role crosses a tenant boundary unless an explicit external-sharing object allows it; only current workspace members can inherit workspace roles; document.delete requires an administrator role and elevated assurance; a legal hold denies deletion regardless of grants. These invariants guide model composition and tests better than a list of scattered conditionals.
Use RBAC for Stable Responsibilities
RBAC assigns permissions to roles and roles to subjects, usually within a scope. In the document service, workspace_admin grants workspace management and document administration, editor grants creation and editing, and viewer grants reading. A role assignment must include its tenant or workspace:
(user:alice, role:editor, workspace:acme-design)
This is compact and auditable. Administrators understand role assignment, reviews can list everyone with a privileged role, and permission changes can be made centrally. A role hierarchy may let workspace_admin include editor, which includes viewer, but keep hierarchy shallow and explicit. Deep inheritance makes effective access difficult to explain.
Model permissions as stable operation identifiers, not route names or UI labels. Several endpoints may perform document.read, while one endpoint may perform both document.edit and document.share. Map operations to policy at the service boundary. Changing an HTTP path then does not silently change the security model.
RBAC becomes strained when every exception creates a role. Roles such as editor_for_finance_documents_on_weekdays mix responsibility, resource selection, and context. Per-project or per-document roles can produce thousands of nearly identical assignments. This “role explosion” is evidence that attributes or relationships are carrying information the role model cannot express naturally.
Separation of duties also needs more than hierarchy. A policy can forbid one subject from holding both invoice_creator and invoice_approver, or require two distinct approvals for a sensitive action. Decide whether the constraint applies when assigning roles or when authorizing an operation. Assignment-time checks keep invalid states out; decision-time checks account for changing workflow facts. High-risk systems may need both.
RBAC’s failure mode is often excessive breadth. Granting a support role at the tenant level because one agent needs one document creates ambient authority. Scope every assignment as narrowly as operations permit, make privileged grants expire when possible, and review actual effective permissions rather than role names alone.
Use ABAC for Properties and Request Context
ABAC evaluates attributes of the subject, resource, action, and environment. A confidential document policy might require the subject’s department to equal the document’s owning department, the device assurance to be elevated, and the request to originate from an allowed network zone. Attributes express conditions without inventing a role for every combination.
Good attributes have an owner, type, allowed values, freshness rule, and meaning. subject.department may come from a workforce directory; resource.classification belongs to the document service; context.assurance comes from a validated request-security layer. A free-form map assembled by clients is not policy data. Normalize attributes at trusted adapters and reject missing or malformed values.
ABAC policies can look deceptively readable:
allow document.read when
subject.tenant_id == resource.tenant_id
and subject.employment_status == "active"
and resource.classification in subject.clearances
and context.network_zone in resource.allowed_network_zones
The hard questions are semantic. What happens when allowed_network_zones is absent? Are clearance names ordered or merely sets? Which clock defines an expiring assignment? Can a service subject have an employment status? Define those answers in typed policy schemas. Missing information should normally fail closed, but the reason must remain distinguishable from an explicit policy denial for operations.
Combining rules needs a stated algorithm. “Any allow wins” can let a broad rule override a specific restriction. “Deny overrides” sounds safer but can make one stale deny disable an entire tenant. A useful structure is to evaluate non-negotiable constraints first, then require at least one grant path. For example, tenant equality, active membership, and legal-hold restrictions are constraints; role permission or a sharing relation supplies the grant.
ABAC’s costs are data dependencies and explainability. A decision can require directory, device, resource, and tenant settings. Fetching them synchronously on every request increases latency and failure surface; caching them delays revocation. Denormalized attributes improve availability only if ownership, propagation lag, and stale-decision behavior are explicit.
Use ReBAC for Sharing and Resource Graphs
ReBAC represents authorization-relevant relationships as tuples and derives access by traversing them. A few document-service tuples might be:
workspace:acme#member@user:alice
workspace:acme#editor@user:alice
folder:roadmap#parent@workspace:acme
document:q3-plan#parent@folder:roadmap
document:q3-plan#owner@user:bob
folder:roadmap#viewer@group:contractors
group:contractors#member@user:carol
The relation names are part of a schema. document.read might be granted to a document owner, a direct viewer, or a viewer of its parent folder; a group viewer expands through group membership. This naturally models collaboration, nested folders, teams, delegation, and sharing links without creating one role per resource.
A relation is not automatically a permission. Schema rules define rewrites such as:
document.viewer = document.viewer
or document.owner
or document.parent.viewer
folder.viewer = folder.viewer
or folder.editor
or folder.parent.viewer
group.member = group.member
or group.nested_group.member
Evaluation walks only allowed relation paths and must enforce tenant constraints at tuple creation and traversal. A malformed parent edge from tenant A to tenant B can otherwise turn inheritance into a cross-tenant leak. Validate object types, relation types, and tenant ownership transactionally when writing tuples.
Graphs introduce cycles, depth, and fan-out. Nested groups can refer to each other; folders can be corrupted into loops; a popular public resource can have millions of viewers. The evaluator needs cycle detection, depth and work limits, indexed reverse lookups, and a policy for results that exceed bounds. Denying with an operational reason is safer than timing out ambiguously.
ReBAC also complicates listing. Checking whether Alice can read one known document is a forward decision. Finding every document Alice can read is a reverse expansion problem that may require indexes, precomputed memberships, or filtered search integration. Design check, list resources, and list subjects as separate workloads. A fast point-check model does not guarantee an efficient permissions-aware search page.
Relationship changes make revocation intuitive: delete the viewer tuple or group membership. Effective revocation still depends on tuple-store replication, caches, already issued download URLs, and downstream copies. The graph gives a precise source fact, not instantaneous erasure from every system.
Compose the Models in One Worked Policy
Consider Carol, an external contractor in tenant acme, requesting document.read for q3-plan. The document is under folder roadmap, classified internal, and not under legal hold. Carol has no workspace-wide viewer role, but group:contractors is a viewer of the folder and Carol is a group member. Tenant policy permits contractors to read internal documents only from a trusted network.
The decision pipeline is:
- Load canonical subject, document, tenant, and context data.
- Enforce the tenant invariant and active workspace membership.
- Enforce ABAC constraints for classification and network zone.
- Search for an RBAC permission or ReBAC relation that grants
document.read. - Return a versioned decision with the dependencies used.
On a trusted network, the ABAC constraints pass. The RBAC path fails, but the ReBAC path Carol -> contractors -> roadmap -> q3-plan succeeds, so the request is allowed. On an external network, the same relationship exists but the contextual constraint denies access. If Carol’s group membership is removed, the relationship grant disappears. If she later receives a workspace viewer role, RBAC supplies an independent grant while the same classification constraint still applies.
Now consider document.delete. The policy requires a scoped workspace_admin role, elevated assurance, no legal hold, and current tenant membership. Ownership or a folder editor relation does not grant deletion. Reusing the read rule and adding a UI confirmation would be privilege escalation. Actions deserve separate grant paths even when they target the same resource.
This composition is easier to reason about as constraints AND grants than as a flat list of allow and deny rules. It also gives useful explanations: DENY_CONTEXT_NETWORK, DENY_LEGAL_HOLD, ALLOW_ROLE_WORKSPACE_ADMIN, or ALLOW_RELATION_FOLDER_VIEWER. Return only safe, coarse reasons to callers; retain richer paths in protected decision logs.
Preserve Tenant Boundaries and Policy Ownership
Tenant isolation is a mandatory constraint, not an ordinary role. Every role assignment, attribute record, relationship tuple, resource lookup, cache key, and audit record must carry tenant scope. Derive the subject tenant from trusted request context and the resource tenant from canonical storage. Never accept both from client input and compare those two untrusted values.
Cross-tenant collaboration needs an explicit object, such as an invitation that joins an external principal to a tenant with a bounded relation and expiry. Do not make the tenant equality check optional whenever an email address matches. The invitation lifecycle should define acceptance, sponsor, scope, review, and revocation. Global support access, if required, should use a separate privileged path with strong approval and auditing rather than synthetic membership in every tenant.
Assign ownership for each policy fact:
| Fact | Owning system | Revocation trigger |
|---|---|---|
| Workspace role | Workspace administration | Role removal or expiry |
| Employment status | Workforce directory | Departure or suspension |
| Document classification | Document service | Reclassification |
| Group membership | Collaboration directory | Membership removal |
| Network zone | Request-security layer | Request ends |
| Legal hold | Compliance service | Hold release |
Ownership prevents one service from inventing convenient attributes. It also determines audit responsibility and propagation. If a document service keeps a local copy of employment status, it needs a freshness contract and a way to process suspension events. Security-sensitive denials may need a fail-closed maximum staleness shorter than ordinary grants.
Policy changes are deployments. Version schemas and rule sets, validate compatibility with stored tuples and roles, and support mixed application versions during rollout. An old writer must not create a relation that the new evaluator interprets more broadly. Use staged migration: add new relation types, dual-read or shadow-evaluate, migrate facts, switch enforcement, then retire old semantics.
Cache Decisions Without Hiding Revocation
Authorization checks often sit on hot request paths, so caching is attractive. The cache key must include subject, action, resource, tenant, policy version, and every contextual dimension that can alter the result. Omitting network zone from the worked policy would reuse a trusted-network allow externally. Caching by role alone ignores resource relationships and attributes.
Cache dependencies, not just a Boolean. The Carol decision depends on her active membership, contractor-group edge, folder-viewer edge, document parent, classification, tenant rule, and policy version. A dependency revision or consistency token lets the evaluator know which snapshot produced the answer. Event-driven invalidation can target affected entries, while a bounded time-to-live covers missed events. Neither alone promises immediate revocation.
Negative caching can reduce repeated graph misses, but a newly granted permission may remain invisible until expiry. Positive caching delays revocation, which is usually riskier. Choose different lifetimes by action: a short read cache may be acceptable, while document.delete can always evaluate strongly against current policy data. For a multi-step sensitive workflow, reauthorize at commit rather than trusting an allow recorded when the form opened.
Consistency is a product and threat-model choice. A replicated tuple store may offer a token after writes; subsequent checks that include the token can wait until that revision is visible. This provides read-your-writes behavior for grant and revoke operations without forcing every global check through one leader. Define what happens when the required revision cannot be reached before the deadline.
Revocation extends beyond the decision engine. Terminate or constrain active sessions according to product policy, expire signed URLs quickly, stop background jobs before effects, invalidate application caches, and prevent stale replicas from issuing new capabilities. Authorization cannot retract bytes already downloaded or erase an email already sent. Highly sensitive exports may require watermarking, audit, or workflow approval because read permission is not data-loss prevention.
Cache telemetry should expose hit rate, entry age, policy version, invalidation lag, and strong-read fallback without putting subject or resource IDs in low-cardinality metric labels. During incidents, an operator must be able to disable caches or force a minimum revision for one tenant without turning every request into an uncontrolled load spike.
Explain, Test, and Operate Every Decision
A decision log should record request time, normalized subject and resource references, tenant, action, allow or deny, safe reason code, policy version, dependency revision, enforcement point, and correlation ID. Sensitive attributes and full relationship paths may require redaction or restricted storage. Log the decision at the point that commits the operation so audit history does not contain approvals for effects that were never attempted without a linking outcome.
Explanations serve different audiences. A caller may receive “not permitted” to avoid exposing resource existence. An administrator may see which role or share grants access. A security investigator may need the evaluated policy version and complete relation path. Build explanations from evaluator evidence, not a second hand-written explanation engine that can disagree with enforcement.
Turn the policy into a matrix before release. For the document service, vary:
- subject type, tenant membership, role, group membership, and suspension state;
- document tenant, classification, parent relation, ownership, and legal hold;
- action, network zone, assurance, and policy version;
- missing attributes, stale revisions, cycles, excessive graph depth, and store failure.
Assert both the decision and stable reason class. Add invariant tests that generate combinations: no cross-tenant request is allowed without an explicit external relation; legal hold always prevents deletion; ownership alone never grants tenant administration; unknown actions deny. Mutation tests are especially valuable for policy because they prove a test fails if AND becomes OR or a constraint is removed.
Integration tests should exercise every enforcement surface: API reads and writes, bulk endpoints, search results, exports, background workers, websocket subscriptions, object downloads, and administrator impersonation if supported. Test time-of-check/time-of-use races by revoking access after a preview but before commit. Verify list APIs do not reveal names or counts for resources that point checks would deny.
Shadow evaluation enables policy migration. Run the candidate evaluator without enforcing it, compare decisions by action and tenant, inspect every new allow, and explain intended differences. Roll out gradually with a kill switch and preserve the old policy data until rollback is safe. Alert on deny spikes, unexpected new allows, evaluation errors, stale-revision failures, graph-limit exhaustion, and latency by action.
Runbooks should cover revoking one subject, isolating a tenant, rolling back policy, rebuilding relationship indexes, responding to a stale cache, and investigating an unexplained grant. Periodic access reviews should ask who can perform high-impact actions and why, not merely list direct role assignments. Effective access includes inherited roles, attributes, groups, parent relationships, and temporary exceptions.
The durable design is one authorization contract with explicit constraints and grant paths. RBAC captures stable responsibility, ABAC captures properties and context, and ReBAC captures sharing and ancestry. Their composition is trustworthy only when tenant scope, policy ownership, consistency, cache behavior, explanations, and failure semantics are part of the model. Authorization is not a collection of scattered if statements; it is executable policy whose decisions must remain bounded, reviewable, and testable as the product changes.