---
title: "RBAC Isn't Enough for AI Agents"
description: "One agent acts for hundreds of users across thousands of resources. A role or a coarse allowlist can't express 'this agent, for Alice, may read document 42.' Authorization for agents has to be per-resource and relationship-aware."
canonicalUrl: "https://zuplo.com/blog/2026/06/08/fine-grained-authz-ai-agents"
pageType: "blog"
date: "2026-06-08"
authors: "nate"
tags: "API Security, ai-agents"
image: "https://zuplo.com/og?text=RBAC%20Isn%27t%20Enough%20for%20AI%20Agents"
---
I gave an internal agent access to our docs tooling last month and watched it do
something a human never would: in one session it touched files belonging to
eight different people, on behalf of four different requesters, in under a
minute. That is the shape of agent traffic, and it broke the mental model I had
been carrying for authorization. A role answers "what may this caller do." An
agent has no fixed answer, because it acts for whoever asked it, against
whatever resource the task happens to reach.

Role-based access control assumes a stable subject with a stable set of
permissions. Agents violate both halves. The grant you actually need is not "the
agent may call the database" or "the agent has the editor role." It is "this
agent, acting for Alice, may read document 42, and nothing else Alice can't
read." A role can't say that, and neither can a coarse allowlist. This is the
same thing Anthropic landed on when it
[reframed an allowlist as a capability grant](/blog/anthropic-made-the-case-for-mcp-gateways?utm_campaign=mcp-gateway):
"this agent may reach this server" is far broader than the per-call decision you
meant to make.

<CalloutAudience
  variant="useIf"
  items={[
    `You expose an API or MCP server to agents that act on behalf of many different end users`,
    `Your authorization model is a role or a static allowlist and you suspect it's too coarse for per-resource decisions`,
    `You need a per-action audit trail of authorization decisions for compliance`,
    `You're weighing RBAC against ABAC or relationship-based access control for agent traffic`,
  ]}
/>

## Three authorization models and where each breaks

RBAC groups permissions into roles and assigns roles to subjects. It is fine
when the subject is a person whose job describes their access. It falls apart
the moment one subject acts for many principals against per-resource scopes,
because you would need a role per (user, resource) pair, which is not a role
anymore.

ABAC, attribute-based access control, evaluates a policy over attributes of the
subject, resource, and environment. It is more expressive, but the per-resource
relationships an agent needs ("Alice is a viewer of document 42 because she's in
the team that owns folder 7") tend to live in your application data, not in a
token's attributes. You end up carrying the whole relationship graph into every
request or rebuilding it in policy.

ReBAC, relationship-based access control, models authorization as a graph of
relationship tuples and answers each check by walking that graph. This is the
model Google described in its
[Zanzibar paper](https://research.google/pubs/pub48190/), the system behind
Drive, Calendar, and YouTube permissions. A check for "can alice view document
42" resolves by traversing edges like `user:alice` is a `viewer` of
`document:42`, and `document:42` is in `folder:7`.
[OpenFGA](https://openfga.dev/docs/authorization-concepts) is the open-source
implementation of that model, and its core question is exactly the agent
question: given this user, this relation, and this object, is the answer yes.

| Model | Decision unit             | Fits agents?                                     |
| ----- | ------------------------- | ------------------------------------------------ |
| RBAC  | Role to subject           | No, subject and resource scope are both unstable |
| ABAC  | Policy over attributes    | Partial, relationship data has to be carried in  |
| ReBAC | Per-resource relationship | Yes, the check is per (user, relation, object)   |

## Why per-call checks shrink an agent's blast radius

A coarse grant is a capability the agent keeps for the whole session, and a
poisoned tool response or a confused loop spends it freely. A per-call
relationship check is re-evaluated on every action against the resource that
action names, so a compromised agent can only reach what the current principal
could already reach. The principal here is the end user the agent is acting for,
read from the verified token on the request, not an identity the agent asserts
for itself. That is the same containment argument behind
[putting a rate limit on every MCP route](/blog/never-ship-mcp-server-without-rate-limit?utm_campaign=mcp-gateway)
and behind
[scanning tool responses for prompt injection](/blog/protect-mcp-against-prompt-injection?utm_campaign=mcp-gateway):
the boundary enforces a fresh, narrow decision instead of trusting a broad,
durable grant. It also produces the artifact regulators increasingly ask for. A
per-action decision logged with the principal, the relation, and the object is
an audit trail. "The agent had the editor role" is not.

## Tool curation today, per-resource ReBAC next

The least-privilege control that is MCP-native and ships today is curation plus
scoping, not relationship checks. With the
[Zuplo MCP Gateway](/blog/introducing-zuplo-mcp-gateway?utm_campaign=mcp-gateway) you publish a
hand-picked subset of an upstream's tools per route, so the agent only sees the
tools you chose, and you bind every token to the server it was minted for so it
can't be replayed elsewhere. That is your first step.

Per-resource authorization is the deeper layer, and Zuplo gives you a composable
piece for it rather than a turnkey button. The
[`openfga-authz-inbound`](https://zuplo.com/docs/policies/openfga-authz-inbound?utm_campaign=mcp-gateway)
policy (export `OpenFGAAuthZInboundPolicy`) runs a Zanzibar-style ReBAC check at
the gateway boundary on the way in. A few honest caveats:

- It is in **beta** and an **enterprise** feature.
- It is the enforcement point, not the store. You bring an external OpenFGA or
  Okta FGA instance; the policy queries it. The `apiUrl`, `storeId`, and
  `authorizationModelId` are required because the relationship graph lives in
  your FGA deployment, not in Zuplo.
- It is a general API-gateway inbound policy. It is not wired into the MCP
  Gateway path as a one-click feature, so treat it as a platform capability you
  compose onto a route, including an MCP route, rather than a built-in MCP
  setting.

The most common way to drive the check is `setContextChecks` from a custom
inbound policy, where you compute the tuple from the live request: the principal
the agent is acting for, the relation, and the resource the call names.

```ts
// modules/agent-authz.ts
import {
  OpenFGAAuthZInboundPolicy,
  HttpProblems,
  RuntimeError,
  type ZuploRequest,
  type ZuploContext,
} from "@zuplo/runtime";

export async function canReadDocument(
  request: ZuploRequest,
  context: ZuploContext,
) {
  if (!request.params?.documentId) {
    throw new RuntimeError("Document ID not found in request");
  }
  if (!request.user?.sub) {
    return HttpProblems.forbidden(request, context, {
      detail: "User not found",
    });
  }

  // The principal is whoever the agent acts for on THIS call, not the agent
  // itself, so the check narrows to what that user could already reach.
  OpenFGAAuthZInboundPolicy.setContextChecks(context, {
    user: `user:${request.user.sub}`,
    relation: "viewer",
    object: `document:${request.params.documentId}`,
  });

  return request;
}
```

The policy then asks your FGA store whether that tuple holds and rejects the
call if it doesn't. The grant is no longer "the agent may read documents." It is
"this principal may view this document," decided per call.

<CalloutDoc
  title="OpenFGA Authorization Policy Reference"
  description="Full reference for the openfga-authz-inbound policy, including setContextChecks, credentials, and the required external FGA store configuration."
  href="https://zuplo.com/docs/policies/openfga-authz-inbound?utm_campaign=mcp-gateway"
  icon="book"
/>

<CalloutTip variant="mistake">
  Binding the check to the agent's own identity instead of the principal it's
  acting for rebuilds the coarse grant you were trying to escape. The agent
  inherits a union of everyone's access. Compute the tuple from the request's
  acting principal, not from the service account the agent authenticated with.
</CalloutTip>

## When to add per-resource checks

If you ship one thing this quarter, ship tool curation and audience-bound tokens
on your MCP routes. That is the least-privilege step that is native and
available now. When a single agent starts acting for many users against
per-resource scopes, and "the agent has a role" stops being a truthful answer,
that is the signal to layer relationship checks on top, with an external FGA
store behind the beta policy. RBAC was built for stable subjects. Agents aren't
stable subjects, and the authorization model has to follow.

The Zuplo MCP Gateway is in public beta and available now on every plan,
including the free one.
[Spin up a free project](https://portal.zuplo.com/signup?utm_source=zuplo-blog&utm_medium=web&utm_campaign=mcp-gateway)
and start adding per-call authorization to your MCP routes today.