Deep dive
Decision table
| Situation | Choice | Why |
|---|---|---|
| Internal tool, few fixed roles | RBAC in code (enum of roles → permissions) | Easy to reason about and test |
| B2B SaaS | Tenant isolation (RLS) + per-tenant RBAC | Tenant leaks are the worst failure |
| Docs/folders/projects shared with users and groups | ReBAC: SpiceDB, OpenFGA, or a relation table | Inheritance and sharing are graph queries |
| Rules on context (region, clearance, time, device posture) | ABAC with a policy engine (Cedar, OPA/Rego) | Policies outside code, testable |
| Many services need the same decisions | Central policy service or embedded engine with shared policy bundle | One source of truth, consistent audit |
| Customer-defined roles | RBAC with roles as data (role → permission rows) | Customers edit roles without deploys |
How it works
Parts:
- policy function —
allowed(principal, action, resource, context) -> bool, the one place rules live - principal — user or service with tenant, roles, attributes
- resource — object with owner, tenant, sharing metadata
- rule store — role table, attribute policies or relationship tuples
- audit log — denials and permission changes
- Load the resource (or its ownership/tenant metadata).
- Evaluate tenant match, then role/relationship/attributes, then explicit denies.
- Deny unless a rule allows; log the denial with principal, action, resource ID.
request -> handler -> load resource -> allowed(p, a, r, ctx) -> allow: act
| deny: 404/403 + log
rules: roles | attributes | tuples
Zanzibar-style ReBAC stores tuples object#relation@subject (doc:42#editor@group:eng#member) and rewrites (editor implies viewer; folder viewer implies doc viewer). Checks are graph walks with caching; a consistency token ("zookie") prevents the new-enemy problem (stale ACL applied to new content).
Code
Rust: a single policy function with typed actions (no stringly permissions scattered in handlers).
#[derive(Clone, Copy, PartialEq)]
pub enum Role { Viewer, Editor, Admin }
#[derive(Clone, Copy)]
pub enum Action { Read, Update, Delete, Share }
pub struct Principal { pub user_id: u64, pub tenant_id: u64, pub role: Role }
pub struct Doc { pub id: u64, pub tenant_id: u64, pub owner_id: u64, pub shared_with: Vec<u64> }
pub fn allowed(p: &Principal, a: Action, d: &Doc) -> bool {
if p.tenant_id != d.tenant_id {
return false; // tenant boundary first, regardless of role
}
let owner = d.owner_id == p.user_id;
let shared = d.shared_with.contains(&p.user_id);
match a {
Action::Read => owner || shared || matches!(p.role, Role::Editor | Role::Admin),
Action::Update => owner || (shared && p.role != Role::Viewer) || p.role == Role::Admin,
Action::Delete | Action::Share => owner || p.role == Role::Admin,
}
}
// In a handler: load, check, then act. Return 404 (not 403) to avoid confirming existence.
pub fn get_doc(p: &Principal, id: u64, load: impl Fn(u64) -> Option<Doc>) -> Result<Doc, u16> {
let d = load(id).ok_or(404u16)?;
if !allowed(p, Action::Read, &d) { return Err(404); }
Ok(d)
}
Postgres row-level security as a second line of defense for tenancy:
ALTER TABLE docs ENABLE ROW LEVEL SECURITY;
ALTER TABLE docs FORCE ROW LEVEL SECURITY; -- applies to the table owner too
CREATE POLICY tenant_isolation ON docs
USING (tenant_id = current_setting('app.tenant_id')::bigint)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::bigint);
-- per request, inside the transaction:
SET LOCAL app.tenant_id = '17';
The app must connect as a role without BYPASSRLS and not a superuser.
Cedar policy (ABAC/RBAC mix):
permit(principal, action == Action::"update", resource)
when { resource.owner == principal || principal in resource.editors };
forbid(principal, action, resource) when { resource.tenant != principal.tenant };
Efficiency
- Cost: a check is a few comparisons in code; ReBAC engines add lookups. The Zanzibar paper reports 95th-percentile latency under 10 ms at millions of checks per second. Bound: latency of the data a check needs, not policy logic.
- Levers:
- Load the resource once and pass it to the policy — one fetch instead of several — every handler.
- Push the filter into the query (
WHERE, RLS, lookup-resources) — one query instead of N checks — list endpoints. - Bulk checks (SpiceDB, OpenFGA) — one call per page of objects — ReBAC services.
- Consistency-token caching (zookie) — the Zanzibar result above — large graphs.
- Index
tenant_idfirst in composite indexes — keeps RLS predicates indexable — multi-tenant tables.
- Naive → standard → state of the art:
- Naive · per-row check calls, fetch-then-filter in memory
- Standard · in-process role enum plus
tenant_idcheck, filters in SQL - State of the art · cached, batched ReBAC service (Zanzibar-style) or embedded Cedar, whose paper reports better evaluation performance than OpenFGA and Rego
Implementation notes
- Enforce in two places: the policy check in the service layer, and tenant scoping in the data layer (RLS or a query builder that always adds
tenant_id). - List endpoints: filter in the query (
WHEREderived from the policy, or a ReBAC "lookup resources" call), never fetch everything and filter in memory. - Test the policy as a table: principals × actions × resources → expected result, including cross-tenant cases.
- Log every denial and every permission change; access reviews need them.
- Version policies like code: reviewed, tested, deployed through CI.
Pitfalls
- IDOR/BOLA:
GET /invoices/:idchecks login but not that the invoice belongs to the caller. The top API vulnerability (OWASP API1). - Checks in the UI only (hidden buttons) and not on the server.
- Filtering list endpoints differently from detail endpoints; search, export and GraphQL resolvers often skip checks.
- Trusting client-sent tenant IDs or roles (from body, or unverified JWT claims).
- Role checks like
role == "admin"spread over 200 handlers: impossible to audit. Centralize. - Caching decisions without invalidation on role change.
- Mass assignment: user updates
{"role":"admin"}through a generic update endpoint. - RLS with
SETinstead ofSET LOCALon pooled connections: tenant ID leaks to the next request.
In production
- Media: Netflix enforces access control in microservices with OPA (OPA adopters); Google runs Zanzibar-style ReBAC for Drive, Photos, YouTube and Maps (Zanzibar paper).
- Finance: BNY Mellon uses OPA as a sidecar for application access control based on Active Directory context (OPA adopters).
- Telecom: VodafoneZiggo uses OPA for authorisation decisions in its internal developer platform (OPA adopters).
- Cloud infra and IoT: Canonical uses OpenFGA as the permission backend for Juju, LXD and Ubuntu Pro; Mapped uses it for IoT identity authorizations (OpenFGA adopters).
Implementations
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
| cedar-policy | Rust | cedar-policy/cedar | cargo add cedar-policy | — | — | — | — | embedded RBAC/ABAC-style policy language with a validator; CLI: cargo install cedar-policy-cli |
| casbin | Rust | apache/casbin-rs | cargo add casbin | — | — | — | — | ACL, RBAC, ABAC models from config files |
| OPA | Go | open-policy-agent/opa | brew install opa | — | — | — | — | Rego policies; sidecar, library or server |
| SpiceDB | Go | authzed/spicedb | brew install spicedb | — | — | — | — | Zanzibar-style ReBAC service |
| OpenFGA | Go | openfga/openfga | brew install openfga | — | — | — | — | Zanzibar-style ReBAC service (CNCF) |
Sources
- Pang et al., Zanzibar: Google's Consistent, Global Authorization System, USENIX ATC 2019. paper
- Sandhu, Coyne, Feinstein, Youman, Role-Based Access Control Models, IEEE Computer, 1996. paper
- Hu et al., NIST SP 800-162: Guide to Attribute Based Access Control (ABAC) Definition and Considerations, 2014. spec
- OWASP, API Security Top 10, 2023. doc; Authorization Cheat Sheet. doc
- PostgreSQL documentation, Row Security Policies. docs
- Cutler et al., Cedar: A New Language for Expressive, Fast, Safe, and Analyzable Authorization, 2024. paper · code
- OPA project, Adopters. list; OpenFGA, Adopters. list