In enterprise agent networks, authorization is not a feature you bolt on after the system is running, it is a structural property of the architecture. When 40 agents can autonomously call hundreds of tools, the question of who is allowed to call what determines your entire risk surface. Human-derived RBAC models fail here because agents have fundamentally different usage patterns than the people they work alongside. This lesson builds a purpose-built RBAC system for agent networks, enforced centrally by Open Policy Agent.
Agent Identity vs Human Identity
Human identity in enterprise systems is built around job functions: an analyst can read reports, a manager can approve workflows, an admin can configure the system. These roles map to UI actions that a human consciously chooses.
Agent identity is categorically different. An agent’s “role” must reflect its workflow position, what tools it needs to accomplish a specific task, not its organizational equivalent. Consider three reasons this distinction matters in practice:
Call frequency. A human analyst might export a report twice a day. An equivalent agent might call the same export tool 500 times an hour as part of an automated pipeline. The risk profile is entirely different.
Scope creep. Humans navigate permission boundaries through UI affordances, they see what they can click. Agents will call any tool they are authorized to call, programmatically, without hesitation. Overly broad agent roles become operational vulnerabilities.
Credential sharing. Humans have passwords and MFA. Agents share service account credentials across instances. If one instance is compromised, all instances with the same role are compromised unless the role itself is tightly scoped.
mindmap
root((Agent RBAC))
Agent Roles
planner-agent
tool:decompose_task
tool:delegate_to_worker
data-agent
tool:read_financial_records
tool:query_database
report-agent
tool:write_report
tool:send_notification
Human Roles
analyst
ui:read_dashboard
ui:export_report
manager
ui:approve_workflow
ui:view_audit_log
Tool-Level Authorization with OPA
Open Policy Agent (OPA) is a general-purpose policy engine that decouples authorization logic from application code. Instead of scattering if agent.role == "data-agent" and tool == "read_financial_records" checks across every agent and service, you centralize all authorization decisions in OPA and make every tool executor query it before running.
The architecture uses OPA as a sidecar in each agent pod. Before executing any tool call, the agent executor sends an authorization request to the local OPA instance on localhost:8181. OPA evaluates the request against the active Rego policy bundle and returns allow or deny in under 1ms, the overhead is negligible.
sequenceDiagram
participant PA as Planner Agent
participant WA as Worker Agent
participant OPA as OPA Sidecar
participant TOOL as Tool Executor
participant AUDIT as Audit Logger
PA->>WA: Delegate task (with agent JWT)
WA->>OPA: POST /v1/data/agent/authz/allow\n{agent_id, tool, action, context}
OPA-->>WA: {allow: true, reason: "role:data-agent has tool:read_db"}
WA->>AUDIT: Log authorization decision (allow)
WA->>TOOL: Execute tool
TOOL-->>WA: Tool result
WA->>AUDIT: Log tool execution result
Writing Rego Policies for Agent Authorization
Rego is OPA’s declarative policy language. It reads like structured English once you understand its evaluation model: a rule is true if all conditions in its body are true, and OPA evaluates every rule for a given query.
# rego/agent_authz.rego
# This policy is the single source of truth for what each agent role can do.
# It is version-controlled, reviewed, and deployed as a bundle: not hardcoded in agent code.
package agent.authz
import future.keywords.if
import future.keywords.in
# Default deny: if no rule explicitly allows, the request is denied.
# This is the most important line in the policy: fail closed, not open.
default allow := false
# Allow the request only if the agent's role grants the requested tool scope.
allow if {
# Extract the agent's role from the verified JWT sub claim.
# The token was already verified by mTLS at the transport layer.
agent_role := data.agent_roles[input.agent_id]
# Check that this role explicitly includes the requested tool.
# 'in' requires an exact match: no glob expansion, no inheritance by default.
input.tool in data.role_permissions[agent_role]
}
# Emergency revocation: deny any agent in the revoked list, regardless of role.
# This rule takes precedence and is used for incident response.
deny if {
input.agent_id in data.revoked_agents
}
# Final decision: allow only if allowed AND not denied.
# Separating allow and deny lets us add revocation without touching the allow logic.
final_decision := {"allow": allow and not deny}
The policy data, agent_roles (mapping agent IDs to roles) and role_permissions (mapping roles to allowed tools), lives in OPA’s data document, updated via bundle. The Rego logic never changes for permission updates; only the data does.
# agent_executor.py
import httpx
from functools import wraps
OPA_ENDPOINT = "http://localhost:8181/v1/data/agent/authz/final_decision"
def require_authorization(tool_name: str):
"""
Decorator that gates every tool execution behind an OPA authorization check.
Applied at the executor layer so no agent code can bypass it.
The agent_id comes from the verified JWT, not from the request payload
preventing agents from spoofing each other's identities in authorization requests.
"""
def decorator(func):
@wraps(func)
async def wrapper(self, *args, agent_jwt_claims: dict, **kwargs):
response = await httpx.AsyncClient().post(
OPA_ENDPOINT,
json={
"input": {
"agent_id": agent_jwt_claims["sub"],
"tool": tool_name,
# Include context for attribute-based extensions later.
"environment": agent_jwt_claims.get("env", "production"),
}
},
timeout=0.5, # Hard timeout: authorization must be fast or fail closed.
)
decision = response.json().get("result", {})
if not decision.get("allow"):
raise PermissionError(
f"Agent {agent_jwt_claims['sub']} not authorized for tool '{tool_name}'"
)
return await func(self, *args, **kwargs)
return wrapper
return decorator
Dynamic Policy Updates and Emergency Revocation
OPA bundles enable the most operationally important property of this system: you can change agent permissions in production without restarting a single agent process. Bundle configuration points OPA to an S3 bucket or HTTP endpoint hosting your compiled policy bundle; OPA polls it on a configurable interval (60 seconds is typical).
The update workflow for a permission change is: modify the data document (e.g., add "tool:web_search" to role_permissions["research-agent"]), recompile the bundle with opa build, push to S3, and wait for OPA to pull it. No Kubernetes rolling update, no pod restart, no in-flight request disruption.
Emergency revocation follows the same path but faster. Add the compromised agent’s ID to the revoked_agents list in the data document, push an emergency bundle, and within the polling interval every service using OPA will deny that agent’s requests. The revocation is centralized, auditable, and takes effect in under a minute.
Every authorization decision, allow and deny, must be written to your audit log with the full input context. When a security incident occurs, you need to reconstruct exactly which tools a compromised agent was authorized to call, which calls it actually made, and when the revocation took effect. That reconstruction is only possible if authorization decisions are logged with the same fidelity as the tool executions themselves.
The next lesson builds on this authorization layer by adding the compliance dimension: making your audit logs legally defensible, enforcing GDPR and HIPAA data handling constraints, and automating evidence collection for SOC 2 audits.