Entity providers and request context

A Cedar policy evaluates against three inputs: a principal, an action, a resource, plus an entity graph that gives the policies the data they need to reason about (which roles the principal is in, which group owns the resource, what the principal's MFA status is). The policy set is loaded once at startup. The principal and action come from the request. The entity graph and the request context come from the application, per request, through two interfaces this chapter covers: the AuthzEntityProvider trait and the StandardRequestContext extension surface.

Doing both of these well determines whether the Cedar integration holds up under load. A naive entity provider that loads an entire user's group membership on every request will be the slowest part of the request lifecycle. A request context that omits an attribute a policy expects produces denies that are hard to debug. The shapes below avoid both failure modes.

The entity provider contract

AuthzEntityProvider is the trait the application implements. The job is to take a request's principal and resource UIDs, and return a Cedar entity graph rich enough that the evaluator can answer the policy questions:

#[async_trait]
pub trait AuthzEntityProvider: Send + Sync {
    async fn entities(
        &self,
        principal: &Principal,
        resources: &[ResourceUid],
    ) -> Result<EntitySet, AuthzProviderError>;
}

The provider receives the principal (so it can load the principal's groups, roles, and any attributes the policies need) and the list of resource UIDs the request is touching (so it can load the resources, their parents, and their attributes). It returns an EntitySet, which is Cedar's typed entity graph: each entity has a UID, a set of attributes, and a list of parent entities.

The contract is "return enough to answer the policies, no more." An entity set that omits an entity a policy references produces an EntityNotFound error at evaluation time. An entity set that includes hundreds of entities the policy never touches wastes the database time. The right shape is the minimum set the policies need for this request.

What "enough" means

The policies that the evaluator runs against the entity set typically need a few categories of data.

The principal's parents. Every role the principal is in, every group they belong to. A policy that says principal in Role::"finance-viewer" needs the principal's parents list to include Role::"finance-viewer" if the principal is in that role. The provider populates this from the application's role-and-group store.

The principal's attributes. The user's tenant id, MFA status, factors completed, custom attributes the policies use. Many of these are already on the Principal value; the provider attaches them as Cedar attributes on the principal entity.

The resource's parents. The tenant that owns it, the project it belongs to, any logical grouping the policies might match against. A policy that says resource in TenantData::"acme" needs the resource's parents list to include TenantData::"acme" if the resource belongs to that tenant.

The resource's attributes. The owner, the visibility setting, the classification level, anything the policies need. The provider populates these from the resource's row.

The principal's relationships to the resource. A ReBAC policy that matches resource.owner == principal needs the resource's owner attribute to equal the principal's UID. If the resource is shared with the principal through a separate sharing record, the provider either expresses it as an attribute on the resource (a shared_with list) or as a parent (the principal is in a "viewers" group attached to the resource).

The application's data model is the source of truth for all of this; the provider's job is to shape the data into Cedar's vocabulary.

A worked provider

A typical provider for a document-management application looks like this:

struct AppEntityProvider {
    db: PgPool,
}

#[async_trait]
impl AuthzEntityProvider for AppEntityProvider {
    async fn entities(
        &self,
        principal: &Principal,
        resources: &[ResourceUid],
    ) -> Result<EntitySet, AuthzProviderError> {
        let mut set = EntitySet::new();

        // Principal: load roles and groups, attach as parents.
        let user_id = principal.user_id().ok_or(AuthzProviderError::NotHuman)?;
        let memberships = sqlx::query_as::<_, (String,)>(
            "SELECT role_uid FROM user_roles WHERE user_id = $1"
        )
        .bind(user_id.to_string())
        .fetch_all(&self.db)
        .await?;

        let principal_uid = ResourceUid::new("User", &user_id.to_string());
        set.insert(Entity {
            uid: principal_uid.clone(),
            attrs: principal_attrs(principal),
            parents: memberships
                .into_iter()
                .map(|(uid,)| ResourceUid::parse(&uid).unwrap())
                .collect(),
        });

        // Resources: load each resource's row + tenant parent.
        for resource in resources {
            if resource.entity_type() == "Document" {
                let row: DocumentRow = sqlx::query_as("SELECT * FROM documents WHERE id = $1")
                    .bind(resource.id())
                    .fetch_one(&self.db)
                    .await?;
                set.insert(Entity {
                    uid: resource.clone(),
                    attrs: document_attrs(&row),
                    parents: vec![ResourceUid::new("TenantData", &row.tenant_id)],
                });
            }
        }

        Ok(set)
    }
}

The shape is uniform: one principal entity (with parents from the role-and-group store), one or more resource entities (each with parents from the tenant model and attributes from the resource's row). The provider uses Postgres in this example; the choice is the application's. The key shape is that the loads are batched per request (one query for memberships, one or two for the resources), not per policy or per entity.

Caching entities, not decisions

The single most important performance choice in a Cedar integration is what to cache. Axess takes the conservative line: entity graphs are cached aggressively, decisions are never cached.

Decisions cannot be cached because they are functions of the entity graph, the policy set, and the context. Any of the three can change between the cache write and the cache read: the entity graph because the database has updated (a role granted, a relationship added), the policy set because a redeploy has happened, the context because the request is different. A cached decision that survives any of these changes produces a wrong answer. The defence is to not cache decisions at all.

Entity graphs can be cached because they are functions of the database state at a known point in time. The cache key is the principal UID plus the resource UIDs; the cache value is the entity set; the cache TTL is a function of how stale the application is willing to tolerate.

Axess provides an AuthzSessionCache decorator that wraps an AuthzSession. The decorator caches the entity graph for a configurable TTL (default sixty seconds for low-sensitivity deployments, one second or less for high-sensitivity deployments, or even off for the highest-sensitivity ones). The cache is keyed by (tenant_id, principal_uid, resource_uids).

The TTL is the lever. Sixty seconds is fine for a deployment where a role change can take a minute to propagate (most internal admin panels). Anything tighter requires the cache to be invalidated on role changes, which means the application's role-mutation code calls into the cache to flush the affected entries. The CacheInvalidator trait on EntityCache is the surface for this; applications that need stricter consistency wire the invalidations explicitly.

The chapter Session lifecycle and crypto envelope covers the generic axess-cache machinery the entity cache uses. Operations runbook covers the operational signals for the cache (hit rate, eviction rate, invalidation rate).

The standard request context

The context is the third input to a policy evaluation. It carries the per-request attributes that are not on the principal or the resource: the MFA status, the IP address, the time of the request, the custom keys the application wants to expose to policies.

StandardRequestContext is the built-in implementation:

pub struct StandardRequestContext {
    pub mfa: bool,
    pub ip: Option<IpAddr>,
    pub now: DateTime<Utc>,
    pub custom: BTreeMap<String, serde_json::Value>,
}

impl StandardRequestContext {
    pub fn from_request(req: &Request) -> Self { /* ... */ }

    pub fn with_custom(mut self, k: impl Into<String>, v: serde_json::Value) -> Self {
        self.custom.insert(k.into(), v);
        self
    }
}

The from_request constructor pulls what it can from the request: the IP from the trusted-proxy chain, the MFA status from the session's factors_completed, the time from the clock. The with_custom builder adds application-specific keys.

Policies can match on any of these:

permit (
    principal,
    action == Action::"write",
    resource
) when {
    context.mfa == true
    && context.ip like "10.*"
    && context.custom.region == "eu"
};

The schema declares the context shape:

type Context = {
    mfa: Bool,
    ip: String,
    custom: {
        region?: String,
        ...
    }
};

Required fields are checked at policy load time; optional fields are checked at evaluation time. A policy that uses a required field the request omits produces a startup error (good, caught early). A policy that uses an optional field the request omits produces a deny at runtime with ContextMissing (acceptable, deny is the conservative answer).

When to extend the context

The custom keys exist to bridge application state that does not fit on the principal or the resource. Common cases:

The first is a tenant feature flag. A policy that gates a beta feature on "this tenant has opted in" reads context.custom.beta, which the application sets from the tenant's feature-flag state.

The second is the request's geographical context. A policy that restricts certain actions to certain regions reads context.custom.region, which the application populates from the load balancer's geo-IP information or from an explicit header.

The third is a stepped-up factor that is not in factors_completed because it was completed for a different reason. A policy that wants to know "did the user complete a fresh password challenge in the last five minutes" reads context.custom.password_challenge_at, which the application populates from a sidecar store of recent challenges.

The pattern across all three: the application owns the data, the context is the carrier, the policy sees a typed attribute it can match on.

Failure modes and visibility

The two failure modes worth knowing are EntityNotFound and ContextMissing, both of which surface as Deny from the evaluator. The right response is the same in both cases: log the failure with enough detail to diagnose, surface a generic deny to the user, and keep the audit trail.

EntityNotFound typically means the entity provider should have loaded an entity but did not. The fix is in the provider: load the missing entity, or update the policy to not reference it.

ContextMissing typically means a policy was written against a context key the application does not provide. The fix is in the schema: declare the key as optional and update the policy to handle its absence, or update the application to provide it.

Axess emits an AuthzEvent for every evaluation, regardless of outcome. The chapter Audit events covers the event surface; the relevant variants here are AuthzEvent::EntityNotFound and AuthzEvent::ContextMissing, both of which name the missing key and the policy that referenced it. A spike in either suggests a mismatch between the policy set and the rest of the deployment; operational dashboards should alert on it.

What this enables

The provider-and-context contract is what makes Cedar usable against an arbitrary application data model. The schema names the shape; the policies match on the shape; the provider populates the shape from whatever the application's storage actually looks like. The three layers are independent, which means a database migration that changes how roles are stored does not break the policies (the provider updates; the rest stays), and a policy change does not touch the database (the policy file updates; the rest stays).

The chapter RBAC, ReBAC, and ABAC patterns covers worked examples that show the three styles composed in real policies.

Further reading

Cedar policy fundamentals covers the policy lifecycle and the evaluator surface this chapter feeds. RBAC, ReBAC, and ABAC patterns covers the policy authoring style with concrete examples for each pattern. Identity store implementation covers how the provider's principal-loading queries fit into the application's identity-store implementation. Audit events covers the AuthzEvent variants the evaluator emits.