Writing ABAC Policies With Cedar
You have decided that scattering if (user.role === "admin" && doc.tenant === user.tenant) checks across controllers is unsustainable, and you want a declarative policy language with formal evaluation semantics instead. This page is part of the Implementing Attribute-Based Access Control guide, and it walks through writing those rules in Amazon Cedar — the principal/action/resource/context model, permit and forbid policies, when/unless conditions over entity attributes, and evaluating and testing policies from TypeScript with @cedar-policy/cedar-wasm.
Cedar is an open-source policy language purpose-built for authorization. Unlike a general-purpose language, every Cedar policy is guaranteed to terminate, is analyzable, and evaluates against a typed schema — which is exactly what you want when an authorization bug means a data breach.
The Cedar Request Model
A Cedar authorization request is always a four-tuple: a principal (who is acting), an action (what they want to do), a resource (what they want to act on), and a context (ambient request facts like MFA state or source IP). Cedar evaluates that request against the full policy set plus an entity store that supplies attributes and relationships, and returns a single Allow or Deny decision along with the policies that determined it.
flowchart LR
REQ["Request\nprincipal\naction\nresource\ncontext"]:::client --> ENG["Cedar engine"]:::idp
POL["Policy set\npermit + forbid"]:::store --> ENG
ENT["Entity store\nattrs + parents"]:::rs --> ENG
ENG --> DEC["Decision\nAllow or Deny\n+ determining policies"]:::client
classDef client fill:#fff0ee,stroke:#c0392b,stroke-width:2px,color:#1a1614
classDef idp fill:#eef0ff,stroke:#2c3e8c,stroke-width:2px,color:#1a1614
classDef store fill:#fffbec,stroke:#d4840a,stroke-width:2px,color:#1a1614
classDef rs fill:#ebf5fb,stroke:#2980b9,stroke-width:2px,color:#1a1614
The decision rule is strict and is what makes Cedar safe by default: a request is allowed only if at least one permit matches and no forbid matches. forbid always wins, and the absence of any matching policy is an implicit deny.
Step 1: Writing Permit Policies
A policy has a head — the permit/forbid effect with principal/action/resource scope — and an optional body of when/unless conditions. The simplest grant restricts a specific action on a resource type. The ==, in, and is operators in the scope match exact entities, group membership, and entity types respectively.
// A member can view documents in their own tenant.
permit (
principal,
action == Action::"viewDocument",
resource is Document
)
when {
principal.tenant == resource.tenant
};
principal.tenant and resource.tenant are attributes resolved from the entity store at evaluation time. Scoping the action with == means this policy is inert for any other action — you write one focused policy per capability rather than a tangle of branches.
Step 2: Conditions With when and unless
when adds a requirement that must be true; unless adds an exception that must be false. Both take a boolean Cedar expression over the principal, resource, and context. Use unless to express carve-outs without nesting negations.
// Editors can update a document, but only with a fresh MFA session,
// and never once the document is locked for legal hold.
permit (
principal in Role::"editor",
action == Action::"updateDocument",
resource is Document
)
when {
principal.tenant == resource.tenant &&
context.mfa_authenticated == true &&
context.auth_age_seconds <= 900
}
unless {
resource.legal_hold == true
};
principal in Role::"editor" matches any principal whose parent chain includes the editor role entity — this is how Cedar expresses role membership, and the same in operator handles nested groups and resource hierarchies (a document in a folder).
Step 3: Forbid Policies Override Everything
Because forbid is evaluated against the same request and always wins, it is the right tool for hard, non-negotiable boundaries — tenant isolation, suspended accounts, IP allowlists — independent of whatever permit rules exist.
// Hard tenant isolation: no cross-tenant access, no exceptions,
// regardless of any permit a future policy author adds.
forbid (
principal,
action,
resource
)
unless {
principal.tenant == resource.tenant
};
// Suspended principals can do nothing.
forbid (principal, action, resource)
when { principal.suspended == true };
This is the key safety property: an over-broad permit written months later cannot punch through a forbid. Express your invariants as forbid policies and your grants as narrowly scoped permit policies.
Step 4: Entities and Attributes
Cedar does not invent attributes — you supply them as an entity store: a list of typed entities, each with uid, attrs, and parents (the membership edges that power in). The principal and resource in a request reference entities by UID, and Cedar reads their attributes during evaluation.
// Entities passed to the evaluator. `parents` define `in` relationships:
// alice is in Role::"editor"; the document lives in a folder.
const entities = [
{
uid: { type: "User", id: "alice" },
attrs: { tenant: "acme", suspended: false },
parents: [{ type: "Role", id: "editor" }],
},
{
uid: { type: "Role", id: "editor" },
attrs: {},
parents: [],
},
{
uid: { type: "Document", id: "doc-42" },
attrs: { tenant: "acme", legal_hold: false },
parents: [{ type: "Folder", id: "designs" }],
},
];
A Cedar schema (omitted here for brevity) declares the type of every attribute so the policy validator can reject a policy that, say, compares resource.tenant to a number — catching authorization bugs before they ship rather than at request time.
Step 5: Evaluating in TypeScript
Use @cedar-policy/cedar-wasm, the WebAssembly build of the official Rust engine, so your TypeScript service evaluates with identical semantics to Cedar everywhere else. Wrap it in a single isAuthorized function that your enforcement middleware calls.
import { isAuthorized } from "@cedar-policy/cedar-wasm/nodejs";
interface AuthInput {
principal: { type: string; id: string };
action: { type: string; id: string };
resource: { type: string; id: string };
context: Record<string, unknown>;
entities: unknown[];
}
const POLICIES = `
forbid (principal, action, resource)
unless { principal.tenant == resource.tenant };
permit (
principal in Role::"editor",
action == Action::"updateDocument",
resource is Document
)
when {
context.mfa_authenticated == true &&
context.auth_age_seconds <= 900
}
unless { resource.legal_hold == true };
`;
export function decide(input: AuthInput): "allow" | "deny" {
const result = isAuthorized({
principal: `${input.principal.type}::"${input.principal.id}"`,
action: `${input.action.type}::"${input.action.id}"`,
resource: `${input.resource.type}::"${input.resource.id}"`,
context: input.context,
policies: { staticPolicies: POLICIES },
entities: input.entities,
});
if (result.type === "failure") {
// Fail closed: a malformed policy/entity set must never allow.
console.error("cedar evaluation error", result.errors);
return "deny";
}
return result.response.decision === "allow" ? "allow" : "deny";
}
Two non-negotiable rules: fail closed on any evaluation error (a parse or schema failure must return deny, never throw past your enforcement point), and never trust client-supplied attributes — build context and entity attributes server-side from a cryptographically verified token, never from request headers an attacker controls.
Step 6: Testing Policies
Cedar’s determinism makes policy testing a pure function exercise: assemble a request and entity set, assert the decision. Cover the matrix of allow, the forbid overrides, and the boundary of each when/unless condition.
import { describe, it, expect } from "vitest";
import { decide } from "./authz";
const aliceEditor = [
{ uid: { type: "User", id: "alice" }, attrs: { tenant: "acme", suspended: false },
parents: [{ type: "Role", id: "editor" }] },
{ uid: { type: "Role", id: "editor" }, attrs: {}, parents: [] },
{ uid: { type: "Document", id: "doc-42" }, attrs: { tenant: "acme", legal_hold: false },
parents: [] },
];
const req = (context: Record<string, unknown>, entities = aliceEditor) => ({
principal: { type: "User", id: "alice" },
action: { type: "Action", id: "updateDocument" },
resource: { type: "Document", id: "doc-42" },
context, entities,
});
describe("document update policy", () => {
it("allows a fresh-MFA editor in the same tenant", () => {
expect(decide(req({ mfa_authenticated: true, auth_age_seconds: 120 }))).toBe("allow");
});
it("denies when the MFA session is stale", () => {
expect(decide(req({ mfa_authenticated: true, auth_age_seconds: 5000 }))).toBe("deny");
});
it("forbid wins: legal hold blocks even a valid editor", () => {
const held = structuredClone(aliceEditor);
held[2].attrs.legal_hold = true;
expect(decide(req({ mfa_authenticated: true, auth_age_seconds: 60 }, held))).toBe("deny");
});
it("forbid wins: cross-tenant access is denied", () => {
const crossTenant = structuredClone(aliceEditor);
crossTenant[2].attrs.tenant = "globex";
expect(decide(req({ mfa_authenticated: true, auth_age_seconds: 60 }, crossTenant))).toBe("deny");
});
});
Run these in CI on every policy change. Because Cedar policies are static text, they belong in version control with peer review — treat a policy diff like a code diff.
Security Implications
The forbid-wins semantics are Cedar’s strongest security property, but they only protect you if you actually express invariants as forbid policies. The common failure is encoding tenant isolation as a when on each permit; a single author who forgets the clause on a new grant opens a cross-tenant hole. A standalone forbid for isolation closes that class of bug permanently.
Validate every policy against a schema in CI. Without it, a typo like resource.tennant silently evaluates to an attribute-not-found error; depending on how you handle errors, that can turn a forbid condition false and grant access. Schema validation catches the typo before deployment.
Finally, attribute provenance is the whole ballgame. Cedar decides correctly on the attributes you give it — if context.mfa_authenticated is set from a header the client can spoof, the policy is decorative. Derive every principal, resource, and context attribute server-side from verified session state.
Prevention & Monitoring Hooks
- Schema validation gate. Block CI if any policy fails
cedar validateagainst the schema. - Decision logging. Log the request tuple, decision, and determining policy ids for every deny — this is your audit trail and your debugging surface.
- Forbid coverage assertions. Keep a test that asserts cross-tenant and suspended-principal requests always deny, run on every policy change.
- Fail-closed alarms. Alert on any
result.type === "failure"; a spike means a bad policy or entity set reached production. - Externalize at scale. As the policy set grows, consider running Cedar behind a dedicated decision service alongside other engines like Open Policy Agent for versioned, centrally managed distribution.
Frequently Asked Questions
What happens when no policy matches a request?
Cedar denies. A request is allowed only when at least one permit matches and no forbid matches; if nothing matches, that is an implicit deny. This default-deny posture means a missing or misnamed policy fails safe — you never accidentally grant access by omission, only by writing an explicit permit.
How is Cedar different from writing the same rules in Rego (OPA)?
Both are declarative policy languages, but Cedar is narrower by design: it is purpose-built for authorization with a fixed principal/action/resource/context model, guaranteed-terminating evaluation, and a type-checkable schema, and it cannot make network calls. Rego is a general query language that is more flexible (arbitrary data joins, external data) at the cost of being harder to analyze formally. Cedar fits per-request authorization decisions; see the OPA integration guide when you need richer data processing in policy.
Can a `permit` policy override a `forbid`?
No, and this is intentional. forbid always wins regardless of how many permit policies match. That is why you should encode hard invariants — tenant isolation, account suspension, kill-switches — as forbid policies: no future permit, however broad, can break through them.
Where should principal and resource attributes come from at evaluation time?
From your own trusted sources, assembled server-side. Resolve principal attributes from verified session/token claims and your user store, and resource attributes from your database, then pass them in the entity store. Never populate attributes from client-controlled headers or request bodies — Cedar will faithfully decide on spoofed input, so attribute provenance is the security boundary, not Cedar itself.
Do I need the Cedar schema, or can I evaluate policies without one?
The engine evaluates policies without a schema, but you should still author one and run cedar validate in CI. The schema declares entity types and attribute types so the validator catches mistakes — a misspelled attribute, comparing a string to a number, an action that does not exist — at build time instead of letting them surface as silent runtime errors that can flip a decision the wrong way.
Related
- Implementing Attribute-Based Access Control — the broader ABAC architecture, attribute normalization, and PDP/PEP topology Cedar slots into.
- Integrating Open Policy Agent for AuthZ — the alternative policy engine when you need general-purpose querying over external data.
- Evaluating Casbin vs OPA for Microservices — how to choose between embedded and centralized policy engines for your service topology.