Per-tool authorization: scopes, capabilities, policy as code
Move fine-grained decisions out of scopes and into OPA, Cedar, or OpenFGA with the token claims as input.
Why this matters
A token saying "the agent may call any tool" is barely better than no auth. Real agent products check each tool call against fine-grained policy that considers the subject, actor, tenant, resource, action, time, context, and risk. This lesson turns that requirement into running code with OPA, Cedar, and OpenFGA — and shows how capability tokens reduce blast radius further.
Learning objectives
- Distinguish RBAC, ABAC, and ReBAC and pick per use case.
- Express policies in Rego (OPA) and Cedar.
- Model relationships in OpenFGA.
- Issue capability tokens for narrow, single-use authority.
- Wire it all into the broker / MCP server.
1. RBAC, ABAC, ReBAC
- RBAC (Role-Based) — "users in role
support_agentmay read tickets". Simple; coarse; struggles with multi-tenant + per-resource ownership. - ABAC (Attribute-Based) — "if
subject.department == resource.departmentandaction == readandtime < 18:00then allow". Flexible; harder to audit. - ReBAC (Relationship-Based) — "user is
editorof document; document is in folder; folder is owned by tenant". Models real-world ownership graphs. Google Zanzibar's heritage.
In practice you'll mix:
- RBAC for org-wide roles (
tenant_admin,support). - ABAC for context (time, IP, risk score, agent purpose).
- ReBAC for resource ownership chains.
Authoring policy as code in OPA (Rego) or AWS Cedar + relationships in OpenFGA gives you a flexible, testable system.
2. Policy as code with OPA
Centralised decision point. Resource servers send a JSON input; OPA returns allow: true|false plus optional reasons.
# policies/mcp/allow.rego
package mcp
default allow := false
# Read tickets: same tenant, has scope
allow {
input.action == "tickets:read"
input.subject.tenant == input.resource.tenant
input.scope[_] == "tickets:read"
}
# Write tickets: above + agent has 'write' purpose declared
allow {
input.action == "tickets:write"
input.subject.tenant == input.resource.tenant
input.scope[_] == "tickets:write"
input.actor.purpose == "ticket_resolution"
}
# Refunds: require HITL + risk_score < 0.8 + binding token (RAR)
allow {
input.action == "payments:refund"
input.approval.granted == true
input.risk_score < 0.8
valid_rar(input.token.authorization_details, input.args)
}
valid_rar(details, args) {
some d
d := details[_]
d.type == "payment_initiation"
d.instructedAmount.amount == args.amount
}Resource server:
def authorize(action: str, claims: dict, args: dict, resource: dict) -> bool:
inp = {
"action": action,
"scope": claims.get("scope","").split(),
"subject": {"sub": claims["sub"], "tenant": claims["tenant"]},
"actor": claims.get("act", {}),
"resource": resource, "args": args,
"approval": current_approval(claims),
"risk_score": risk_score(claims, args),
"token": claims,
}
r = httpx.post("http://opa:8181/v1/data/mcp/allow",
json={"input": inp}, timeout=0.2).json()
return r.get("result", False)OPA bundles + decision logs are first-class: ship policy as a signed bundle from CI, send each decision to a sink for audit.
3. AWS Cedar
Cedar is a more recent policy language (AWS, 2023) designed for application-level authz with formal analysis. Strongly typed; static reasoner can prove policies don't grant unintended access.
permit (
principal,
action == Action::"tickets:read",
resource
) when {
principal.tenant == resource.tenant &&
context.token.scope.contains("tickets:read")
};
forbid (
principal,
action,
resource
) unless {
context.token.exp > context.now
};The Cedar SDK runs in your app process (no network hop). Tradeoffs vs OPA:
- Cedar: typed schema, formally analysable, single-process latency, smaller community.
- OPA: untyped JSON, separate process, huge community, broader ecosystem (Kubernetes, Envoy, Terraform).
Either is fine for agent IAM; pick one and standardise.
4. OpenFGA — relationship-based access control
For "who can read this document?", encode relationships, not roles. Inspired by Google Zanzibar.
Schema:
model
schema 1.1
type user
type org
relations
define member: [user]
type folder
relations
define owner: [org]
define editor: [user, org#member]
define viewer: [user, org#member, folder#editor]
type doc
relations
define parent: [folder]
define editor: [user, folder#editor]
define viewer: [user, folder#viewer]Write tuples (facts):
fga tuple write user:alice editor doc:42
fga tuple write user:agent.helpdesk-v1 viewer doc:42 # the agent can read
fga tuple write org:acme member user:alice
fga tuple write folder:engineering owner org:acme
fga tuple write doc:42 parent folder:engineeringCheck:
fga query check user:agent.helpdesk-v1 viewer doc:42
# {"allowed": true}Excellent for B2B SaaS agents where access is determined by org membership + sharing rules. Pair with OPA / Cedar for action-level (RBAC/ABAC) checks; OpenFGA handles the resource graph.
5. Capability tokens
A capability is a transferable, narrow permission to perform a specific action on a specific resource. Capability tokens are the operational form: short-lived, narrowly-scoped, single-purpose tokens issued just-in-time.
The pattern:
- Agent decides it needs to call
tickets-api.update(ticket_id=42, status="closed"). - Agent runtime asks the capability service for a capability.
- Capability service checks policy (OPA), HITL approval if required, and mints a token:json
{ "iss": "https://cap.acme.com", "sub": "alice@acme.com", "act": { "sub": "agent.helpdesk-v1" }, "aud": "tickets-api", "cap": { "resource": "ticket:42", "action": "update", "fields": ["status"], "expected_value": "closed" }, "exp": "+60s", "jti": "cap_4f12...", "cnf": { "jkt": "<dpop key thumbprint>" } } - Agent calls
tickets-apiwith the capability token + DPoP. tickets-apivalidates and enforces: must matchcap.resource,cap.action,cap.fields,cap.expected_value. Reject anything else.
Even a fully-compromised agent runtime that grabs the token can only do the one thing. Capability tokens are basically RAR (lesson 3.1) for internal APIs.
This is the highest leverage technique in agent IAM. Use it for any high-stakes action.
6. Composing policy + capability
Workflow per high-stakes tool call:
1. Agent: "I want to refund $200 to order #1234 for alice@acme.com"
2. Broker / capability service:
a. OPA decision: subject=alice, actor=helpdesk-v1, action=refund, amount=200, time=now
→ "needs approval"
b. Trigger CIBA (lesson 3.1) with binding_message:
"Refund $200 to order #1234?"
c. User approves on phone
d. Mint capability token bound to (order=1234, amount=200, currency=USD), TTL 60s
3. Agent calls payments-api with the capability token
4. payments-api validates: cap matches args? token live? subject + actor OK?
→ execute, log, returnThe agent never has the authority to refund any other amount or any other order. The user explicitly approved this one. Audit trail names both. This is what an EU AI Act high-risk system looks like in practice.
7. ABAC inputs: context the agent doesn't control
Some attributes should come from your infrastructure, not the agent:
- IP / geo of the calling agent (workload identity tag).
- Time of day + business hours.
- Risk score (computed by a separate service from session signals, anomaly detectors).
- Tenant compliance tier.
- Per-action cost so far for cost-aware throttling.
Inject these in the policy input on the resource server, not from the agent. Anything the agent provides could be lied to it by injected content.
8. Testing policy
Treat policies like code:
# tests/mcp_test.rego
package mcp
test_allow_tickets_read {
allow with input as {
"action": "tickets:read",
"scope": ["tickets:read"],
"subject": {"sub":"alice","tenant":"acme"},
"resource": {"tenant":"acme"},
}
}
test_deny_cross_tenant {
not allow with input as {
"action": "tickets:read",
"scope": ["tickets:read"],
"subject": {"sub":"alice","tenant":"acme"},
"resource": {"tenant":"globex"},
}
}opa test policies/ -vCI runs opa test on every PR; failing tests block merge. Same for Cedar (cedar test) and OpenFGA assertions.
Coverage > 90% for any policy that protects regulated data.
9. Hands-on lab (4 h)
- Stand up OPA + OpenFGA (lesson 0.1).
- Write a Rego policy with at least 5 rules covering read, write, cross-tenant deny, business-hours restriction, and HITL gate.
- Model an OpenFGA schema for
user,org,folder,doc; write tuples for two tenants; verify cross-tenant queries deny. - Build a capability service (
/cap) that:- Takes
(action, args)from an agent. - Consults OPA.
- If approval needed, triggers CIBA (mock if needed).
- Returns a short-lived JWT bound to the agent's DPoP key.
- Takes
- Build a
payments-apithat enforces capability tokens (validatecapmatches request). - Write
opa testcases covering each policy branch; run in CI.
10. Common pitfalls
- Mixing ABAC and ReBAC in one policy language without clear lanes — pick OPA for action, OpenFGA for relationships.
- Trusting subject-supplied attributes (tenant, role) instead of resolving from claims server-side.
- Capability tokens too broad ("any refund up to $1000") — defeats the purpose.
- No tests for
denycases — only positive paths reviewed. - Policy bundles unsigned — supply chain compromise rewrites policy silently.
- Decisions not logged → no post-hoc analysis.
11. Self-check
- RBAC vs ABAC vs ReBAC — pick per scenario.
- Capability token design — 3 must-have claims.
- OpenFGA in 2 sentences.
- Two attributes that must come from infra, not the agent.
- How CIBA + capability token + DPoP compose.
12. References
- OPA + Rego docs.
- AWS Cedar docs + Cedar by Example.
- OpenFGA docs (
openfga.dev). - Google "Zanzibar: Google's Consistent, Global Authorization System" (paper).
- "OAuth 2.0 Rich Authorization Requests" (RFC 9396).
- OpenID Foundation "AuthZEN" working group.
- "Capability-based Security" — Mark Miller (Combex / E language).
Sign in to save your progress and earn badges.