Cedar Authorization
Cedar is Amazon’s open-source authorization language and evaluation engine. It was created by the Automated Reasoning team — the same group behind s2n-tls (Amazon’s memory-safe TLS implementation in Rust/C) and Zelkova (an SMT-solver-based tool — SMT: Satisfiability Modulo Theories, a logic-based reasoning system that can automatically prove or disprove propositions — for proving properties of IAM (Identity and Access Management) policies). Cedar powers AWS Verified Permissions and is available as a standalone Rust crate (cedar-policy).
The key differentiator is formal verification. Cedar’s type system catches policy errors at parse time, not at evaluation time. The language is decidable (every evaluation terminates), so there are no performance surprises from runaway policy logic.
Cedar vs OPA vs Casbin
OPA (Open Policy Agent) is a general-purpose policy engine from the CNCF (Cloud Native Computing Foundation). Policies are written in Rego, a language based on Datalog (a declarative logic programming language — think SQL for rules: you define what is true, not how to compute it). Casbin is a simpler library supporting predefined access control models: RBAC (Role-Based Access Control — permissions granted to roles, roles assigned to users), ABAC (Attribute-Based Access Control — policies that evaluate attributes of subjects, resources, and environment), and ACL (Access Control Lists — explicit per-user permission lists).
| Cedar | OPA (Rego) | Casbin | |
|---|---|---|---|
| Syntax | English-like permit/forbid | Datalog-based Rego | Model DSL (RBAC/ABAC/ACL) |
| Auditability | Non-engineers can read policies | Steep learning curve; hard to audit | Model files are readable but rules get opaque |
| Type safety | Schema-validated at parse time | No static type checking | No formal verification |
| Decidability | Always terminates | Turing-complete; performance surprises possible | Terminates (simple model evaluation) |
| Expressiveness | Less expressive for highly dynamic policies | Most expressive of the three | Limiting for complex business rules |
| Ecosystem | AWS-native, growing | CNCF graduated, massive ecosystem | Wide language support, simple integration |
When to pick what
Use Cedar when policies must be auditable by non-engineers and you want compile-time safety. Use OPA when you need maximum expressiveness and your team is comfortable with Datalog. Use Casbin for straightforward RBAC/ABAC where you want minimal setup.
Language Basics
Cedar policies are built around the principal / action / resource triple. Each statement is either permit or forbid, with optional when conditions.
permit(
principal,
action == Action::"ai:chat",
resource
) when {
principal.tier == "pro" && principal.llm_calls_remaining > 0
};
forbid(
principal,
action == Action::"ai:chat",
resource
);Entity types define the nouns: Tenant, Action, Resource. Conditions in when blocks use dot notation to access entity attributes.
EntityUid is NOT a UUID
Cedar’s entity identifier (
EntityUid) is a namespaced string, not a database UUID. It’sTypeName::"string_id"— likeAction::"ai:chat"orTenant::"acme-corp". The “Uid” stands for “unique identifier” in the sense of “unique within the Cedar entity graph.” You pick the string — it can be a slug, a name, or yes, a UUID string if you want. Cedar doesn’t care what’s inside the quotes, it just uses it for equality matching.
Default deny
Cedar is default-deny. If no
permitmatches, the request is denied. A singleforbidmatch overrides all permits (forbid wins).
Action groups: the in operator on actions
Actions are entities, which means they support the same in hierarchy as principals. This lets you write one policy for a group of related actions instead of repeating it per action.
Example: an AI SaaS with multiple AI endpoints:
// WITHOUT action groups — one policy per action (repetitive)
permit(principal, action == Action::"ai:chat", resource)
when { principal.tier == "pro" };
permit(principal, action == Action::"ai:summarize", resource)
when { principal.tier == "pro" };
permit(principal, action == Action::"ai:translate", resource)
when { principal.tier == "pro" };
// WITH action groups — one policy covers all AI actions
permit(principal, action in ActionGroup::"ai-operations", resource)
when { principal.tier == "pro" };To make this work, you declare the hierarchy when building entities in Rust:
// Action entities declare their parent group (just like principals declare parent groups)
let ai_group_uid: EntityUid = EntityUid::from_type_name_and_id(
"ActionGroup".parse::<EntityTypeName>()?,
EntityId::new("ai-operations"), // the string in ActionGroup::"ai-operations"
);
// Each action's parents set contains the group(s) it belongs to
let chat_action = Entity::new_no_attrs(
EntityUid::from_type_name_and_id(
"Action".parse::<EntityTypeName>()?,
EntityId::new("ai:chat"), // the string in Action::"ai:chat"
),
HashSet::from([ai_group_uid.clone()]), // ← parent: ai-operations group
);
let summarize_action = Entity::new_no_attrs(
EntityUid::from_type_name_and_id(
"Action".parse::<EntityTypeName>()?,
EntityId::new("ai:summarize"),
),
HashSet::from([ai_group_uid.clone()]), // ← same parent
);
// The group entity itself must also exist (same rule as principal groups)
let ai_group_entity = Entity::new_no_attrs(ai_group_uid, HashSet::new());
// All of these go into Entities::from_entities(...)Now the policy action in ActionGroup::"ai-operations" matches any request where the action’s parents include that group. Add a new AI endpoint? Create its action entity with the same parent — the policy applies immediately, no policy changes needed.
The Type System
Cedar schemas declare entity types, their attributes, and which actions apply to which principal/resource pairs. The schema validator catches:
- Typos in attribute names (
principal.teirinstead ofprincipal.tier) - Type mismatches (comparing a string attribute to an integer)
- References to undefined entity types or actions
This validation happens at parse time — before any authorization request is evaluated. In contrast, OPA and Casbin only surface these errors at runtime.
Entity Attributes vs Request Context
Cedar separates authorization data into two distinct categories. Understanding this split is essential for correct policy design.
| Entity Attributes | Request Context | |
|---|---|---|
| What it represents | Persistent facts about WHO is acting | Transient circumstances of THIS request |
| Where it lives | On the principal/resource Entity | In the Context object |
| Cedar access syntax | principal.tier, resource.owner | context.mfa_verified, context.source_ip |
| Lifecycle | Exists across requests (database-backed) | Dies when the request ends |
| Examples | Subscription tier, remaining API calls | Source IP, MFA status, request timestamp |
This separation mirrors how the data is sourced:
- Entity attributes come from database tables (
subscriptions.tier,subscriptions.remaining_calls). They describe the principal independent of any particular request. - Context comes from the HTTP request itself (headers, TLS metadata, JWT claims about the current session). It describes the circumstances under which this request is made.
use cedar_policy::{Entity, EntityId, EntityTypeName, EntityUid, RestrictedExpression, Context};
use std::collections::{HashMap, HashSet};
// --- Step 1: Build the entity's unique identifier (EntityUid) ---
// EntityUid = EntityTypeName + EntityId. Think of it as "Tenant::\"acme-corp\"".
// EntityTypeName is the entity *type* (parsed from a string like "Tenant").
// EntityId is the instance name (e.g., the tenant's database ID or slug).
let principal_uid: EntityUid = EntityUid::from_type_name_and_id(
"Tenant".parse::<EntityTypeName>().unwrap(), // type: "Tenant"
EntityId::new("acme-corp"), // instance: "acme-corp"
);
// --- Step 2: Entity attributes — persistent facts about WHO is acting ---
// HashMap<String, RestrictedExpression> — keys are attribute names,
// values are typed literals (see "RestrictedExpression" section below).
let mut attrs: HashMap<String, RestrictedExpression> = HashMap::new();
attrs.insert("tier".into(), RestrictedExpression::new_string("pro".into()));
attrs.insert("llm_calls_remaining".into(), RestrictedExpression::new_long(42));
// --- Step 3: Construct the Entity ---
// Entity::new signature:
// pub fn new(uid: EntityUid, attrs: HashMap<String, RestrictedExpression>,
// parents: HashSet<EntityUid>) -> Result<Self, _>
//
// - uid: who this entity IS (type + id)
// - attrs: key-value facts Cedar policies can read via dot notation (principal.tier)
// - parents: group memberships for the `in` operator (see Entity Hierarchy below)
let parent_groups: HashSet<EntityUid> = HashSet::new(); // no groups yet
let principal: Entity = Entity::new(principal_uid, attrs, parent_groups)?;
// --- Step 4: Request context — per-request transient data ---
// Context::from_pairs takes Vec<(String, RestrictedExpression)>.
// These become `context.mfa_verified`, `context.source_ip` in Cedar policies.
let context: Context = Context::from_pairs(vec![
("mfa_verified".into(), RestrictedExpression::new_bool(true)),
// Extension types like ip() are parsed from string — see "Extension Types" below
("source_ip".into(), "ip(\"10.1.2.3\")".parse::<RestrictedExpression>()?),
])?;A policy can combine both in a single rule:
permit(
principal in TenantGroup::"enterprise",
action == Action::"ai:chat",
resource
) when {
principal.llm_calls_remaining > 0 && // entity attribute (from DB)
context.mfa_verified == true && // request context (from JWT)
context.source_ip.isInRange(ip("10.0.0.0/8")) // request context (from HTTP)
};Entity Hierarchy and the in Operator
Cedar’s in operator checks entity graph membership, not attribute equality. This is Cedar’s mechanism for RBAC (Role-Based Access Control) without per-entity policy enumeration.
How it works
- You declare parent-child relationships between entities when building the
Entitiescollection - Cedar resolves
intransitively — if A is in B, and B is in C, thenA in Cis true - The
incheck goes in the principal scope (the policy head), not in thewhenclause
// This checks the entity graph — NOT an attribute comparison
permit(
principal in TenantGroup::"enterprise",
action == Action::"ai:chat",
resource
);Why in goes in the principal scope, not when
The principal scope (principal in TenantGroup::"enterprise") is a structural constraint resolved via the entity graph. The when clause holds runtime conditions evaluated against attribute values. The distinction:
principal in TenantGroup::"X"— graph traversal: “this entity belongs to group X in the entity hierarchy” (transitive, supports nesting)principal.group == "X"— flat string comparison: “this entity has an attribute called group with value X” (no hierarchy, no transitivity)
Building the entity graph in Rust
The key insight: Cedar resolves in by traversing the parents field of the principal entity. You declare group membership by listing group EntityUids in the parents set when constructing the principal.
use cedar_policy::{Entities, Entity, EntityId, EntityTypeName, EntityUid, RestrictedExpression};
use std::collections::{HashMap, HashSet};
// Groups loaded from DB: SELECT group_name FROM tenant_groups WHERE tenant_id = $1
let groups: Vec<String> = vec!["enterprise".to_string(), "beta-features".to_string()];
// Each group is an EntityUid of type "TenantGroup" with the group name as its ID.
// TenantGroup::"enterprise", TenantGroup::"beta-features"
let group_type: EntityTypeName = "TenantGroup".parse()?;
let parent_uids: HashSet<EntityUid> = groups.iter()
.map(|g| EntityUid::from_type_name_and_id(group_type.clone(), EntityId::new(g)))
.collect();
// The principal entity's third argument (parents) declares its group memberships.
// When Cedar evaluates `principal in TenantGroup::"enterprise"`, it checks
// whether TenantGroup::"enterprise" appears in this principal's parents set.
let principal: Entity = Entity::new(
principal_uid, // EntityUid — who this entity is (e.g., Tenant::"acme-corp")
attrs, // HashMap<String, RestrictedExpression> — entity attributes
parent_uids.clone(), // HashSet<EntityUid> — group memberships (parents in the graph)
)?;
// CRITICAL: group entities must also exist in the Entities collection.
// Cedar won't resolve `in` for entities it doesn't know about.
// These are "stub" entities — no attributes, no parents of their own.
// Entity::new_no_attrs(uid, parents) creates an entity with an empty attribute map.
let group_entities: Vec<Entity> = parent_uids.iter()
.map(|uid| Entity::new_no_attrs(uid.clone(), HashSet::new()))
.collect();
// Collect ALL entities into a single set. Cedar needs to see:
// - The principal (with its parents declared)
// - The action entity (e.g., Action::"ai:chat")
// - The resource entity (e.g., Resource::"ai")
// - Every group entity referenced in any principal's parents
let mut all_entities: Vec<Entity> = vec![principal, action_entity, resource_entity];
all_entities.extend(group_entities);
// Entities::from_entities builds the queryable entity graph.
// The second argument (Option<&Schema>) enables schema validation if provided.
let entities: Entities = Entities::from_entities(all_entities, None)?;Forgetting to add group entities
If you declare
TenantGroup::"enterprise"as a parent of the principal but don’t add a correspondingEntity::new_no_attrs(...)for it, Cedar will silently fail to resolve theincheck. The policy won’t match, with no error. Always create stub entities for every group referenced in parents.
The operational advantage
Add a new enterprise customer? INSERT INTO tenant_groups (tenant_id, group_name) VALUES ($1, 'enterprise'). No policy changes, no redeployment, no cache invalidation. The next authorization check loads the updated groups from the DB and passes them as parents — the existing policy principal in TenantGroup::"enterprise" immediately applies.
Extension Types
Cedar has built-in extension types that provide semantic operations on values that cannot be correctly handled as raw strings or integers:
| Extension | Constructor | Operations | Why not strings? |
|---|---|---|---|
ipaddr | ip("10.0.0.1") | .isInRange(), .isLoopback(), .isMulticast(), .isIPv4() | "10.0.0.1" < "9.0.0.0" is lexicographically true but numerically wrong |
decimal | decimal("3.14") | Comparison operators | Floating-point equality is unreliable |
IP address example
permit(
principal,
action == Action::"admin:panel",
resource
) when {
context.source_ip.isInRange(ip("10.0.0.0/8"))
};The ip() constructor creates an opaque typed value. Cedar understands that 10.1.2.3 is inside 10.0.0.0/8 by performing proper CIDR (Classless Inter-Domain Routing — the notation 10.0.0.0/8 means “any IP where the first 8 bits match”) arithmetic — not string comparison.
Building extension values in Rust
Extension constructors are classified as literals in Cedar’s grammar, so RestrictedExpression accepts them via parsing:
// ip("10.1.2.3") is a literal expression — safe to parse
let ip_expr = format!("ip(\"{}\")", source_ip);
let restricted = ip_expr.parse::<RestrictedExpression>()?;
context_pairs.push(("source_ip".into(), restricted));This is safe because parse::<RestrictedExpression>() rejects anything that isn’t a literal or extension constructor. You cannot inject operators or logic through this path.
RestrictedExpression: Preventing Policy Injection
RestrictedExpression is Cedar’s equivalent of SQL parameterized queries. It enforces a strict subset of Cedar expressions that allows only:
- String literals (
"pro") - Integer literals (
42) - Boolean literals (
true,false) - Entity references (
Tenant::"abc") - Extension constructors (
ip("10.0.0.1"),decimal("3.14"))
It does NOT allow: operators (||, &&, ==), function calls, attribute access, or any other executable logic.
The injection attack
Consider building Cedar entities from user input (database rows, JWT claims, HTTP headers):
// WRONG: interpolating user input into Cedar source text
let cedar_src = format!(r#"
permit(principal, action == Action::"ai:chat", resource)
when {{ principal.tier == "{}" }};
"#, user_supplied_tier);If user_supplied_tier is "pro" || true, the rendered Cedar becomes:
permit(principal, action == Action::"ai:chat", resource)
when { principal.tier == "pro" || true };This always evaluates to true — the attacker bypassed the tier check entirely.
The defense
// CORRECT: user input goes through RestrictedExpression
let mut attrs = HashMap::new();
attrs.insert(
"tier".to_string(),
RestrictedExpression::new_string(user_supplied_tier.clone()),
);
let principal = Entity::new(principal_uid, attrs, parents)?;Now the malicious value "pro" || true is stored as an opaque string literal. When Cedar evaluates principal.tier == "pro", it does a character-by-character comparison of the full string "\"pro\" || true" against "pro" — which is false. The || true is data, not code.
Proof via test
#[test]
fn test_malicious_tier_value_is_treated_as_literal_string() {
let policy = make_policy(vec![PolicyRule {
effect: PolicyEffect::Allow,
action: "ai:chat".into(),
principal_conditions: vec![PrincipalCondition::TierEquals("pro".into())],
context_conditions: vec![],
}]);
let mut ctx = base_context();
ctx.tier = "\"pro\" || true".to_string(); // injection attempt
assert!(!eval(&[policy], &ctx)); // DENIED — injection treated as literal data
}The general principle
Anywhere you build authorization data from untrusted input, use
RestrictedExpressionconstructors (new_string,new_long,new_bool). Never interpolate values into Cedar source text. This is the same principle as SQL parameterized queries vs string concatenation — separate code from data at the type level.
Architecture Patterns
Keeping Cedar at the boundary
Cedar is an infrastructure detail, not a domain concept. Domain objects define authorization rules in their own terms (e.g., PolicyRule structs with PrincipalCondition and ContextCondition enums), and a dedicated renderer translates them into Cedar text at the infrastructure layer. The domain layer has zero cedar-policy dependency.
// Domain: no Cedar types. Conditions split by where the data comes from.
pub enum PrincipalCondition { // checks on entity attributes (DB-backed)
TierEquals(String),
TierIn(Vec<String>),
RemainingCallsGreaterThan(i64),
InGroup(String), // becomes principal scope, not `when`
}
pub enum ContextCondition { // checks on request context (per-request)
RequireMfa,
SourceIpInRange(String), // CIDR notation, uses ip() extension
}
// Infrastructure: translates domain -> Cedar text
fn render_rule(rule: &PolicyRule) -> String {
let principal_str = match find_group_condition(&rule.principal_conditions) {
Some(group) => format!("principal in TenantGroup::\"{group}\""),
None => "principal".to_string(),
};
// ... render when clause from remaining conditions
}The InGroup condition is special: it renders into the principal scope (the policy head), not the when clause. This is because in is a structural graph operation, not a runtime condition check.
Stateless evaluation
Parse Cedar policies fresh per request rather than caching a PolicySet in memory. The parsing cost (~100us) is small relative to a typical database-backed authorization check, and it keeps the service stateless — no cache invalidation problems across replicas, no RwLock contention, no stale policies.
If parsing cost becomes measurable (thousands of policies), add a cache with explicit invalidation via pub/sub (publish/subscribe — a messaging pattern where publishers emit events and subscribers receive them without knowing each other): use Postgres LISTEN/NOTIFY (a built-in Postgres mechanism where one session executes NOTIFY channel and all sessions running LISTEN channel receive the notification asynchronously) or Redis (an in-memory data store commonly used as a message broker) rather than TTL-based staleness.
Storage layer independence
Policies are stored as JSONB in Postgres, not as Cedar text. The domain PolicyRule structs serialize via serde. The storage layer has zero Cedar dependency — you could swap Cedar for OPA without touching the database schema.
let rules_json = serde_json::to_value(&policy.rules)
.map_err(|e| AppError::Serialization { source: Box::new(e) })?;Using the Cedar SDK
The cedar_policy Rust crate (cedar-policy on crates.io) provides the core types. Here’s the full evaluation flow with type signatures:
use cedar_policy::{
Authorizer, Context, Decision, Entities, Entity, EntityId, EntityTypeName,
EntityUid, PolicySet, Request, RestrictedExpression,
};
use std::collections::{HashMap, HashSet};
// 1. Parse policies from Cedar text → PolicySet
// PolicySet::from_str(cedar_source: &str) -> Result<PolicySet, ParseErrors>
let policy_set: PolicySet = r#"
permit(principal, action == Action::"ai:chat", resource)
when { principal.tier == "pro" };
"#.parse()?;
// 2. Build entity identifiers for the request triple: principal, action, resource
// EntityUid = EntityTypeName + EntityId (like a fully-qualified name)
//
// Actions are entities too (see "Action groups" in Language Basics above).
// In practice they're stubs unless you're using action hierarchies.
let principal_uid: EntityUid = EntityUid::from_type_name_and_id(
"Tenant".parse::<EntityTypeName>()?,
EntityId::new("acme-corp"),
);
let action_uid: EntityUid = EntityUid::from_type_name_and_id(
"Action".parse::<EntityTypeName>()?,
EntityId::new("ai:chat"),
);
let resource_uid: EntityUid = EntityUid::from_type_name_and_id(
"Resource".parse::<EntityTypeName>()?,
EntityId::new("ai"),
);
// 3. Build entities with attributes and parents
// Entity::new(uid: EntityUid, attrs: HashMap<String, RestrictedExpression>,
// parents: HashSet<EntityUid>) -> Result<Entity, _>
let mut attrs = HashMap::new();
attrs.insert("tier".into(), RestrictedExpression::new_string("pro".into()));
let principal = Entity::new(principal_uid.clone(), attrs, HashSet::new())?;
// Entity::new_no_attrs(uid: EntityUid, parents: HashSet<EntityUid>) -> Entity
// Shortcut for entities that are just identifiers (actions, resources, groups)
let action = Entity::new_no_attrs(action_uid.clone(), HashSet::new());
let resource = Entity::new_no_attrs(resource_uid.clone(), HashSet::new());
// 4. Collect into Entities (the queryable entity graph)
// Entities::from_entities(entities: Vec<Entity>, schema: Option<&Schema>)
let entities = Entities::from_entities(vec![principal, action, resource], None)?;
// 5. Build request context (per-request transient data)
// Context::from_pairs(pairs: Vec<(String, RestrictedExpression)>)
let context = Context::from_pairs(vec![
("mfa_verified".into(), RestrictedExpression::new_bool(true)),
])?;
// 6. Build the authorization request
// Request::new(principal: EntityUid, action: EntityUid, resource: EntityUid,
// context: Context, schema: Option<&Schema>) -> Result<Request, _>
let request = Request::new(principal_uid, action_uid, resource_uid, context, None)?;
// 7. Evaluate: does any permit policy match this (principal, action, resource, context)?
// Authorizer::is_authorized(&Request, &PolicySet, &Entities) -> Response
let authorizer = Authorizer::new();
let response = authorizer.is_authorized(&request, &policy_set, &entities);
// Response::decision() returns Decision::Allow or Decision::Deny
let allowed: bool = response.decision() == Decision::Allow;String emission vs typed SDK
A common approach is string emission (
render_rulebuilds Cedar text viaformat!, thenPolicySet::from_str()parses it). This is simpler to implement and debug (you can print the Cedar text), but typos in the rendered string only surface at parse time. The typed SDK approach would catch structural errors at Rust compile time, at the cost of more verbose construction code. LearnOS uses string emission — the domain layer definesPolicyRulestructs, and an infrastructure-layer renderer converts them to Cedar text.
See also
- AWS Verified Permissions — managed Cedar evaluation service on AWS
- Multi-Tenant Authentication Patterns — how tenant identity feeds into Cedar evaluation
- Row-Level Security — complementary database-level isolation (Cedar gates the action; RLS gates the data)