The AgentArea authorization model
AgentArea models resource access as ReBAC in OpenFGA. Agents, skills, MCP servers and clients are not four permission systems — they are one genericresource
type whose access is derived from the project it belongs to and the grants
written on it. Everything you own is a node; everything you can reach is an edge.
This is relationship-based access control, not role-based. Roles exist in the
model, but they are objects in the graph carrying independent permission bits,
not a global role table. Read
authorization models first if that
distinction is unfamiliar.
The problem
An agent platform accumulates object kinds quickly: agents, skills, skill collections, MCP server instances, registered clients, files, projects. Modelling each as its own permission namespace means the same four relations get copy-pasted per kind, and every new kind is a schema change plus a migration. At the same time, the objects nest. A workspace contains projects; projects contain resources; a project can contain another project. Granting a team access to a project must reach the resources inside it without writing a grant per resource, and revoking it must not leave orphans behind.The deployed model
The model isconfig/auth/openfga/model.fga, schema 1.1, loaded into OpenFGA at
startup. Seven types:
resource type. An agent, a skill, an MCP server and a
registered client are all resource:<uuid>. Which kind of thing a resource is
lives in the database, not the graph. Adding a new governed kind needs no model
change and no migration.
Independent permission bits. can_read, can_write and can_manage do not
imply each other. A role granting only can_write yields can_write: true with
can_read: false and can_manage: false. This is the Google IAM
predefined-role shape, not the GitHub “editors are also viewers” shape, and it is
why the ownership writer grants all three bits explicitly rather than granting
manager and expecting the rest to follow.
project is the governance container and it nests. A nested project is the
folder — there is no separate folder type. Authorization inherits downward:
... from parent carries a grant from a project into its children, and
... from project carries it from a project into its resources. admin on the
workspace short-circuits all three bits at every level below it.
Agents are principals. [User, Agent] on reader/writer/manager and
[User:*, Agent:*] on the role bits means an agent can hold a grant in its own
right. An agent is therefore both a subject and — as an artifact someone
configures and deletes — a resource:<id> object.
Note the mixed casing: User, Agent and Workspace are PascalCase while
role, role_assignment, project and resource are lowercase. The lowercase
types landed additively beside the pre-existing identity types rather than
replacing them, and the casing is a live artifact of that. Object references are
literal, so a tuple is written resource:<uuid>#reader@User:<id>.
How ownership and projects are seeded
Two writers keep the graph in step with the database. When a workspace is created,seed_workspace writes three tuples so the
workspace is usable without manual setup:
<workspace_id>-root, and
because project.can_* rolls up admin from workspace, a workspace admin
manages that root project and everything under it.
When a governed resource is created, grant_resource_owner attaches it to the
root project and grants the creator all three bits:
How a check is made
Application code does not speak OpenFGA. It callsrequire_permission(permission, resource_type, resource_id, user_id), which
resolves a PermissionService from the DI container. When the backend is
OpenFGA, that service maps a generic verb onto one of the three bits and issues a
single Check against resource:<id>:
A verb outside that table is denied and logged. Two resource types,
model_instance and model, are listed as ungoverned and return allow without
consulting the graph — they are scoped by the workspace layer only, and a graph
check would deny a legitimate owner who has no tuple.
When OpenFGA is unreachable the client raises rather than coercing the answer to
allow or deny, so an outage surfaces as a failure at the call site instead of a
silent posture change.
The relationship explorer at /v1/access-control exposes the same graph:
GET /graph and GET /relationships read it, POST/DELETE /relationships
write and revoke resource grants, POST /check and POST /resolve answer single
questions, and POST /sync reconciles them. Every one of those endpoints
requires workspace admin, and both the object and the subject are asserted to be
inside the caller’s workspace before a tuple is touched. When no graph backend is
configured, reads report enabled: false and writes return HTTP 503.
Admission at the request edge
Resource checks answer “may you change this object”. A separate decision answers “may you invoke this agent at all”, and it does not consult the graph. Every protocol edge resolves a caller the same way.resolve_user_context_from_token
turns a Kratos JWT, an aat_ API key or a Hydra token into one user context, so a
credential that works over REST works everywhere. authorize_agent_action then
decides, taking a subject, one of four action verbs — agent:read, agent:write,
agent:execute, agent:stream — and the agent’s workspace, in this order:
- A public grant on the agent allows anyone, including an anonymous caller.
- An anonymous caller with no public grant is denied.
- An authenticated caller whose accessible workspaces include the agent’s workspace is allowed.
- Everything else is denied.
Why not RBAC
Calling this model RBAC is not loose phrasing, it is a different system. AgentArea resources are shared per object, inside a hierarchy, by ordinary users — the exact workload where a global role table becomes one role per object. The graph is shaped to keep role bundles through therole and
role_assignment types while moving the scope onto an edge. One role:editor
object would attach to any number of projects and resources through separate
assignment nodes, so the bundle stays small and the scope stays exact. Compared
with a role column on each table, that shape buys inheritance without a cascade
job: a grant on a parent project reaches nested projects and their resources with
no additional rows.
Read that paragraph as the model’s design, not as behaviour you can rely on
today. The types are deployed and validated, but nothing writes role,
role_assignment or project-parent tuples yet, so the working part of the graph is
ownership and workspace-admin cascade. What is already real is the part that
motivated the choice: resource grants are per object, and a workspace admin
reaches everything through the root project without a grant per resource.
The cost is that OpenFGA is a second datastore. Ownership writes are distributed
writes that can fail independently of the database transaction, checks are network
calls on the request path, and a set of invariants the graph cannot express has to
be enforced by the writer instead — one workspace and one parent per project, one
project per resource, same-workspace edges only, an acyclic project graph, and
each role_assignment attached to exactly one object.
Limits
- The graph is off by default in code.
ACCESS_CONTROL_BACKENDdefaults todisabled, in which case the permission service isWorkspaceScopedPermissionService, whosecheckreturnsTrueunconditionally. The only boundary in that configuration is workspace scoping in the repository layer. The shippeddocker-compose.dev.yamland the Helm chart both set the backend toopenfga, so a standard deployment has the graph on — but a process started without those environment variables does not. - Seven call sites check, and one of them is a no-op.
require_permissionis called on agent edit and delete, skill edit and delete, MCP server edit and delete, and model instance delete. The model instance call cannot deny anything:model_instanceis on the ungoverned list, so that check returns allow before the graph is consulted. Read and list endpoints are scoped by workspace in the database and are not graph-filtered; the client’slist_objectsreverse lookup is implemented but has no caller. A user inside a workspace can therefore list resources they hold no tuple for, and registered clients are not graph-checked at all. - Roles are modelled but never written. No code in this repository creates a
roleorrole_assignmenttuple, and no endpoint exposes one — the relationship explorer explicitly rejects group grants and refers the caller toproject/role, which has no writer. Therole_assignmentbranch of everycan_*rule therefore never matches. The same holds forproject:<id>#parentand forreader/writer/managerwritten directly on a project: the model supports them and nothing populates them. - Five tuple shapes exist in practice. Everything the graph decides today
rests on
Workspace#members,Workspace#admin,project#workspace,resource#project, andresource#reader|writer|manager. OnlyUsersubjects are ever written, despiteAgentbeing accepted everywhere aUseris. - Projects are not yet graph-authorized. The
projecttype exists, nests and inherits, but resources are attached to the workspace root project by the ownership writer. Access to project-scoped data elsewhere in the platform is still workspace-string-scoped. - Role bits will need writing for both subject shapes. The role bits accept
[User:*, Agent:*]. Whenever a role writer does land, a role written only forUser:*will silently deny agent assignees — a fail-closed footgun that shows up only when a real agent runs. - Tool calls do not touch this graph. Authorization for invoking a tool is a policy-engine decision, not a graph lookup. See tool authorization.
- Public execution is not expressible. The public-grant hook that
authorize_agent_actionconsults first is a placeholder that always returns false. There is no tuple, flag or endpoint that makes an agent publicly executable, so every caller must be authenticated and in the agent’s workspace. - Only A2A calls the edge authorizer explicitly. The A2A endpoint routes
through
authorize_agent_action. The REST execute path enforces the same outcome implicitly, through workspace-scoped repositories rather than a call to the shared decision point, so the two paths agree today by construction rather than by sharing the check.
Related
- Authorization models — the vocabulary this page assumes.
- The policy engine — the other authorization surface, and why it is separate.
- Tool authorization — what a tool call clears, and what it does not.
- Audit — what a grant change leaves behind.