RBAC, ReBAC, and ABAC patterns
The three letter-soup acronyms RBAC, ReBAC, and ABAC name the three
standard styles of authorisation. Cedar is one of the few policy
languages that admits all three in the same set of rules. This
chapter walks through each style with worked examples, then shows
how to compose them in a single policy set without the rules
fighting each other. The examples are concrete enough that you
should be able to paste them into a .cedar file and have them
type-check against a corresponding schema.
RBAC: roles as groups
Role-based access control assigns users to roles and assigns permissions to roles. The model has been the workhorse of enterprise authorisation since the 1990s and remains the right starting point for most applications.
The schema declares roles and the action permissions they hold:
entity User {
tenant_id: String,
};
entity Role;
entity Document {
tenant_id: String,
owner: User,
};
action read appliesTo {
principal: [User],
resource: [Document],
};
action edit appliesTo {
principal: [User],
resource: [Document],
};
The policy grants the role-action mappings:
permit (
principal in Role::"viewer",
action == Action::"read",
resource
);
permit (
principal in Role::"editor",
action in [Action::"read", Action::"edit"],
resource
);
The entity provider, on each request, attaches the user's role
memberships as parent entities. A user in Role::"viewer" has
that role in their parents list; a user in Role::"editor" has
that role in their parents list and inherits read permission
through the second policy's action set.
The shape works for most applications until two situations arise. The first is when permissions need to depend on the relationship between the principal and the resource (a user can edit their own documents but not others'), which is the ReBAC case below. The second is when permissions need to depend on the request context (MFA must be present for sensitive actions), which is the ABAC case below.
ReBAC: relationships as paths
Relationship-based access control assigns permissions based on the relationship between the principal and the resource, not on a role label. The classic example is ownership: a user can edit a document they own.
The schema does not change much; the relationship is already on the entity:
entity Document {
tenant_id: String,
owner: User,
shared_with: Set<User>,
};
The policy expresses the relationship:
permit (
principal,
action == Action::"edit",
resource
) when {
resource.owner == principal
};
permit (
principal,
action == Action::"read",
resource
) when {
resource.owner == principal
|| principal in resource.shared_with
};
The first rule grants edit to the owner. The second rule grants
read to the owner or to anyone in the resource's shared_with
set. The set membership principal in resource.shared_with is the
ReBAC primitive: the principal is in some set on the resource, and
the policy matches on that.
More elaborate relationships involve multi-hop paths. Consider a "team" model where a user belongs to a team, the team owns projects, and the projects contain documents. The schema:
entity Team;
entity Project {
owner_team: Team,
};
entity Document {
project: Project,
};
entity User in [Team];
The policy that says "anyone in the team that owns the project that contains this document can read the document":
permit (
principal,
action == Action::"read",
resource
) when {
principal in resource.project.owner_team
};
The in operator follows the entity graph: resource.project
yields a Project entity, .owner_team yields a Team entity,
and principal in Team checks the principal's parents list. The
entity provider populates the graph: the document with its project
parent, the project with its owner_team attribute, the user with
their team memberships. Cedar walks the graph at evaluation time.
The pattern generalises to any depth, though policies that walk more than two or three hops start to feel hard to review. When the depth gets uncomfortable, extract the relationship into an intermediate entity (a "can_view" set on the document that the application's data layer computes ahead of time) and let the policy match on the simpler shape.
ABAC: attributes as conditions
Attribute-based access control adds context to the decision. The attributes might be on the principal (MFA status, last authentication time), on the resource (sensitivity level), or on the request (IP address, time of day). A policy applies only when the attributes match.
The schema declares the attribute shapes:
entity User {
tenant_id: String,
mfa_completed: Bool,
last_authn_at: Long, // unix seconds
};
entity Document {
tenant_id: String,
classification: String, // "public" | "internal" | "secret"
};
type Context = {
ip: String,
now: Long,
};
The policy combines attribute conditions:
permit (
principal,
action == Action::"read",
resource
) when {
principal.tenant_id == resource.tenant_id
&& (
resource.classification == "public"
|| (
resource.classification == "internal"
&& principal.mfa_completed
)
|| (
resource.classification == "secret"
&& principal.mfa_completed
&& context.now - principal.last_authn_at < 900 // last 15 min
)
)
};
The rule grants read access in three tiers: public documents to anyone in the tenant, internal documents to anyone in the tenant with MFA completed, secret documents to anyone in the tenant with MFA completed in the last fifteen minutes. The attributes drive the gradations; the policy expresses them in one statement.
ABAC is the right tool for time-sensitive, location-sensitive, and context-sensitive policies. It is the wrong tool for static permissions (use RBAC) or for relationship checks (use ReBAC). When in doubt, write the policy and read it back: if the rule says "users in X role can perform Y," it is RBAC; if it says "users with relationship Z to this resource can perform Y," it is ReBAC; if it says "users can perform Y when condition W," it is ABAC.
Composing the three styles
A real production policy set mixes the three. A user who has the
editor role (RBAC) can edit any document, but a user who owns a
document (ReBAC) can edit it regardless of role, and a user trying
to edit a secret document must have MFA completed (ABAC).
// RBAC layer: editors get full access.
permit (
principal in Role::"editor",
action in [Action::"read", Action::"edit", Action::"delete"],
resource
);
// ReBAC layer: owners get full access to their own.
permit (
principal,
action in [Action::"read", Action::"edit", Action::"delete"],
resource
) when {
resource.owner == principal
};
// ReBAC layer: shared-with users get read access.
permit (
principal,
action == Action::"read",
resource
) when {
principal in resource.shared_with
};
// ABAC layer: secret documents require fresh MFA, forbid otherwise.
forbid (
principal,
action,
resource
) when {
resource.classification == "secret"
&& (
!principal.mfa_completed
|| context.now - principal.last_authn_at > 900
)
};
The forbid rule overrides any permit that would otherwise
match. The structure works because Cedar evaluates all rules: if
any permit matches and no forbid matches, the decision is
Allow; if any forbid matches, the decision is Deny
regardless of what permits also match.
The pattern is to express the broad grants through permit rules
in increasing specificity (role, relationship, context), then to
express the absolute constraints through forbid rules. The
forbid rules are typically about high-sensitivity resources or
about high-risk principal states; they are the small set of cases
where a positive grant is not enough.
Tenant isolation as a structural rule
Multi-tenant applications need a structural rule that no policy
should ever leak data across tenants. The right shape is a single
top-level forbid:
forbid (
principal,
action,
resource
) when {
principal.tenant_id != resource.tenant_id
};
The rule applies to every action on every resource. Any later
permit that would have allowed a cross-tenant access is
overridden. The rule is the structural defence against the worst
class of authorisation bug a multi-tenant application can have: an
operator from tenant A accessing tenant B's data because of a
mistake in another policy.
The rule is also the right place to validate that the principal
has a tenant id at all. A workload principal might be in a global
trust domain (no tenant), in which case the comparison fails the
type system and the rule denies. The policy authoring style is to
treat tenant id as a required attribute on every multi-tenant
entity, and to let this forbid catch any drift.
Step-up as a policy concern
Step-up authentication is the pattern where a user is asked to re-prove identity (or to prove with a stronger factor) before performing a sensitive action. The mechanism is in the state machine (see Factors and methods §"Step-up authentication"); the policy expresses when step-up is required.
The shape:
forbid (
principal,
action == Action::"delete-account",
resource
) when {
!("Fido2" in principal.factors_completed)
};
The rule denies the account-deletion action unless FIDO2 is in the
user's completed factors. The user reaches the action with a
password-and-TOTP session, gets denied, and the application offers
step-up: the user completes the FIDO2 ceremony, the session's
factors_completed now includes Fido2, the next request to the
delete-account action passes the policy.
The pattern composes with the other styles. A permit rule says
who can delete an account (RBAC: the user themselves, ReBAC: the
admin who owns the user). The forbid rule adds the contextual
requirement (ABAC: FIDO2 in factors_completed). The three rules
together produce a policy that says "the user themselves can
delete their own account, but only after completing FIDO2 in this
session."
Anti-patterns
The two patterns most likely to mislead are worth naming.
The first is duplicating ReBAC as RBAC. The temptation is to
materialise the ownership relationship as a per-resource role
("owner of document 123"), then write an RBAC policy that grants
edit to the role. The shape works but produces an explosion of
roles (one per resource), is hard to invalidate when ownership
changes, and obscures the relationship that the policy is actually
expressing. The right shape is to express ownership as an
attribute (resource.owner == principal) and write the ReBAC
policy directly.
The second is encoding state machines in policies. A workflow that
allows transitions only from certain states is a state machine,
not a policy. Writing it as a Cedar rule (permit ... when { resource.state == "draft" && action == "submit" }) admits the
rule but makes the policy set the source of truth for what the
state machine allows. The right shape is to put the state machine
in code (or in a typed state machine in the application), and to
use Cedar only for "who can invoke this transition" rather than
"which transition is valid right now."
Schema discipline
The most consequential decision in any Cedar integration is the schema. The schema names every entity type, every attribute on every entity, every action that applies to every principal-resource pair, every required and optional context key. Getting the schema right is most of the work; getting the policies right is what follows naturally from a good schema.
Three rules help:
The first is to name entities by their domain meaning, not by the
table they live in. User is the right name; usersRow is the
wrong name. The policies that read like English are the ones that
let reviewers do their job.
The second is to declare attributes as required only when every
production deployment guarantees the attribute is present. An
attribute declared as required forces the entity provider to
return it on every load, which often forces the application to
add an INSERT default. Optional attributes are the right default;
require only when the policy logically depends on it.
The third is to update the schema whenever a policy expression needs an attribute that is not yet declared. The validator catches the inconsistency at load time; the alternative is a runtime deny that is hard to debug. The schema is not optional; treat it as part of the policy set.
Further reading
Cedar policy fundamentals covers the policy lifecycle and the
evaluator surface. Entity providers and request context covers
the data-loading contract the policies in this chapter depend on.
Audit events covers the AuthzEvent variants the evaluator
emits, including the policy id that produced each decision. The
Cedar documentation covers the
language in full detail and is the authoritative reference for
syntax and semantics.