Skip to main content

AI agents

Cedar vs Open Policy Agent vs OpenFGA: Choosing a Policy Engine for AI Agent Authorization

Cedar suits per-call agent rules, OPA suits teams already on Rego, OpenFGA suits who-owns-what questions. We ran one refund-agent rule set in all three.

12 min read
Diagram on a dark grid: one tool call card (issue_refund, order O-1001, $250, production token, approval AP-77 in amber) connected to three engine cards labelled Cedar, OPA Rego and OpenFGA, each ending in a green ALLOW chip.

In October we ran twelve independent tests against the governance layer of Ware, our simulated warehouse with a real AI recovery agent. One of them found that the agent could take a packer off a bench that was packing another customer's order, and the policy let it, because the agent held delegated authority and nobody had written down "but not for someone else's order". The fix was one rule. Finding the gap took a test someone thought to write.

That is the real job of a policy engine for AI agents: hold the rules the model must not be trusted with, and make missing rules easy to spot. So which one should you use? Short answer: Cedar is the best fit for checking each agent tool call against conditions (amounts, environments, approvals), and is what AWS uses for Bedrock AgentCore Policy. OPA fits if your team already runs Rego elsewhere. OpenFGA is the right tool when the hard question is "who is related to which object" across millions of records, and it is weaker on numeric limits. Plenty of teams will run two.

So I wrote one refund-agent rule set in all three engines and ran the same seven test cases. Every screenshot below is real output.

Key takeaways

  • All three engines got the same seven answers. The difference was how much each made me work, and what each would have caught if I had made a mistake.
  • Cedar's schema validator caught my typo before anything ran. OPA caught the same typo only once I handed it a JSON schema. That is a real option, but you have to choose it.
  • OpenFGA handled the $100 limit through CEL conditions. It worked, but leave out a parameter and the check returns an error rather than a deny, so your gateway must treat errors as "no".
  • None of them consumes an approval. "Approved once" means single use only if your tool service enforces it in the same transaction as the write.

The rule set: a refund agent with five rules

Here is the spec, as a support lead would say it:

The refund agent may read orders. It may issue refunds up to $100 on its own. Above $100 only with a recorded approval for that exact refund. Never in production on a staging token. Never for an order belonging to another tenant.

It has a numeric limit, a human decision bound to one action, an environment check and a tenancy boundary: most of what goes wrong with agents. If you have read our AI governance approach, this is "policy as code, deny by default" made concrete.

The seven test cases: read an own-tenant order (allow), refund $40 (allow), refund $250 with no approval (deny), refund $250 with an approval for exactly $250 on that order (allow), refund $250 with an approval that was for $100 (deny), refund $40 with a staging token against production (deny), and refund $40 on another tenant's order (deny).

Amounts are in cents, as integers, in all three engines. Floating-point money inside an authorisation rule is a bug you only find at month end.

How does Cedar express agent rules?

Cedar is AWS's open-source authorisation language. A request is principal, action, resource and context (often shortened to PARC). Policies are permit or forbid. Three properties matter here: if no permit matches, the answer is deny; if any forbid matches, it beats every permit; and the order of the policies never matters.

That is why the two "never" rules read so cleanly:

@id("read-own-tenant")
permit (principal is Agent, action == Action::"ReadOrder", resource is Order);

@id("refund-up-to-100")
permit (principal is Agent, action == Action::"IssueRefund", resource is Order)
when { context has amount_cents && context.amount_cents <= 10000 };

@id("refund-over-100-with-approval")
permit (principal is Agent, action == Action::"IssueRefund", resource is Order)
when {
  context has amount_cents && context.amount_cents > 10000 &&
  context has approval &&
  context.approval.order == resource &&
  context.approval.amount_cents == context.amount_cents
};

@id("never-cross-tenant")
forbid (principal is Agent, action, resource is Order)
unless { resource.tenant == principal.tenant };

@id("never-prod-on-staging-token")
forbid (principal, action, resource)
when { context.target_env == "production" && context.token_env == "staging" };

Notice that the read permit has no tenant check. It doesn't need one. The forbid covers every action on every order, so when someone adds a new action next quarter, it is fenced in from day one. This is the rule Ware was missing.

I ran this with the official @cedar-policy/cedar-wasm package (Cedar 4.14.0) from a small Node script. The approval is an entity (Approval::"AP-77", with order, amount and approver) that the gateway loads and passes in with the request. The script validates the policies against a schema first, then evaluates each case and prints which policy decided it:

Cedar wasm run: schema validation ok, then seven allow or deny decisions, each naming the policy that decided it

The diagnostics are the quiet win. A denied refund names never-cross-tenant, not just "false". When an agent gets refused at 2am, that is the line your on-call engineer needs in the audit log.

Cedar is what Amazon Verified Permissions runs, and it is the language of Policy in Amazon Bedrock AgentCore, which checks every tool call going through an AgentCore Gateway before the tool runs. In AWS's own write-up of why it chose Cedar (May 2026), the gateway "blocks everything by default", and Cedar's partial evaluation is used to hide tools from the agent's tool list when they would always be denied. The model can't try a tool it never sees. The docs now also list a second language, Dogwood, which I haven't tested. Cedar joined the CNCF as a Sandbox project on 8 October 2025.

How does Open Policy Agent handle the same rules?

OPA is the general-purpose option. Rego rules run over any JSON you give it, and the project graduated in the CNCF in January 2021. Rego doesn't impose permit and forbid, so I had to build them myself: a set of deny reasons, and allow only when something is permitted and the deny set is empty.

package refunds

# Default deny: nothing is allowed unless a rule below says so.
default allow := false

allow if {
	permitted
	count(deny) == 0
}

# What the refund agent may do.
permitted if input.action == "read_order"

permitted if {
	input.action == "issue_refund"
	input.context.amount_cents <= 10000
}

permitted if {
	input.action == "issue_refund"
	input.context.amount_cents > 10000
	approval := data.approvals[input.context.approval_id]
	approval.order == input.resource.id
	approval.amount_cents == input.context.amount_cents
}

# What it may never do. Any reason here beats any permit above.
deny contains "order belongs to another tenant" if {
	input.resource.tenant != input.principal.tenant
}

deny contains "staging token cannot act in production" if {
	input.context.target_env == "production"
	input.context.token_env == "staging"
}

That's two lines of convention you have to keep. Nothing in Rego stops a colleague adding an allow if rule that skips the deny set. With Cedar, that mistake can't be written. With OPA, it is a code review comment.

What you get back is flexibility. The deny set is a list of human-readable reasons, so one call can return everything wrong with a request. Here is opa test with the same seven cases written as Rego tests, then opa eval on a request that breaks two rules at once (OPA 1.22.0, the official static binary):

opa test passing seven of seven cases, then opa eval returning allow false with two deny reasons

Can OpenFGA do numeric limits and approvals?

OpenFGA comes from a different family. It is modelled on Google's Zanzibar: you store relationship tuples ("tenant acme is the tenant of order O-1001", "refund-bot is a member of acme") and ask whether a relationship path exists. It moved to CNCF Incubating on 28 October 2025.

The tenancy rule is what it was built for. member from tenant reads as "a member of whatever tenant this order belongs to", and it holds at millions of orders without anyone passing entities in.

The numbers are the awkward part. OpenFGA handles them with conditions, CEL expressions attached to tuples and evaluated with context sent on the check:

model
  schema 1.1

type user

type agent

type tenant
  relations
    define member: [agent with token_env_ok]
    define auto_refunder: [agent with within_limit]

type order
  relations
    define tenant: [tenant]
    define approved_refunder: [agent with approval_matches]
    define can_read: member from tenant
    define can_refund: member from tenant and (auto_refunder from tenant or approved_refunder)

condition token_env_ok(token_env: string, target_env: string) {
  !(target_env == "production" && token_env == "staging")
}

condition within_limit(amount_cents: int, limit_cents: int) {
  amount_cents <= limit_cents
}

condition approval_matches(amount_cents: int, approved_amount_cents: int) {
  amount_cents == approved_amount_cents
}

It works. The approval becomes a tuple, refund-bot approved_refunder order:O-1001, carrying the approved amount. The $100 limit lives on the auto_refunder tuple. The staging-token rule, though, is a "never" squeezed into a condition on membership, and that is a hack. OpenFGA has no forbid. Its exclusion operator (but not) is the honest tool, fed by a contextual tuple that describes the token. Either way, you are writing an attribute rule in a relationship language.

I ran the official openfga server (v1.22.1) in memory, imported the store with the fga CLI (v0.8.2), and ran the checks by hand, writing the $100 approval first and then the $250 one. Then the same cases as a fga model test file:

fga query check results against an in-memory OpenFGA server, a missing-context validation error, and fga model test passing 7 of 7 checks

Look at the last check. I left out amount_cents and got an error instead of false: "missing context parameters". That is correct behaviour, but it means your gateway has to treat every error as a deny. A gateway written as if (res.allowed === false) block() lets that call straight through.

Which engine catches policy mistakes before production?

This matters more for agents than for human users. AWS's AgentCore lets you describe a policy in plain English and have a model write the Cedar. So the policy itself may now come from an LLM, and a policy you cannot check is a prompt with extra steps. (We made the same argument in system prompts are not security controls.)

I made the same typo in both languages: context.amount instead of amount_cents. In the Cedar file I also compared a tenant to a string.

Cedar validation rejecting two policy mistakes; opa check passing the same typo without a schema and rejecting it with one

Cedar refused both, because the schema is part of how you normally use it. Plain opa check passed the broken policy with exit code 0. At runtime, the broken rule would never match, so the agent would be denied every small refund. That fails closed, but you'd learn about it from a support queue. With a JSON schema passed to opa check, OPA caught it with a better error message than Cedar's. The capability is there. It just isn't on by default, and in my experience defaults are what ship.

Beyond typos, Cedar has Cedar Analysis, which encodes policies as formulas and can tell you that a change made a policy set more permissive, or that a permit can never fire. I didn't run it for this piece. AWS describes using it when policies are attached to an AgentCore Gateway. OPA and OpenFGA give you tests, and tests only cover the cases someone thought to write. Ware's cross-tenant gap is the example.

Cedar vs OPA vs OpenFGA at a glance

Comparison table of Cedar, OPA Rego and OpenFGA across eight rows

Cedar OPA (Rego) OpenFGA
Model Principal, action, resource, context Rules over any JSON input and data Relationship tuples, plus CEL conditions
If nothing matches Deny Whatever your default says Deny (no path)
Conflicts forbid always wins You write the precedence No forbid; use but not
$100 limit Native Native Condition; context needed on every call
Tenant of 10M orders You pass the entities in You load or fetch the data Native: stored tuples
Catching a typo Schema validator, built in Opt-in JSON schema Typed model; missing context is an error
Formal analysis Cedar Analysis (SMT) Tests Model tests
CNCF Sandbox (2025) Graduated (2021) Incubating (2025)

When should you pick which, or combine them?

Pick Cedar when the rules are mostly about the call itself: tool, arguments, amounts, environment, the identity in the token. That is most agent tool authorisation. If you are on AWS and routing tools through AgentCore Gateway, the decision is already made for you.

Pick OPA when you already have Rego in production and people who are good at it. Budget a day to set up the permit/deny convention and the schema checks, and make both mandatory in CI.

Pick OpenFGA when the hard question is relational. Which customer owns this order? Is this agent acting for someone who can see that folder? Questions like these change with every write in your system and don't fit in a request payload.

Combining them is less exotic than it sounds. OpenFGA answers "is this order in a tenant this agent may act for?" and passes the answer into the Cedar or OPA context as a boolean; Cedar or OPA checks the amount, approval and environment. It is two systems to run, so only do it when the relationship graph is too big to pass in.

Where should the policy check sit?

Two places, doing different jobs.

Diagram: agent, gateway as gate one, refund service as gate two with an amber approval record, then payments, with an audit log

The gateway (AgentCore Gateway, an MCP proxy, your own API layer) is the right place for coarse rules. Is this tool allowed for this agent at all? Is a staging token trying to reach production? It is central, it sees all traffic, and the agent cannot route around it.

The tool service is where the fine rules have to be enforced, because it holds the data and the transaction. This is the lesson from our other Ware finding: an approval was checked in one transaction and the action executed in another, and a change in between went unseen. None of the three engines can fix that, because none of them consumes an approval. Cedar said "allow" to AP-77 every time I asked. So did OpenFGA, for as long as the tuple existed. Single use, and binding to the exact arguments and the state version they were approved against, have to live in the service that writes. Our human-in-the-loop guide goes through how.

The engine is the easy part. The harder part is writing rules that say "never" clearly, testing the cases nobody thought of, and treating every error as a no. That is the work of the Access Layer in our AI agent builds.

Frequently asked questions

Is Cedar better than OPA for AI agents?

For checking individual tool calls, usually yes. Default deny and forbid-wins are built in, the schema validator catches mistakes before deployment, and Cedar Analysis can compare policy versions. OPA can do the same job if you enforce a deny convention and schema checks yourself, and it wins if your team already runs Rego.

What does Bedrock AgentCore Policy use?

Cedar. AgentCore Policy evaluates Cedar policies at the AgentCore Gateway before any tool call reaches the tool, denies by default, and can write Cedar from natural language, then validates it with Cedar's schema checks and automated reasoning. The docs also list Dogwood as a second supported language.

Can OpenFGA enforce a spending limit like "refunds up to $100"?

Yes, with a CEL condition on a tuple and the amount sent as context on each check. It worked in our test. But a check that is missing a context parameter returns an error, not a deny, so the caller must fail closed.

Do I need both OpenFGA and Cedar?

Only if your relationship data is too large or changes too fast to pass in with each request. Then OpenFGA answers the relationship question and Cedar or OPA handles amounts, approvals and environments.

Does a policy engine make approvals single use?

No. All three will keep saying allow for the same approval. Marking an approval as used, in the same transaction as the write, is the tool service's job.

One email when we publish something worth your time

No cadence, no drip sequence. We write when we have learned something running agents in production, which is not weekly.