Fine-Grained Authorization for Agents
AI agents are first-class subjects in LumoAuth's fine-grained authorization engine. Beyond the capability strings on the agent record, you can target agents with ReBAC relationship tuples (relationship-based access, Zanzibar style: "agent X is a viewer of document Y"), ABAC policies over agent attributes (attribute-based rules), and query decisions through the standard OpenID AuthZEN 1.0 API (the standard "may this subject do this action on this resource?" request) — the same engine that authorizes human users.
Every agent decision uses deny-overrides combining: an explicit deny-effect ABAC policy beats any allow — capability match, relationship tuple, or allow-effect policy. A "no matching policy" default deny does not override; it simply contributes no allow.
Agent subjects in ReBAC (Zanzibar)
Agents appear in relationship tuples under the agent subject namespace, using the public agent id (agt_...):
permission:reports.generate#grantee@agent:agt_0123456789abcdef
document:doc-1#viewer@agent:agt_0123456789abcdef
group:eng#member@agent:agt_0123456789abcdef
Everything that works for user: subjects works for agent: subjects:
- Wildcard permission grants —
permission:docs.*#grantee@agent:...matchesdocs.view(single segment) andpermission:docs.**#grantee@...matches nested slugs. - Userset expansion — put an agent in a group (
group:eng#member@agent:...) and grant the group (document:doc-1#viewer@group:eng#member); the agent resolves through the chain. - Organization isolation — tuples are scoped to your organization; a sibling organization's checks never see them.
The system namespaces agent, mcp_server and mcp_tool are provided by the platform.
Agent attributes in ABAC
ABAC policies target agents through the agent. subject prefix. Available attributes:
| Attribute | Type | Example |
|---|---|---|
agent.id | string | agt_0123456789abcdef |
agent.name | string | Billing Bot |
agent.status | string | active, suspended, ... |
agent.identityType | string | workload, api_key, certificate, custom |
agent.capabilities | array | ["invoices.read", "invoices.export"] |
agent.workloadIdentityProvider | string | aws, gcp, k8s, azure, custom |
agent.canActOnBehalfOfUsers | boolean | true |
agent.userAccessMode | string | both, org_only, ... |
agent.tenantId / agent.orgId | int / string | 42 / acme-corp |
contains on an array attribute means strict membership, so a policy condition like agent.capabilities contains "invoices.export" matches an agent holding that capability.
Policies keyed on user.* attributes never match agent subjects (and agent.* policies never match users) — subject types are isolated, so a user-facing deny cannot accidentally fire for an agent or vice versa.
Example deny policy — block delegating agents from destructive document actions:
{
"name": "deny-delegating-agents-destructive",
"resource_type": "document",
"action": "delete",
"effect": "deny",
"conditions": {
"operator": "AND",
"conditions": [
{ "subject": "agent.canActOnBehalfOfUsers", "operator": "equals", "value": true }
]
}
}
AuthZEN 1.0 API
LumoAuth implements the OpenID AuthZEN Authorization API 1.0 evaluation endpoints. Like the other /api/v1/authz endpoints they are not org-scoped — the organization is resolved from your access token. Both user tokens and agent bearer tokens may call them; subject lookups are always scoped to the caller's organization.
Single evaluation
POST /api/v1/authz/v1/evaluation
curl -X POST https://app.lumoauth.dev/api/v1/authz/v1/evaluation \
-H "Authorization: Bearer AGENT_OR_USER_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"subject": {"type": "agent", "id": "agt_0123456789abcdef"},
"action": {"name": "invoke"},
"resource": {"type": "mcp_tool", "id": "srv_github/create_issue"},
"context": {"resource_id": "srv_github/create_issue"}
}'
{ "decision": true }
Denies carry an optional advice context:
{
"decision": false,
"context": {
"id": "1f0a9c33d02b41aa",
"reason_admin": { "en": "No matching policies or relationships" }
}
}
Semantics: subject.type is user (id = user UUID) or agent (id = agt_...); the decision combines, in deny-overrides order: explicit ABAC deny → Zanzibar relationship <resource.type>:<resource.id>#<action.name> → permission-slug grant <resource.type>.<action.name> (RBAC roles for users, permission: tuples for both) → ABAC allow → default deny.
- Malformed requests (missing/invalid
subject,action.name,resource.type,resource.id, or an unsupported subject type) return HTTP 400. - Unknown subjects (an id that doesn't resolve in your organization) return HTTP 200 with
"decision": false— a deny, not an error, so subjects can't be enumerated.
Boxcarred evaluations
POST /api/v1/authz/v1/evaluations
Top-level subject / action / resource / context act as defaults; each entry in evaluations overrides them per-field (whole-object precedence, per spec). Order is preserved in the response. Maximum 100 evaluations per request.
curl -X POST https://app.lumoauth.dev/api/v1/authz/v1/evaluations \
-H "Authorization: Bearer AGENT_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"subject": {"type": "agent", "id": "agt_0123456789abcdef"},
"action": {"name": "invoke"},
"evaluations": [
{"resource": {"type": "mcp_tool", "id": "srv_github/create_issue"}},
{"resource": {"type": "mcp_tool", "id": "srv_github/delete_repo"}},
{"resource": {"type": "mcp_tool", "id": "srv_slack/post_message"},
"subject": {"type": "agent", "id": "agt_other_agent_id"}}
]
}'
{
"evaluations": [
{ "decision": true },
{ "decision": false, "context": { "id": "…", "reason_admin": { "en": "…" } } },
{ "decision": false, "context": { "id": "…", "reason_admin": { "en": "…" } } }
]
}
Per-MCP-tool authorization convention
Tool-level access for MCP servers (tool servers agents call over the Model Context Protocol) is expressed with plain strings — no server registration changes needed:
| Concept | Value |
|---|---|
| AuthZEN resource | {"type": "mcp_tool", "id": "<serverId>/<toolName>"} |
| Zanzibar object | mcp_tool:<serverId>/<toolName> |
| Relation | invoke |
| Subject | agent:<agentId> |
| Server-wide grant | mcp_server:<serverId>#invoke@agent:<agentId> |
A tool inherits invoke from its server through a parent tuple (mcp_tool:<sid>/<tool>#parent@mcp_server:<sid>), so one server-wide grant covers every linked tool. Identifiers must not contain :, #, *, or (within a segment) /.
In-process callers can use the McpToolAuthorizationService helper instead of HTTP:
$mcpAuthz->grantInvoke($agent, 'srv_github', 'create_issue'); // writes tuples
$mcpAuthz->canInvoke($agent, 'srv_github', 'create_issue'); // => true
$mcpAuthz->grantServerInvoke($agent, 'srv_github'); // server-wide
External MCP gateways ask the same question through the AuthZEN endpoint with the mcp_tool resource shown above. This is the COAZ-style ("Common Authorization for Agents") mapping of a tool invocation onto AuthZEN:
POST /api/v1/authz/v1/evaluation
{
"subject": {"type": "agent", "id": "agt_0011223344556677"},
"action": {"name": "invoke"},
"resource": {"type": "mcp_tool", "id": "srv_github/create_issue"},
"context": {}
}
→ {"decision": true} exactly when mcp_tool:srv_github/create_issue#invoke@agent:agt_0011223344556677 (or the server-wide mcp_server:srv_github#invoke@agent:…) resolves, with ABAC deny policies overriding. The PDP ignores PEP-supplied ip / userAgent context and uses the real request's. Build or parse the mapping in PHP with the static helpers:
McpToolAuthorizationService::authZenEvaluation($agentId, 'srv_github', 'create_issue'); // request body above
McpToolAuthorizationService::parseAuthZenResource('mcp_tool', 'srv_github/create_issue'); // ['serverId' => …, 'toolName' => …] or null
McpToolAuthorizationService::tupleString($agentId, 'srv_github', 'create_issue'); // mcp_tool:srv_github/create_issue#invoke@agent:<id>
Every AuthZEN deny is audited as authzen.evaluation_denied (subject, action, resource, deciding engine). Allows are written to the application log at debug level; set {"authz": {"audit_allow_decisions": true}} in the organization settings JSON to audit them as authzen.evaluation_allowed too. (AARP is not implemented.)
JIT permission integration
When an agent requests JIT permissions (just-in-time: short-lived access asked for at the moment of use), the PDP (policy decision point, the engine that answers allow or deny) is consulted for every capability slug implied by the requested authorization_details (with subject = the agent and, when present, the rich authorization request's identifier as the resource):
- Explicit ABAC deny → the request is denied outright — it is never auto-approved and is not forwarded to human approval (a deny policy is an administrative decision HITL must not override ad hoc). The denial counts toward the task's risk history.
- Explicit allow (every requested slug allowed) → the heuristic risk score is reduced by 20 points (floored at 0), which can move a request from human review into auto-approval. Critical types (
payment,user_data,credentials,admin_action) never receive this reduction — policy allows cannot fast-track them. - No agent-applicable data → the PDP step is skipped entirely (cheap existence probe), and the heuristic assessment stands alone.
Audit records for jit.permission_requested / jit.permission_denied gain pdp_consulted and pdp_decision metadata.
Ask API integration
The Ask API combines the agent's capability wildcards with the PDP:
- final decision =
capability_match OR pdp_allow, unless an explicit ABAC deny fires (deny-overrides); - responses and denial audit records include
decided_by:capability,pdp, ordeny_override; - capability strings are matched in their canonical form: lower-case, with
:and-folded into., sousers:*,Users.*andusers.*are the same wildcard andusers:deletethe same slug asusers.delete. Capabilities are stored canonically whatever surface wrote them (portal, Admin API,PUT …/scopes, register API), and the creator-containment rule (an admin cannot grant an agent a catalogue permission or wildcard they do not hold) judges the same canonical form; - the PDP is consulted whenever your organization has agent tuples or any active ABAC policy — a blanket deny on a resource type overrides a capability allow even though it never mentions
agent.*attributes. Organizations with neither keep the capability-only fast path.
Standards used on this page
| Plain name | Standard |
|---|---|
| Authorization API (AuthZEN) | OpenID AuthZEN Authorization API 1.0 |
| Rich authorization requests | RFC 9396 |
See the standards reference for what each one covers and how LumoAuth uses it.