-
Pick the model by the shape of the access rule — do not default to RBAC for everything.
| Model | Decide by | Use when | Engine fit |
|---|
| RBAC | role → permission set | Fixed, coarse tiers (admin/editor/viewer); permissions don't depend on the specific row | DB tables, Casbin, Cedar |
| ABAC | attributes of subject+resource+context | Rules vary by field/status/time/region (owner.dept == doc.dept AND time < embargo) | OPA/Rego, Cedar |
| ReBAC | relationship/ownership graph | Per-resource sharing, nesting (folder→doc), "users this owner invited" — Drive/GitHub-style | OpenFGA / SpiceDB (Zanzibar), Oso |
Default: start RBAC for app-wide roles, add ReBAC the moment you need per-resource sharing or hierarchy, add ABAC conditions for field/context rules. They compose — RBAC roles can be relations in a ReBAC graph. Don't roll a bespoke nested-if engine; pick one of the named tools.
-
Separate authN from authZ — the decision is its own layer. AuthN hands you a verified principal ({user_id, tenant_id, roles} from the validated token — see auth-jwt-session). AuthZ takes (principal, action, resource) → allow|deny. Never re-derive identity inside the policy, and never let the policy trust unverified claims.
-
Centralize the decision behind one authorize() call — never inline if role ==. Every protected operation calls the same checkpoint; scattered checks drift and leak.
def authorize(principal, action, resource):
decision = engine.check(
subject=principal.user_id,
tenant=principal.tenant_id,
action=action,
resource=resource,
)
if not decision.allow:
raise Forbidden(action, resource.id)
return decision
-
Enforce multi-tenant isolation on every query — and derive tenant_id from the token, never the client. A client-supplied tenant/org id is an attacker-controlled cross-tenant key. Scope every read/write by the token's tenant; treat a missing tenant scope as a bug, not a default-all.
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
ALTER TABLE documents FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON documents
USING (tenant_id = current_setting('app.tenant_id')::uuid);
Set app.tenant_id per request/connection from the verified token (SET LOCAL app.tenant_id = ... inside the request transaction). App-layer WHERE tenant_id = $1 is the primary guard; RLS is the backstop for the day someone forgets it.
-
Deny by default, least privilege, deny wins. No matching allow rule ⇒ deny. Start every role at zero permissions and add. When allow and deny rules overlap, explicit deny beats allow. Write this into the policy, don't rely on convention.
-
Make policy versioned, code-reviewed, and unit-tested — policy-as-code. Keep .rego / Cedar / policy.polar in the repo, PR-reviewed like app code. Example OPA/Rego with the three non-negotiables baked in:
package authz
default allow := false # deny by default
default deny := false # bare `deny` is always defined
allow if { # owner can do anything to own resource
input.resource.tenant_id == input.principal.tenant_id # same-tenant gate, always
input.resource.owner_id == input.principal.user_id
}
allow if { # role grants the action
input.resource.tenant_id == input.principal.tenant_id
some role in input.principal.roles
grants[role][_] == input.action # e.g. grants.editor[] = "document:edit"
}
deny if input.resource.status == "locked" # explicit deny condition
final_allow := allow and not deny # deny wins over any allow
Wire the API to read final_allow, not allow. Run opa test policy/ -v in CI. The same input schema feeds both the running engine and the tests.
-
Pass the decision an explicit resource snapshot, fetched tenant-scoped first. Load the resource (already filtered by tenant in the query) before checking, so the policy sees real owner_id/status/tenant_id. Checking by id alone, then fetching unscoped, reintroduces the IDOR.
-
Verify with an allow/deny matrix per role × action — including explicit cross-tenant denial (see Verify) before shipping.
Done = the role × action matrix passes in CI, cross-tenant and IDOR probes are denied at both the API and DB tier (RLS enforced), the policy is versioned with default allow := false and deny-wins (API reads final_allow), and grep finds no authorization logic outside the centralized layer.