How to implement RBAC for AI agents
Teams reach for role-based access control the day their agent reaches production, then discover within a week that roles alone do not stop the actions that cause harm. A support agent role lets the agent issue refunds. It does not stop the agent from issuing a fifty-thousand-dollar refund to an attacker who jailbroke its prompt. Wire RBAC into an agent in Python, then layer Veto on top so the role claim and the request arguments both get a vote in every decision. Four steps, real code, no glue scripts.
What you'll build
- An agent registry that maps each agent identity to one named role.
- A YAML policy file with role-scoped allow, deny, and require_approval rules.
- A single veto.decide call in the governed tool path.
- Live context (fraud score, tenant id, request id) feeding the policy so decisions are tool-call-level, not role-level.
Step 1: Define the role set
Pick a small set of roles, one per agent persona. Two or three is enough to start. Each role should answer one question: what is this agent here to do? Resist the urge to model every permission in the role name. Roles are the coarse cut. Granular decisions belong in policy. For a support stack, a support role and an ops role usually cover the surface. The role becomes a claim on the agent identity, exposed to Veto on governed decisions.
# pip install veto-sdk
import os
from veto_sdk import Veto
veto = Veto(
api_key=os.environ["VETO_API_KEY"],
policy_path="policies/agents.yaml",
)
The SDK reads policies/agents.yaml on boot and hot-reloads on change. No deploy is needed to update a rule. Roles get attached to the agent identity at startup and travel with every decide call.
Step 2: Write the role policy in YAML
The policy file is where the granularity gap closes. A rule matches on agent role, then narrows further with arguments, tools, and context. Below is a starting policy for support and ops roles. Notice the support role can issue refunds but not large ones, can read tickets freely, and cannot send external email without a human in the loop. Ops can restart services without approval but cannot drop tables without one.
# policies/agents.yaml
- name: support_agent_role_actions
match:
agent.role: support
rules:
- if: tool == "refund_order" and args.amount_cents > 50000
then: require_approval
- if: tool == "delete_user"
then: deny
- if: tool == "send_email" and args.to_external == true
then: require_approval
- if: tool in ["read_ticket", "read_order", "add_note"]
then: allow
- name: ops_agent_role_actions
match:
agent.role: ops
rules:
- if: tool == "delete_user"
then: require_approval
- if: tool == "drop_table"
then: deny
- if: tool in ["read_metrics", "restart_service", "scale_replicas"]
then: allow
Each rule list is evaluated top to bottom. The first match wins, so put narrower rules first. Read the YAML format reference for the full grammar, including expression operators and policy bundles.
Step 3: Add the decision to the tool path
The enforcement point is a single function around the tool dispatch loop. Before the tool runs, call veto.decide with the tool name, the arguments, and the agent identity. The decision returns an allow, deny, or require_approval outcome. On approval, the SDK blocks until a human acts in the workspace or via the configured approval integration, then returns the outcome. On deny, the tool never runs.
import os
from veto_sdk import Veto, Decision
veto = Veto(api_key=os.environ["VETO_API_KEY"])
def run_tool(agent_id: str, role: str, tool: str, args: dict, user_id: str):
decision: Decision = veto.decide(
tool=tool,
args=args,
agent={"id": agent_id, "role": role},
actor={"user_id": user_id},
)
if decision.outcome == "deny":
raise PermissionError(decision.reason)
if decision.outcome == "require_approval":
approval = veto.approvals.wait(decision.approval_id, timeout=120)
if approval.status != "approved":
raise PermissionError(f"Approval rejected: {approval.note}")
return TOOLS[tool](**args)
Each governed decision is recorded with the decision context you send: tool, args, decision, rule that matched, latency, and approval id when relevant. The logs feed SOC 2 evidence and power the activity feed in the workspace.
Step 4: Pass live context into the decision
The whole point of layering Veto on RBAC is that the decision sees more than the role. Pass a context dict with the signals your policies use: fraud score, tenant id, freeze window flag, current merchant. The policy can reference any of them by name. This is where the role check graduates from a static permission to a runtime decision.
def run_tool(agent_id, role, tool, args, user_id, request_id):
decision = veto.decide(
tool=tool,
args=args,
agent={"id": agent_id, "role": role},
actor={"user_id": user_id},
context={
"request_id": request_id,
"fraud_score": fraud.score(user_id),
"merchant_id": current_merchant_id(),
"tenant_id": current_tenant_id(),
},
)
# additional fields omitted for brevity
With context wired in, you can write rules like "refunds above $5,000 during a freeze window require approval" or "deletes against the production database require approval if the agent is acting on a tenant other than its own." The role gets the agent in the door. Context decides what they do once inside.
Failure modes to catch
Treating roles as the whole story
A common failure mode is to model everything in role names: support_l1_no_refunds, support_l2_can_refund_under_500, and on and on. The list grows faster than the team can review it. Keep roles small. Push the variability into policy rules where it can be version-controlled and tested.
Forgetting to pass context
If only the role and the tool name reach the decision, the policy is doing role-based access with extra plumbing. Audit your decide calls and confirm fraud score, tenant id, and the other signals your rules reference are all in the context dict.
Flipping to enforce too early
New policies should run in shadow mode for a week before they enforce. See the shadow-test guide for the rollout pattern.
Production checklist
- Every agent identity has exactly one role attached at startup.
- policies/agents.yaml is in git, with code-review on every change.
- Each governed tool call routes through veto.decide before execution.
- Context dict carries fraud, tenant, merchant, and request id on each governed call.
- New rules run in shadow mode and graduate to enforce only after seven clean days.
FAQ
Why is plain RBAC not enough for AI agents?⌄
RBAC was designed for humans, where role grants are stable and request volume is low. Agents chain hundreds of actions per minute and act on instructions that can be smuggled in through tool output. A role can say a support agent may issue refunds. It cannot say a support agent may issue this $50,000 refund, on this merchant, after this fraud score, during this freeze window. That decision needs arguments and context, not just a role.
Where does Veto fit relative to my existing IAM?⌄
IAM stays in place. It still issues the agent identity and the role claim. Veto reads the role from the request and applies runtime rules on top. Think of IAM as deciding who the agent is and Veto as deciding what this specific action should do given who they are.
Can I version policies in git?⌄
Yes, and you should. The YAML files live in your repo alongside application code. Veto reads them at startup and watches for changes. Policy updates go through the same pull-request review process as code changes, which gives you change history, blame, and rollback from the same workflow.
Related guides
Wire a configured review channel or workspace approval into the require_approval path your role policy uses.
Shadow-test AI agent policiesRoll out new role rules in observe-only mode before they start blocking.
Log AI agent decisions for SOC 2Map role decisions to CC6.1 and CC7.2 controls with inspectable logs.
AI agent permissionsHow Veto handles permission decisions for agent tool calls.
Runtime authorizationGlossary entry that explains the model RBAC plus Veto implements.
Release role-aware policy with your next agent update.