DEV Community

Cover image for Writing Agent Authorization as Code with Cedar
Dushyanth V
Dushyanth V

Posted on Originally published at dvlog.dev

Writing Agent Authorization as Code with Cedar

Previously in this series:

  • Alice asked the support assistant to summarize a ticket. A line hidden in that ticket made the model emit a write to tickets.globex.
  • An agent with one broad service account is a confused deputy. The fix is to authorize every tool call: permits are the intersection of grants (user, agent, tenant, and the scope the request handed over), and forbids are invariants (the resource’s tenant, the request scope, the depth, the approval).
  • The check lives in one gateway, outside the model. Alice’s injected call is waiting there now.

Delete one Cedar policy and an injected cross-tenant write sails through. In Part 1, an instruction hidden in a support ticket made Alice’s assistant try to close Globex’s tickets from Acme’s tenant. The agent is real, Alice is real, and both may write tickets. What decides is a policy: a rule, written as code, that the gateway evaluates on every call. This part writes those rules in Cedar, replays Alice’s read and the injected write against them, and then tries to break them.

Every policy, test, and CLI output in this series is real: the site's build runs them on every deploy, so a snippet can’t drift from its output.

Why the rules are code, and why Cedar

The usual alternative is a role check inside each handler: an if on the user’s role at the top of every tool. That scatters the decision, and a bug fixed in one handler survives in the other nine. Write the rules once, in a policy language, and ship them with the binary. At startup, refuse to run if a policy fails to parse or validate against the schema.

I use Cedar here for two reasons. Its schema lets you validate policies before they ship, so a malformed rule does not wait in production for the request that hits it. And its decision model is small: deny by default, permits allow, and a forbid overrides any permit. OPA with Rego is the main alternative. The pattern maps onto it with one difference, covered below, and the pattern matters more than the engine.

Going deeper: what changes if you use Rego, and what Cedar adds

Rego has no built-in “forbid overrides permit”. allow and deny are ordinary rule names, and the OPA documentation is explicit that if you want them to conflict, you control which one wins. The usual pattern is a third rule that holds when allow is true and deny is not, with a default of false. It works. The catch is that the override is something your policy text must get right, in every package, instead of something the engine guarantees.

Cedar also has analysis tooling beyond tests. Its symbolic compiler turns a policy set into constraints for an SMT solver and can check properties of the whole set: that it never errors, that one set is never more permissive than another, that two sets are equivalent. I do not use it in this series. It is the next step once tests stop being enough, and a later post takes it.

The schema, and two modeling choices that matter

schema.cedarschema

// Grants are data: sets of tool groups stored on the tenant, the user and
// the agent. Policy text never names a tenant.
entity ToolGroup;

entity Tenant = {
  read_groups: Set<ToolGroup>,
  write_groups: Set<ToolGroup>,
};

entity User in [Tenant] = {
  read_groups: Set<ToolGroup>,
  write_groups: Set<ToolGroup>,
};

// The workload. Its identity comes from its certificate, not from the prompt.
entity Agent = {
  read_groups: Set<ToolGroup>,
  write_groups: Set<ToolGroup>,
};

// A tool bound to one tenant's data.
entity Tool in [ToolGroup] = {
  tenant: Tenant,
};

// Built by the gateway from verified facts, never copied from the request body.
type CallContext = {
  user: User,          // the human the chain started with
  tenant: Tenant,      // the tenant bound at authentication
  depth: Long,         // agent-to-agent hand-offs so far; the relay counts, never the agent
  // The scope of the exchanged token this hop holds. Required on every call: at
  // depth 0 it is what the user's request handed to the first agent.
  delegated_read_groups: Set<ToolGroup>,
  delegated_write_groups: Set<ToolGroup>,
  // The exact call and the clock, both stamped by the gateway and never read from the
  // request body. request_hash is sha256 over tenant, user, agent, tool, action and the
  // canonical arguments, so an approval is bound to one call and checked again when the
  // call runs.
  request_hash: String,
  now: datetime,
  approved_by?: User,          // set only by the approval step
  approval_for?: String,       // the request hash the approver saw
  approval_expires?: datetime, // when the approval stops being an approval
};

// Actions are risk levels. They are enumerated, never negated.
action read, write, destroy appliesTo {
  principal: Agent,
  resource: Tool,
  context: CallContext,
};
Enter fullscreen mode Exit fullscreen mode

Two choices carry weight. Actions are risk levels, enumerated and never negated: read, write, destroy. A rule that says “everything except destroy” quietly permits the next action someone adds. And tools belong to groups. Policy decides per group, so it stays reviewable as tools multiply.

Notice the CallContext too. The gateway builds it from verified facts, in the ordered steps from Part 1, never from the request body. That is why context.tenant can be trusted and a tenant named by the model cannot. Ignore depth for now; Part 3 explains it. The delegated_ sets return in the second replay below, and request_hash, now and the three approval fields in the next section.

Grants are data: Alice and Bob

Per-tenant variation is data, not policy. Grants are attributes on entities, loaded per request, and no tenant’s name appears in policy text. Here is Alice, who may read and write tickets:

entities.json

{
  "uid": { "type": "User", "id": "alice" },
  "attrs": {
    "read_groups": [{ "__entity": { "type": "ToolGroup", "id": "tickets" } }],
    "write_groups": [{ "__entity": { "type": "ToolGroup", "id": "tickets" } }]
  },
  "parents": [{ "type": "Tenant", "id": "acme" }]
}
Enter fullscreen mode Exit fullscreen mode

And Bob, also at Acme, who may only read:

entities.json

{
  "uid": { "type": "User", "id": "bob" },
  "attrs": {
    "read_groups": [{ "__entity": { "type": "ToolGroup", "id": "tickets" } }],
    "write_groups": []
  },
  "parents": [{ "type": "Tenant", "id": "acme" }]
}
Enter fullscreen mode Exit fullscreen mode

Signing a new customer, or changing what Bob may do, is a data change. Nobody edits, reviews, or redeploys a policy for it.

Three rules, six policies

Tenant isolation; read and write tools (one rule, written as two policies, one per risk level); and destructive actions that need a second person (one rule, three policies: who approves, for which call, until when).

policies.cedar (excerpt: six of the eight policies)

// 1. Tenant isolation. A forbid, so no permit written later can override it.
@id("tenant-isolation")
forbid (principal, action, resource)
unless {
  resource.tenant == context.tenant &&
  context.user in context.tenant
};

// 2. Read vs write tools. Agent AND user AND tenant must hold the grant.
@id("read-tools")
permit (principal, action == Action::"read", resource)
when {
  resource in principal.read_groups &&
  resource in context.user.read_groups &&
  resource in context.tenant.read_groups
};

@id("write-tools")
permit (principal, action in [Action::"write", Action::"destroy"], resource)
when {
  resource in principal.write_groups &&
  resource in context.user.write_groups &&
  resource in context.tenant.write_groups
};

// 3. Destroy needs an approver from the same tenant who is not the requester and
//    who holds the write grant for the tool: someone who could have done it alone.
@id("destructive-needs-approval")
forbid (principal, action == Action::"destroy", resource)
unless {
  context has approved_by &&
  context.approved_by != context.user &&
  context.approved_by in context.tenant &&
  resource in context.approved_by.write_groups
};

// 4. An approval is for one call. The approver saw a hash of the exact call; the call
//    that runs must have the same hash. An approval presented without one binds nothing.
@id("approval-matches-call")
forbid (principal, action, resource)
when {
  context has approved_by &&
  (if context has approval_for then context.approval_for != context.request_hash else true)
};

// 5. An approval expires. It is checked when the call runs, not only when it was
//    given. An approval with no expiry never started.
@id("approval-is-fresh")
forbid (principal, action, resource)
when {
  context has approved_by &&
  (if context has approval_expires then context.approval_expires <= context.now else true)
};
Enter fullscreen mode Exit fullscreen mode

The downloadable policies.cedar holds two more forbids: request-scope, the scope this hop holds, explained below and narrowed per hop in Part 3; and delegation-depth, which Part 3 explains.

The two permits are the intersection from Part 1, written out. The agent, the user, and the tenant must each hold the grant.

There is no destroy grant. Destroy rides on the write grant, and the third rule adds two conditions on top: an approver from the same tenant who is not the requester, and who holds the write grant for the tool, so the approver could have done the destroy alone. The second condition arrived after a proof showed that the rule without it accepted an approver who could not.

The three approval fields in the context come from one signed approval object. The gateway verifies the signature before the engine sees anything, then copies the approver, the request hash the approver saw, and the expiry into the context. The two forbids after the rule compare them with the call that is running now: its hash, which the gateway computes over the tenant, the user, the agent, the tool, the action and the canonical arguments, and the gateway’s clock. An approval for another call, or a stale one, is a named deny. Single use cannot be expressed in policy. The gateway burns the approval’s nonce after a permit; Part 3 describes that store.

The replay: Alice’s read, then the injected write

Here is the read Alice asked for, as the gateway hands it to the engine:

terminal

cedar authorize --verbose \
  --schema schema.cedarschema --policies policies.cedar \
  --entities entities.json --request-json requests/alice-read.json
Enter fullscreen mode Exit fullscreen mode

output

ALLOW

note: this decision was due to the following policies:
  read-tools
Enter fullscreen mode Exit fullscreen mode

Now the call the injection produced. Same agent, same user, same bound tenant. Only the action and the tool differ:

requests/injected-write.json

{
  "principal": "Agent::\"support-assistant\"",
  "action": "Action::\"write\"",
  "resource": "Tool::\"tickets.globex\"",
  "context": {
    "user": { "__entity": { "type": "User", "id": "alice" } },
    "tenant": { "__entity": { "type": "Tenant", "id": "acme" } },
    "depth": 0,
    "now": { "__extn": { "fn": "datetime", "arg": "2026-10-27T09:12:00-0400" } },
    "request_hash": "sha256:766666",
    "delegated_read_groups": [{ "__entity": { "type": "ToolGroup", "id": "tickets" } }],
    "delegated_write_groups": [{ "__entity": { "type": "ToolGroup", "id": "tickets" } }]
  }
}
Enter fullscreen mode Exit fullscreen mode

output

DENY

note: this decision was due to the following policies:
  tenant-isolation
Enter fullscreen mode Exit fullscreen mode

Both the agent and Alice hold the write grant. The call is denied anyway, and the answer comes with the IDs of the matching policies. Here that is the one policy that said no.

The agent sends a read for its own tenant to the gateway, and the gateway calls the tool. Then an injected write aimed at another tenant arrives at the gateway. The gateway denies it with the tenant-isolation policy and never calls the tool. The gateway writes the decision to the decision log.

Figure 1. The same sequence, step by step: a permitted read, an injected cross-tenant write, the deny at the gateway, the logged decision.

Animated version on dvlog.dev

The injection that stays inside the tenant

The same hidden line could have said “close every open ticket for Acme”. No tenant boundary is crossed, so tenant-isolation has nothing to say. What stops it is the scope the request handed over. Alice asked for a summary. A summary needs no write scope, so the scope minted for the first agent (depth 0) is read-only, and a write is outside it:

requests/injected-write-same-tenant.json

{
  "principal": "Agent::\"support-assistant\"",
  "action": "Action::\"write\"",
  "resource": "Tool::\"tickets.acme\"",
  "context": {
    "user": { "__entity": { "type": "User", "id": "alice" } },
    "tenant": { "__entity": { "type": "Tenant", "id": "acme" } },
    "depth": 0,
    "now": { "__extn": { "fn": "datetime", "arg": "2026-10-27T09:12:00-0400" } },
    "request_hash": "sha256:1a1a1a",
    "delegated_read_groups": [{ "__entity": { "type": "ToolGroup", "id": "tickets" } }],
    "delegated_write_groups": []
  }
}
Enter fullscreen mode Exit fullscreen mode

output

DENY

note: this decision was due to the following policies:
  request-scope
Enter fullscreen mode Exit fullscreen mode

Tenant isolation answers whose data. Request scope answers what the user asked for. Two invariants, two forbids, two names in the log. The model is grant intersection plus resource-tenant invariants: the permits say what the user, the agent and the tenant hold, and the forbids say what no grant may cross.

The cross-tenant fixture above keeps its write scope on purpose. With the scope wrong, tenant isolation holds alone, and the twist below shows how much rests on it. The reverse holds too: the build runs this request with tenant-isolation deleted, and request-scope denies it by itself.

One caveat. The depth-0 scope must come from the entrypoint or the task type (a summarize endpoint issues a read-only scope), or be confirmed by the user. The model never chooses it, or the confused deputy is back. Part 3 covers minting that scope and narrowing it across hops.

The twist: delete one policy and the write goes through

How much work is tenant-isolation doing? Remove it and run the same request again. The build does exactly this: the same request file, against a copy of the policy set with that one policy deleted.

output, with tenant-isolation removed

ALLOW

note: this decision was due to the following policies:
  write-tools
Enter fullscreen mode Exit fullscreen mode

ALLOW, credited to write-tools. Read that permit again. It asks whether the agent, the user, and the tenant hold the write grant for the tickets group, and they all do. It never asks whose tickets these are. Nothing is wrong with the permit. It just isn’t the rule that knows about tenants.

This is why forbid beats permit, and why invariants should be forbids. In Cedar, one matching forbid overrides any number of permits. You could write tenant isolation as a condition inside each permit instead. It would hold until someone adds a permit and forgets the condition. As a forbid it holds over every permit written later, including the careless one.

Bob’s unnamed deny

Bob asks the assistant to update a ticket. He has no write grant:

output

DENY

note: no policies applied to this request
Enter fullscreen mode Exit fullscreen mode

Also a deny, but look at what is missing: a name. No permit matched and no forbid fired, so there is no policy ID to log. A default deny can’t explain itself. It can only report that nothing said yes.

That is fine for Bob. It is not fine for a rule you care about. A forbid has an ID, and that ID shows up in the decision, in the log, and in your tests. If a deny matters, give it a name by making it a forbid.

Tests that lie

The deny that happened for the wrong reason. Cedar skips a policy it cannot evaluate and decides without it. In other words, a forbid that errors fails open, which is why every test asserts num_errors: 0. Here is a write to a tool the entity loader never registered:

output

DENY

error while evaluating policy `tenant-isolation`: entity `Tool::"tickets.unregistered"` does not exist

note: this decision was due to the following policies:
  request-scope
Enter fullscreen mode Exit fullscreen mode

A test that asserts only “deny” passes. But read the output. tenant-isolation did not fire. It errored, because the tool does not exist, and it was skipped. The deny came from request-scope, the forbid from the replay above that denies any call to a tool outside the scope this hop holds. An unregistered tool belongs to no group, so it fired. The rule this test is about never ran. The call was stopped by luck, not by tenant isolation.

The gateway from Part 1 refuses an unregistered tool at step 4, before policy runs. This replay goes straight to the engine to show what happens if that step ever regresses.

The same rule at runtime. The gateway applies it to every answer the engine returns, before it looks at the decision:

src/lib.rs (Part 1's crate)

/// The engine's answer, reduced to what the rule below needs. These types stand in for
/// `cedar_policy::Response` and `cedar_policy::Diagnostics` (4.x) with the same method
/// names, so `conclude` reads as it would against the crate. The crate itself is not a
/// dependency: this example is standard library only.
pub struct Response {
    decision: Decision,
    diagnostics: Diagnostics,
}

/// What the engine decided from the policies it could evaluate.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Decision {
    Allow,
    Deny,
}

/// The policies behind the decision, and the policies that errored and were skipped.
#[derive(Default)]
pub struct Diagnostics {
    reason: Vec<String>,
    errors: Vec<String>,
}

impl Response {
    pub fn new(decision: Decision, reason: Vec<String>, errors: Vec<String>) -> Self {
        Response {
            decision,
            diagnostics: Diagnostics { reason, errors },
        }
    }

    pub fn decision(&self) -> Decision {
        self.decision
    }

    pub fn diagnostics(&self) -> &Diagnostics {
        &self.diagnostics
    }
}

impl Diagnostics {
    /// The IDs of the policies that determined the decision.
    pub fn reason(&self) -> impl Iterator<Item = &str> {
        self.reason.iter().map(String::as_str)
    }

    /// One entry per policy the engine could not evaluate. Each one was skipped.
    pub fn errors(&self) -> impl Iterator<Item = &str> {
        self.errors.iter().map(String::as_str)
    }
}

/// What the gateway does with the engine's answer. Any evaluation error is a deny,
/// whatever the decision says: an errored policy was skipped, and a skipped forbid fails
/// open. The caller matches the decision only once this has passed.
pub fn conclude(resp: &Response) -> Result<Decision, Deny> {
    if resp.diagnostics().errors().next().is_some() {
        log_errors(resp);
        return Err(Deny {
            policy: "evaluation-error",
        });
    }
    Ok(resp.decision())
}

/// The errors go to the decision log with the names of the policies that did run, so the
/// skipped one can be found. Never the request body.
fn log_errors(resp: &Response) {
    for e in resp.diagnostics().errors() {
        eprintln!("evaluation error: {e}");
    }
    let ran: Vec<&str> = resp.diagnostics().reason().collect();
    eprintln!("policies that determined the decision: {ran:?}");
}
Enter fullscreen mode Exit fullscreen mode

num_errors: 0 in the tests is this rule applied early. The runtime check is what holds when the entity store regresses. The types here stand in for the Cedar crate’s Response and Diagnostics with the same method names; the example is standard library only, and the build compiles and tests it.

Schema validation does not save you here. Cedar’s validation guide lists missing entities among the errors that validation cannot rule out.

Assert reasons and num_errors. So every test case names the decision, the policies that must be behind it, and the number of evaluation errors:

tests.json (one case, entities omitted)

{
  "name": "injected write to another tenant is denied by tenant-isolation",
  "request": {
    "principal": "Agent::\"support-assistant\"",
    "action": "Action::\"write\"",
    "resource": "Tool::\"tickets.globex\"",
    "context": {
      "user": { "__entity": { "type": "User", "id": "alice" } },
      "tenant": { "__entity": { "type": "Tenant", "id": "acme" } },
      "depth": 0,
      "now": { "__extn": { "fn": "datetime", "arg": "2026-10-27T09:12:00-0400" } },
      "request_hash": "sha256:120202",
      "delegated_read_groups": [{ "__entity": { "type": "ToolGroup", "id": "tickets" } }],
      "delegated_write_groups": [{ "__entity": { "type": "ToolGroup", "id": "tickets" } }]
    }
  },
  "decision": "deny",
  "reason": ["tenant-isolation"],
  "num_errors": 0
}
Enter fullscreen mode Exit fullscreen mode

cedar run-tests fails a case when a named policy is missing from the reasons or when the error count differs.

The canary fixture. The build also runs one fixture that is wrong on purpose. It expects the unregistered-tool deny above to be credited to tenant-isolation. That fixture must fail. If it ever passes, the suite has stopped telling the two kinds of deny apart, and the build stops.

Mutation testing. This means breaking the code on purpose to see whether the tests notice. The build deletes each policy in turn, then each guard (each condition joined by &&) in turn, and reruns the tests. Every one of those broken copies must fail at least one test. If you delete the tenant check and no test fails, the tests cannot fail. Several cases in the suite exist only because this check demanded them: each switches a single grant off to prove that one guard matters.

One limit to remember: validation is not a proof of narrowness. A well-typed but over-broad policy validates cleanly. Only a test, or analysis, will tell you it allows too much.

Try it yourself

Try it yourself.

Install the Cedar CLI with cargo install cedar-policy-cli --version 4.13.0 (tested with Cedar CLI 4.13.0, the version the site's build pins), download the files below into one directory, and run:

terminal

cedar validate --schema schema.cedarschema --policies policies.cedar
cedar run-tests --schema schema.cedarschema --policies policies.cedar --tests tests.json

The clean run ends with the first line below. The canary file, tests-wrong-reason.json, is not in that count: the build runs it on its own and requires it to fail. Then delete the tenant-isolation policy from your copy and run the tests again. The run should end with the second line. The five requests/*.json files are the calls this post replays; cedar authorize takes each one with --request-json, as shown above.

output

results: 28 passed, 0 failed
Enter fullscreen mode Exit fullscreen mode

output, with tenant-isolation removed

results: 24 passed, 4 failed
Enter fullscreen mode Exit fullscreen mode

These are the files the site's build tests; download them from there, not from a copy. tests.json carries the entities inside every case, which is the form cedar run-tests takes. The delegated_* fields are explained in Part 3.

Takeaways

  • Write authorization as policy, validate it against a schema, and refuse to start if it does not validate.
  • Keep grants in data and invariants in forbids. Permits are the intersection of grants (user, agent, tenant, and the scope the request handed over); forbids are invariants (the resource’s tenant, the request scope, the depth, the approval). A forbid outranks every permit, and it has a name.
  • A deny is not evidence. Assert why the call was denied and that nothing errored.
  • Break your own policies on purpose. A guard that no test depends on is a guard you can lose without noticing.

But Alice’s assistant doesn’t work alone: it hands her ticket to a second agent, and now the question is who that one is acting as.

Originally published at dvlog.dev. Part 2 of Authorizing AI Agents.

Top comments (0)