Cedar policy fundamentals

Most application authorisation is the if user.role == "admin" style: a check scattered across handlers, expressed in code, written by whoever happened to be in the file at the time, with no shared schema and no way to review the policy as a whole. The pattern works for small applications and fails for everything else, because the authorisation logic is the part of your system that needs the most review and is also the part most likely to drift.

Cedar is a policy language designed for this exact problem. It is declarative, deny-by-default, statically checkable against a schema, and built to express RBAC, ReBAC, and ABAC in one set of rules. Axess loads a Cedar policy set at startup, validates it against a schema, and exposes per-request evaluation through a small typed interface.

The feature flag is authz (on by default in the axess facade).

The lifecycle

Cedar in axess has three lifecycle phases: load, evaluate, redeploy. Each phase has a specific failure mode, and the design is built so the failures land at the right place.

The load phase happens once at application startup. The application constructs a PolicyStore from one or more policy files, validates the parsed policies against a schema, and produces an AuthzStore that holds the result. A load failure (a malformed policy, a type mismatch against the schema, an action that references an undefined entity) is a startup failure: the process refuses to start. The defence is structural: there is no path to production with a broken policy file, because the process refuses to come up.

The evaluate phase happens once per authorisation check. The application constructs an AuthzSession from the AuthzStore, a Principal (typically extracted from the session or from a workload-identity resolver), an AuthzEntityProvider that supplies your entity graph for this request, and a context (MFA status, IP address, your custom attributes). The session offers two verbs: require (allow or deny, returning an error on deny) and decide (a typed AuthzDecision). The evaluation is cheap, predictable, and deterministic.

The redeploy phase happens when policies change. The application loads a new PolicyStore from the new policy files, swaps it in behind the AuthzStore's Arc, and from the next request onward new evaluations use the new policies. A hot reload of policies is supported; the trade-off is that decisions in flight at swap time see the old policies and decisions started after see the new policies. There is no decision-caching layer in axess for this reason: a cached decision from before a redeploy would survive into the new policy regime and produce wrong answers. The chapter Entity providers and request context expands on what does and does not get cached.

Loading policies

The minimal load is a directory of .cedar files plus a schema.cedarschema file:

use axess::authz::{AuthzStore, PolicyStore};

let policy_store = PolicyStore::load_directory("./policies")?;
let schema = std::fs::read_to_string("./policies/schema.cedarschema")?;
policy_store.validate_against(&schema)?;

let authz_store = AuthzStore::new(policy_store);

The load is recursive: every .cedar file under the directory is parsed and added to the policy set. Cedar policies have no import or namespace mechanism beyond the entity-type namespace; the collection of all files is the policy set, evaluated as one.

validate_against is the call that catches malformed policies before they reach production. The validator checks that every entity type the policies reference is defined in the schema, that every attribute access is on an attribute the schema declares, and that the types align (a policy that asks principal.age > "old" gets caught because the schema declares age as a number and the literal is a string).

The schema is its own discipline. Writing a schema that accurately describes your entities is the hardest part of a Cedar integration. The schema names the principal types (User, Workload, Role, Group), the action types (read, write, administer), the resource types (your domain objects), and the parent relationships (a User is in Groups, which are in Roles, which permit Actions). The Cedar documentation covers schema authoring in detail; the chapter here focuses on what axess does with a schema once it has one.

The per-request evaluator

The AuthzSession is constructed per request and lives only as long as the request:

// One `AuthzStore` for the process, built from the policy set, the
// schema and your entity provider. It is held in an `Arc`.
let session = authz_store.for_user_id_with_context(
    &user_id.to_string(),
    StandardRequestContext::new(mfa_verified, client_ip),
)?;

if session.is_permitted("View", &doc_id).await {
    proceed()
} else {
    render_forbidden()
}

for_user_id binds the caller. It turns the user id into the Cedar User UID your schema declares, and the session carries that UID for every check it makes. Use for_user_id_with_context when policies need request attributes; for_user_id alone gives an empty Cedar context.

The entity provider is bound to the store, not the session, because it is process-wide application code rather than per-request state. It is what loads the relevant entities (the user record, their group memberships, the resource being accessed, its parents) for each evaluation. The next chapter, Entity providers and request context, covers the contract in detail.

StandardRequestContext::new(mfa_verified, ip_address) covers the common context keys. Applications needing more implement BuildRequestContext themselves and pass their own type; the session is generic over it.

The resource is your provider's ResourceId, not a Cedar UID. You pass the id your application already has, and the provider's resource_uid turns it into the UID the policies match on. The action is a plain &str naming the action in your schema.

The session caches entities per (action, resource) for its lifetime, so a handler that checks the same pair twice pays for the provider once.

There are three verbs, and no method returns an error for a denial.

require(action, resource) returns Result<(), AuthzDenied>, so a handler can ? it and let a deny become a 403. AuthzDenied is a unit type: it says access was refused, and deliberately says nothing about why, because the reason is exactly what an attacker probing policies would like to learn. Log the detail on your side of the call.

is_permitted(action, resource) returns a plain bool, for code that needs a non-binary outcome: a UI that hides a button rather than showing it and denying on click, an admin panel listing what this user could do.

batch_check(&[(action, resource)]) evaluates several pairs and returns Vec<(String, AuthzDecision)>, sharing the session's entity cache across them. Use it to answer "which of these may I do?" in one pass instead of a loop of is_permitted.

All three are async because the provider is: loading entities usually means a database round trip.

// require version: handler short-circuits on deny
async fn delete_document(
    session: AuthzSession,
    Path(doc_id): Path<String>,
) -> Result<Json<()>, AppError> {
    session.require("Delete", &doc_id).await?;
    // ... proceed with delete
}

// is_permitted version: branch on the boolean
async fn dashboard(
    session: AuthzSession,
) -> impl IntoResponse {
    let can_create_doc = session.is_permitted("Create", &template_id).await;
    render_dashboard(can_create_doc)
}

The wildcard resource UID in the second example is a Cedar convention for "is the principal allowed to perform this action at all?"; it relies on the policy set being written with that question in mind.

What policies cannot do

Cedar is the right tool for asking "is this allowed?". It is not the right tool for everything that pattern-matches like authorisation but is actually something else.

It is not for rate limiting. Rate limits are stateful (they depend on the rate of past requests, not the content of the current request), expensive to express in declarative terms, and not what Cedar is built for. Use the RateLimitLayer middleware (covered in Rate limiting).

It is not for input validation. A request with an invalid body fails at deserialisation, not at authorisation. Cedar policies that try to enforce body-shape constraints duplicate validation logic and run after the body has already been parsed.

It is not for state transitions. A workflow that allows a transition from Pending to Approved but not from Pending to Closed is a state machine, not a policy. Implement the state machine in code (or in a axess-style typed state machine for the workflow); use Cedar to gate access to the transition operations.

It is not for caching decisions across requests. Policies and entity graphs are mutable; cached decisions are stale by construction. Axess deliberately caches entity graphs (which are much more stable) and not decisions.

The next chapter, Entity providers and request context, covers the entity-graph caching mechanism and the contract between Cedar and your data layer.

Further reading

Entity providers and request context covers the AuthzEntityProvider trait, the StandardRequestContext extension points, and the caching posture. RBAC, ReBAC, and ABAC patterns walks through worked examples of each style and how they compose in one policy set. The principal model covers the principal types the evaluator binds to.