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 the application 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. This chapter covers the lifecycle: loading, validation, the per-request evaluator, the contract with the application's data layer, and the error modes.

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 application refuses to start. The defence is structural: there is no path to production with a broken policy file because the application 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 the application's entity graph for this request, and a context (MFA status, IP address, the application's 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 the application's 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 (the application's 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:

let session: AuthzSession = authz_store.session()
    .with_principal(principal)
    .with_entity_provider(&app.entity_provider)
    .with_context(StandardRequestContext::from_request(&request))
    .build();

match session.decide(
    Action::View,
    ResourceUid::new("Document", "doc-123"),
).await {
    Ok(AuthzDecision::Allow) => proceed(),
    Ok(AuthzDecision::Deny) => render_forbidden(),
    Err(e) => render_error(e),
}

The with_principal call binds the caller. The principal carries the user id, the tenant id, the factors completed, and the authentication time. Cedar policies can match on any of these.

The with_entity_provider call binds the application's data layer. The entity provider is the application-specific code that loads the relevant entities (the user record, their group memberships, the resource being accessed, its parents) for the evaluation. The provider returns a Cedar entity graph; the session holds it for the duration of the evaluation. The next chapter, Entity providers and request context, covers the provider contract in detail.

The with_context call binds the contextual attributes. The StandardRequestContext covers the common cases: MFA status, IP address, the time of the request. Applications can extend it with custom keys (a custom-headers map, a tenant-feature-flag set, a geographical location).

The decide verb evaluates the policies and returns AuthzDecision::Allow or AuthzDecision::Deny. The verb is async because the entity provider may need to fetch entity data from a database. The require verb is a thin wrapper that returns an error on Deny, suitable for handlers that want to short-circuit on a denied request.

What policies look like

A Cedar policy is a permit or forbid statement against a principal, action, and resource, with optional when conditions. The simplest possible policy:

permit (
    principal,
    action == Action::"read",
    resource
);

This is the "everyone can read everything" policy. It permits any principal to perform the read action against any resource. It is useful for nothing in production but illustrates the shape.

A real RBAC policy:

permit (
    principal in Role::"finance-viewer",
    action == Action::"read",
    resource in TenantData::"acme"
) when {
    principal.tenant_id == "acme"
};

This permits any principal in the finance-viewer role to read any resource in the acme tenant's data, but only when the principal is also in the acme tenant. The in operator is set membership against the entity graph: the policy is asking the entity provider "is this principal in this role?", which the provider answers from the application's data.

A ReBAC policy:

permit (
    principal,
    action == Action::"edit",
    resource
) when {
    resource.owner == principal
};

This permits a principal to edit a resource only when the resource's owner attribute equals the principal. Ownership is the ReBAC relationship; the schema declares Document has an owner attribute of type User, and the entity provider populates it from the document's row.

An ABAC policy:

permit (
    principal,
    action == Action::"write",
    resource in TenantData::"acme"
) when {
    principal.tenant_id == "acme"
    && context.mfa == true
    && context.ip like "10.*"
};

This permits writes to the acme tenant's data when the principal is in the tenant, has completed MFA, and is connecting from an internal IP range. Context attributes come from the StandardRequestContext (or custom extensions); the schema declares them so the validator can type-check the policy.

The three styles compose freely in one rule. A real production policy is typically a mix: roles establish broad permissions, relationships restrict to ownership, attributes restrict to high-assurance contexts. Cedar's deny-by-default behaviour means the rules accumulate as positive grants; no rule denies, and the absence of a permitting rule is itself a deny.

Errors

The AuthzError enum has variants for the cases that go wrong:

pub enum AuthzError {
    PolicySetInvalid(String),       // load-time, should never reach prod
    SchemaValidationFailed(String), // load-time
    EntityNotFound { uid: String }, // evaluator could not load an entity
    ContextMissing(String),         // policy needed a context key not provided
    EvaluationFailed(String),       // Cedar internal error (rare)
    Cancelled,                      // request cancelled during evaluation
}

The load-time variants should never reach production because the PolicyStore::validate_against call catches them at startup.

The runtime variants are recoverable but specific. EntityNotFound means the entity provider returned no entity for a UID a policy referenced; the deployment may have a stale Cedar reference or a race between policy and data. ContextMissing means a policy referenced a context key the request did not provide; the schema should have caught this at load time but did not (a context key the schema declared as optional, used in a policy as if required). EvaluationFailed is the catch-all for Cedar's own errors, which are rare in well-formed policy sets.

Every variant produces a deny. There is no path where an evaluation error produces an allow. The defence is structural and is one of the reasons Cedar was chosen.

When to use require versus decide

The two verbs differ in their failure handling. require returns an error on Deny (so the handler short-circuits with an error without needing an explicit match); decide returns the typed decision (so the handler can branch).

The recommendation is to use require in handlers (the most common case: deny gives a 403, allow proceeds), and decide in code that needs to express a non-binary outcome (a UI that hides buttons rather than displaying them and denying on click, an admin panel that shows what the current user could do).

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

// decide version: branch on the decision
async fn dashboard(
    session: AuthzSession,
) -> impl IntoResponse {
    let can_create_doc = matches!(
        session.decide(Action::Create, ResourceUid::new("Document", "*")).await,
        Ok(AuthzDecision::Allow)
    );
    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 the application's 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.