--- title: "Cedar authorization" description: "The Obol Cedar schema, entity shape, argument conditions, example policies, and the fixture suite that both planes run." --- Cedar is the authorization of record. It is embedded in the gateway through the `cedar-policy` crate and evaluated synchronously, in-process, with no I/O. The schema, the example policy pack, and the fixtures live in `packages/cedar/` and are the cross-language contract between the Rust gateway and the Python control plane (ADR-0017). ## Schema `packages/cedar/schema.cedarschema` declares the whole `Obol` namespace: ```cedar namespace Obol { entity Workspace = { env: String, frozen: Bool }; entity User in [Workspace] = { role: String }; entity Agent in [Workspace] = { env: String, key_prefix: String, tools_mode: String, max_refund_usd?: decimal }; entity Connection in [Workspace] = { connector: String, status: String }; entity Tool in [Connection] = { name: String, destructive: Bool, connector: String }; entity Model = { provider: String }; type CallContext = { env: String, approved: Bool, destructive: Bool, args_hash: String, amount_usd?: decimal }; action call appliesTo { principal: [Agent], resource: [Tool], context: CallContext }; action list appliesTo { principal: [Agent], resource: [Tool], context: { env: String } }; action route appliesTo { principal: [Agent], resource: [Tool], context: { env: String } }; action complete appliesTo { principal: [Agent], resource: [Model], context: { env: String } }; action read appliesTo { principal: [User, Agent], resource: [Workspace, Connection, Tool], context: {} }; } ``` The hierarchy is `Tool in Connection in Workspace`, and `Agent in Workspace`. Policies are strictly validated against this schema at compile time — a policy that does not validate is never published. ## Entity shape Control publishes entities as JSON in exactly the shape `Entities::from_json_value` accepts. Each entry is `{uid: {type, id}, attrs, parents}`. Decimal attributes use Cedar's extension encoding with one to four fractional digits. ```json { "uid": { "type": "Obol::Agent", "id": "support" }, "attrs": { "env": "prod", "key_prefix": "ob_live", "tools_mode": "full", "max_refund_usd": { "__extn": { "fn": "decimal", "arg": "50.00" } } }, "parents": [{ "type": "Obol::Workspace", "id": "ws_acme_prod" }] } ``` `packages/cedar/fixtures/entities.json` is the reference set: one workspace, two agents, two connections, four tools, and two models. ### Request-state binding Several virtual keys can share one agent identity. Publishing one key's attributes onto that shared agent would let another key inherit its refund cap, so for live requests the gateway **projects** the principal rather than reading it from the published set (ADR-0040): - The `Obol::Agent` entity — `env`, `key_prefix`, `tools_mode`, `max_refund_usd` from the key's `attributes`, plus workspace membership — is rebuilt from the authenticated `KeyContext` on every evaluation. It replaces, rather than merges with, any published agent of the same id, so an omitted constraint cannot survive from another or a revoked key. - A `Tool` entity missing from the published set is synthesized from its `ToolSnapshot`. - Published entities still supply every other identity and resource. Raw offline fixtures keep their explicit entity inputs untouched. A key attribute that is not a decimal where the schema expects one is an entity error, and an entity error is a deny. ## Example policies `packages/cedar/policies/support-agent.cedar` is the shipped example pack. Every policy carries an `@id` annotation, because those ids are what land on receipts. ```cedar // Every agent may see and complete within its own workspace. @id("agents-list-and-complete") permit(principal is Obol::Agent, action in [Obol::Action::"list", Obol::Action::"complete"], resource); ``` ### Argument conditions The refund cap is the canonical argument condition: the amount comes from the call context, the cap from the principal. ```cedar @id("support-refund-cap") permit( principal == Obol::Agent::"support", action == Obol::Action::"call", resource == Obol::Tool::"stripe.create_refund" ) when { context has amount_usd && principal has max_refund_usd && context.amount_usd.lessThanOrEqual(principal.max_refund_usd) }; ``` `context.amount_usd` is derived in the gateway by reading the tool's `amount_path` out of the call arguments and dividing by its `amount_divisor`. The gateway rejects a negative amount before authorization, because a `lessThanOrEqual` cap on its own would allow one. ### Resource sets Cedar 4.x does not accept a set of entities in the policy *scope*. A set must move into the `when` clause. ```cedar @id("support-reads") permit( principal == Obol::Agent::"support", action == Obol::Action::"call", resource ) when { resource in [Obol::Tool::"stripe.retrieve_charge", Obol::Tool::"github.create_issue"] }; ``` ### Forbids A `forbid` overrides every `permit`. The approval gate and the absolute prohibition are both forbids, and only their `@id` tells them apart. ```cedar // Destructive tools in prod require an approval. @id("prod-destructive-needs-approval") forbid(principal, action == Obol::Action::"call", resource) when { resource.destructive && context.env == "prod" && !context.approved }; // Payout changes may never be called or listed by anyone. @id("no-payout-changes") forbid( principal, action in [Obol::Action::"call", Obol::Action::"list"], resource == Obol::Tool::"stripe.update_payout" ); ``` A `forbid` whose `@id` ends in `needs-approval` is the approval gate. When it is the *only* reason a destructive prod call was denied and the call is not yet approved, the gateway returns `approval_required` instead of `deny`. Any other forbid stays a plain deny, and so does a deny with no matched policy at all — no approval could ever flip a missing permit. A workspace-authored approval forbid must be named with that suffix to become a ticket. See [Approvals](/policy/approvals). ## Authoring notes - `decimal` has no `<`, `<=`, `>` or `>=` operators. Use `lessThan`, `lessThanOrEqual`, `greaterThan`, `greaterThanOrEqual`. Decimals carry one to four fractional digits. - The policy scope accepts `== A`, `in A` for a single entity, or `is T`. Sets belong in `when`. - Annotate every policy with `@id("…")`. Without it the receipt falls back to Cedar's generated `policyN`. - A workspace with no authored policy starts from a single `agents-list-and-complete` permit. Everything else is authored. ## Evaluation and caching `Authorizer::compile` parses the `PolicySet`, strictly validates it against the schema, and loads the entities. The compiled authorizer is cached by `(workspace_id, policy_id, revision_id, hash)` — the publication identity, not just the bundle content — so an identical bundle republished under a new revision still produces a verdict that names the current revision. On a compile error the cache is untouched and the gateway keeps serving with the previous authorizer. A `Verdict` carries the decision, the `matched_policies` (resolved `@id` annotations), any errors, and the `policy_id` and `revision_id` of the snapshot that decided. ### Discovery and reachability Listing a tool requires both the `list` permission and **call reachability**. Reachability is a partial evaluation of the `call` action with the principal, resource, environment, and destructive classification known, and arguments and approval left unknown: - A definite denial or an evaluation error hides the tool. - An argument-dependent residual stays discoverable and undergoes full authorization at invocation. - A virtual capability additionally needs at least one eligible, authorized concrete binding. - The same filter runs before provider selection, so a permanently forbidden provider cannot outrank a callable alternative. This is a conservative approximation, not a satisfiability proof and not permission to execute. ## Fixtures `packages/cedar/fixtures/*.json` cases are the executable contract. Each is `{policy, entities, request{principal, action, resource, context}, expect{decision, policy_ids}}`, run by `obol-policy` tests, by the `obol-policy-check` CLI, and by control's Python tests. `packages/fixtures/cedar/` is a byte-for-byte mirror, diff-checked in CI. | Fixture | Request | Expected | |---|---|---| | `refund_50_allow` | `support` calls `stripe.create_refund`, approved, `amount_usd = 50.00` | `allow`, `support-refund-cap` | | `refund_500_deny` | Same, `amount_usd = 500.00` | `deny`, no matched policy | | `refund_50_unapproved_needs_approval` | Same at `50.00` but `approved = false` | `deny`, `prod-destructive-needs-approval` | | `prod_delete_forbid` | `admin` calls `github.delete_repo` in prod, unapproved | `deny`, `prod-destructive-needs-approval` | | `list_visible` | `support` lists `stripe.create_refund` | `allow`, `agents-list-and-complete` | | `payout_list_forbidden` | `support` lists `stripe.update_payout` | `deny`, `no-payout-changes` | | `payout_change_forbidden_for_admin` | `admin` calls `stripe.update_payout`, approved | `deny`, `no-payout-changes` | Run the suite, or validate a pack by hand: ```bash cargo run -p obol-policy-check -- test --fixtures packages/cedar/fixtures cargo run -p obol-policy-check -- validate \ --policy packages/cedar/policies/support-agent.cedar \ --entities packages/cedar/fixtures/entities.json ``` A fixture expectation matches when the decision is equal and every expected policy id appears in `matched_policies`. Related: [Policy overview](/policy/overview), [CEL prefilter](/policy/cel-prefilter), [Publishing](/policy/publishing), [Receipts](/receipts/overview), [Virtual keys](/security/virtual-keys).