Enforcer v3 docs / Authorization for AI Agents
For AI agents
Authorization for AI agents
An agent acting for a user is not the same as the user. It needs its own identity, only the permissions it actually needs, and the ability to be switched off without switching off the person it works for.
Most authorization products are a decision layer: you hand them a subject id and a resource, and they answer true or false. Enforcer resolves the caller first and puts that object into the policy, so a rule is written over a real accessor with its roles, tenant and group memberships already attached. You are not maintaining a second copy of who everyone is.
Give the agent its own identity
Create the agent as an account in your tenant and put it in a group. The group is what rules read, so changing what every agent may do is one membership change rather than an edit to each rule.
Gate the action, not the prompt
A rule in a system prompt is advice. The model can talk itself past it, and a prompt injection can talk it past it faster. Put the check where the action actually happens, in the code that runs the tool, so the decision is made outside anything the model can reach.
curl -sS -X POST "$BASE/authz/check" \
-H "Authorization: Bearer $AGENT_JWT" \
-H "Content-Type: application/json" \
-d '{ "action": "issue",
"resource": { "type": "payment", "id": "pay_123",
"owner_id": "",
"tenant_id": "" },
"contexts": { "amount": 820 } }'
# -> { "success": true, "allow": false, "reason": "..." }
# policy_id comes back only when you sent one; it is the policy that decided.
Act on allow. Show or log reason; it is the difference between an agent that says "that failed" and one that says why. The caller's identity comes from the token, so you never send it and the agent cannot claim to be someone else.
Make your own rule the decision
The check above ran the built-in policy, which answers ownership and tenancy only. A business threshold like a refund ceiling lives in a policy you store once and then name on the call. Two steps.
1. Store the rule. Needs admin or tenant admin; an ordinary user gets 403 requires tenant admin. Pass active: false explicitly, because active defaults to true on create.
curl -sS -X POST "$BASE/policies" \
-H "Authorization: Bearer $ADMIN_JWT" \
-H "Content-Type: application/json" \
-d '{ "name": "agent-refund-ceiling",
"active": false,
"tenant_id": "'"$TENANT_ID"'",
"source": "package enforcer.custom\n\nimport rego.v1\n\ndefault allow := false\ndefault reason := \"over_refund_ceiling\"\n\nallow if {\n\tinput.action == \"issue\"\n\tinput.resource_type == \"payment\"\n\tinput.resource.tenant_id == input.accessor.tenant_id\n\tinput.contexts.amount <= 500\n}\n\nreason := \"within_refund_ceiling\" if allow\n" }'
# -> 201 { "success": true, "data": { "id": "<POLICY_ID>", ... } }
Every custom policy declares package enforcer.custom and defines allow. Enforcer reads allow and reason out of that package; an undefined document is a deny. It compiles at write time, caps at 64KB, and each decision is bounded to 100ms. Your policy sees input.accessor (the resolved caller: account_id, role, tenant_id, permissions, groups, verifications), input.action, input.resource_type, input.resource and input.contexts.
2. Name it on the check. Without policy_id the built-in policy decides and contexts is ignored.
curl -sS -X POST "$BASE/authz/check" \
-H "Authorization: Bearer $AGENT_JWT" \
-H "Content-Type: application/json" \
-d '{ "policy_id": "'"$POLICY_ID"'",
"action": "issue",
"resource": { "type": "payment", "id": "pay_123", "tenant_id": "'"$TENANT_ID"'" },
"contexts": { "amount": 820 } }'
# -> { "success": true, "allow": false, "reason": "over_refund_ceiling", "policy_id": "<POLICY_ID>" }
Drop amount to 400 and the same call returns "allow": true, "reason": "within_refund_ceiling". Both outcomes are pinned by a test in the backend, so this example cannot drift from the engine.
Deny is the default, on top of a fixed baseline
A decision that matches nothing is refused, and a policy that matches nothing returns allow: false. What is refused is not everything you have not written a rule for. Before any rule of yours runs, a built-in baseline already allows every caller to read and write rows they own, read the groups and group types in their own tenant, read their own audit events, and read global policies. The admin role sits above all of it and is allowed everything.
The baseline does not read the action name. Any action other than manage counts as a write, so a caller acting on a resource they own is allowed whether the action is read, issue, refund, or a name you invented this morning. Adding a tool that acts on the caller's own resources therefore does widen what an agent can do unless one of your rules constrains it.
To constrain a specific action, write the rule and name it on the call with policy_id. A named policy replaces the built-in decision for that call rather than adding to it, so the rule you name is the whole decision.
One more thing worth knowing: on POST /authz/check the resource is a description you send, not a row we look up. owner_id and tenant_id are taken at face value, so send values your own application resolved from its datastore, never values that reached you from the caller.
Start switched off
Create every rule with active: false and switch it on with PUT /policies/{id} once the wording is agreed. A rule that starts blocking the moment it exists is a rule nobody dares create.
An inactive rule is stored and not evaluated, so it produces nothing to watch. GET /audit-events is the identity and administration trail (sign-ins, API key lifecycle, role, tenant and terms changes); it does not record authorization decisions. To keep a decision trail, log the reason and policy_id that the check returns, at your call site.
Creating a policy requires tenant admin. An ordinary user gets 403 requires tenant admin, so have an admin create the rule, or read the global rules with GET /policies first.