| tags |
|
|---|
Phase 9 (ADR [[0028-agent-tool-call-authorization|0028]]). The starter answers "may this human act on this resource?". An AI agent calling tools on a person's behalf collapses two identities into one bearer token, so the agent inherits the whole human ceiling — and the tool surface itself has no gate at all. This guide is the shipped contract for the gate that closes that: a new example service,
example-mcp-server, whose@McpToolmethods proxy the catalog REST API and are authorized before they run, by a policy that computes principal ceiling ∩ agent capability in Rego.Nothing in the library changes. No
opa-abac-*module, no existing service, no existing.regodocument. The MCP server consumes the published starter exactly as an adopter would — which is also what makes [[#On the local rig|the e2e]] meaningful: the target layer it denies at is the shipped one, byte-for-byte.
The spec stops at the transport edge, deliberately:
- It specifies how a server validates a bearer token and nothing above it. There is no per-tool authorization concept: a token that passes validation may call every tool the server exposes.
- There is no mechanism for filtering the advertised tool list. The 2026-07-28 revision sanctions the set varying by the authorization presented — and forbids per-connection variation — but ships no mechanism.
- The identity reaching the tool body is a single identity, the token's
sub. Nothing distinguishes the human on whose behalf the call is made from the agent making it, so a policy cannot bound the agent separately from the person.
agent ──MCP──▶ example-mcp-server ──REST (the caller's own bearer)──▶ catalog service
┌───────────────────────────┐ ┌──────────────────┐
│ LAYER 1 — the TOOL-GATE │ │ LAYER 2 — │
│ may this (principal, │ │ the TARGET-GATE │
│ actor) invoke THIS TOOL? │ │ may this │
│ → agent_tools.rego (NEW) │ │ PRINCIPAL touch │
│ → ceiling ∩ capability │ │ THIS RESOURCE? │
└───────────────────────────┘ │ → UNCHANGED │
└──────────────────┘
principal ceiling ∩ agent capability ∩ principal ceiling on the row
The load-bearing property is what is not in that picture: the MCP server sends the catalog service the caller's bearer and nothing else — never a role, a capability, a delegation chain, or an acting-as header, and never a minted, exchanged or rewritten token. The catalog service therefore re-derives the principal and applies the same ceiling it would for a direct REST call. The intersection holds across the two layers rather than being passed between them, which is why the MCP server can only ever fail to narrow — it can never widen. (Asserting a caller-supplied role is exactly the fail-open shape [[MULTI-TENANT-ISOLATION|slice B4]] removed; it is not reintroduced.)
The honest cost: the per-type policies never see the actor, so they cannot do agent-aware row filtering. A tool needing "this agent may see only some of the rows its principal may see" would need a propagation design, which is out of scope here.
DelegationChainExtractor normalizes the validated token into a DelegationChain — the principal
(the token sub), the actor (the agent), and an ordered chain — read from a plain custom claim
carrying RFC 8693 act semantics, minted by a stock Keycloak protocol mapper. No token exchange, no
nightly build.
| Claim state | Reading | Why |
|---|---|---|
| absent | an ordinary human call — principal-only evaluation | Honest, not a bypass: principal-only is the widest the tool-gate ever goes, and it is still exactly the principal's own ceiling. Agent narrowing only ever restricts. |
| present, well-formed | an agent call — the intersection applies | |
| malformed / oversized / cyclic | deny | A broken agent claim is not a human. Never downgrade malformed to principal-only. |
AgentCapabilitySupplier resolves the actor's profile — allowed categories, an allowed-tool list,
allowed actions, a max risk tag. It follows the repo's supplier doctrine (ADR
[[0014-supplier-outage-error-distinct|0014]]) exactly:
| Outcome | Decision | Distinguishable? |
|---|---|---|
| resolved | narrow by the profile | |
| authoritative-empty (agent known, zero capability) | deny every tool — a real answer | distinct internal code + log line |
| outage (throws) | deny | distinct internal code + log line, identical caller-facing error (no oracle) |
The profile's allowed-tools list is mandatory: an empty list denies every tool. Reading it as
"unrestricted" would turn a profile someone trimmed to nothing into a profile that permits
everything.
The lookup is memoized per turn — a turn is one MCP request, never a session. Within a turn the roster and the gate must see one capability answer or they could disagree; across turns the memo must die, or revoking an agent's capability mid-session would not take effect until the client reconnected and "the list said I could" would quietly become an authorization.
infra/opa/policies/agent_tools.rego (package agent_tools, default allow := false) consumes the
shipped permission_categories expansion, so a principal's ceiling here is derived exactly the way
every other enforced path derives it:
principal_actions := permissions.effective_actions(input.role_definition, input.resource.attributes.target_type)
agent_actions := {action | some action in capability.allowed_actions}
effective_actions := principal_actions & agent_actions # set intersection can only SHRINKThat one line is the whole agent model: a capability naming an action the ceiling does not grant
contributes nothing. An agent can never do what its principal cannot. It lives in the policy, not
in Java, so it is auditable and opa test-able rather than drifting from the rule that is supposed
to own it.
Category, tool allow-list and risk-tag checks are independent ANDs on top, so satisfying one never
compensates for failing another. The risk ranking is a table in the policy for the same reason.
tools/list advertises only the tools the caller may actually call — one batch allowAll
round-trip (the ADR [[0016-action-enrichment-affordance-metadata|0016]] primitive) over one
AbacContext per registered tool, against the same bulk rule the call path asks.
Uniform for every caller, with no code branch: a human (no chain) is evaluated ceiling-only, an agent gets ceiling ∩ capability. The contrast comes from the policy.
Three rules make the hint safe:
- Call-time enforcement is authoritative and always runs. A listed tool never skips the gate. That is how mid-session revocation is caught — and it is why the roster may be degraded at all. There is deliberately no roster-derived cache and no "already checked in the pre-flight" shortcut; the moment one appears, the hint has silently become an authorization.
- The roster answers the same question the call gate will. Whatever the call path would decide
right now, the roster predicts. This settles the switch interaction rather than leaving it to be
discovered: with
agent-gateoff the call path evaluates an ordinary human principal, so the roster builds its contexts without the agent attributes and shows the ceiling-only cut. A roster that kept narrowing while calls did not would hide tools the caller can successfully call — the one direction a hint must never fail in. - Omit, never fabricate. A tool is only ever removed from the advertised list. Nothing is added, in any failure mode.
Decisions are paired with the registry's declaration order by index, then applied to the delegate's list by name — the two orders are different lists and nothing contracts them to agree.
Every edge lands on deny, or the smaller result.
| Edge | Failure | Lands on |
|---|---|---|
| delegation chain | claim absent | principal-only (an ordinary human call) |
| delegation chain | malformed / oversized / cyclic | deny |
| capability supplier | authoritative-empty | deny every tool |
| capability supplier | outage | deny (distinct code, identical caller-facing error) |
| principal ceiling | outage at call time | deny (tool-gate-ceiling-unavailable) |
| tool-gate OPA call | down / timeout / malformed / no allow binding |
deny |
| tool metadata | a tool with no declared action/category/risk/target-type | deny at registration time — startup fails, the tool is never exposed |
| roster batch | dead PDP, or a genuinely zero-capability agent | the empty roster, treated as authoritative (see below) |
| roster identity / capability / ceiling at list time | unreadable or throwing | the unfiltered list + WARN, with the gate still denying per call |
| roster adapter | pinned SDK internals moved by an upgrade | startup failure, naming the pins |
Why an empty roster is the honest answer. OpaClient.allowAll is contractually total and
fail-closed: it never throws, and normalises outage, timeout, non-200, malformed body and length
mismatch alike into an all-false vector. "The batch failed" is therefore not a signal this seam can
emit — a PDP outage is indistinguishable from a zero-capability agent, and the design does not
pretend otherwise. Both are answered with an empty roster, which is correct in both cases: during
that outage every tools/call denies too, so a roster advertising four unusable tools would be the
misleading one. Degradation to the unfiltered list survives only for the edges outside the
batch, which genuinely can fail.
| Switch | OFF behaviour |
|---|---|
example.mcp.authz.agent-gate.enabled |
The tool-gate stops narrowing. The call is evaluated exactly as an ordinary human principal's, and the catalog service still enforces that principal's ceiling on every resource — so OFF cannot grant an agent more than its principal already has. The roster reads the same switch and shows the same ceiling-only cut. |
example.mcp.authz.roster-filter.enabled |
The adapter and its smoke check are not installed at all — the deliberate escape hatch for the day an SDK bump moves the pinned internals. The served list is the unfiltered one, byte-identical to the outside-the-batch degradation path, with call-time enforcement unchanged. |
There is no switch that disables call-time enforcement. The authoritative gate is not optional.
A deny is a CallToolResult with isError set and structured content carrying a stable code and
the denying layer — tool-gate or target-gate. That is the protocol's own way of telling a model
something went wrong in a way it can act on: pick another tool, ask the human to escalate. Never a
stack trace, never a bare 5xx, never a silent empty result. The two layers being distinguishable is
what lets a model react instead of retrying blindly.
The tool-gate wraps every advertised tool's call handler by post-processing the specification list Spring AI's scanner produces, so a tool added later cannot be forgotten — nothing enumerates tool names.
./gradlew :example-mcp-server:test
opa test infra/opa/policies -vOne configuration pin you must not lose.
spring.ai.mcp.server.protocol: STREAMABLEis set explicitly inapplication.yml. Spring AI 2.0.0's auto-configuration matches the property string and ignores the properties class's own field default: with the property absent, the SSE condition (matchIfMissing = true) wins and the server silently serves the legacy transport (GET /sse+POST /mcp/message) whilePOST /mcp404s. The roster adapter's seam exists only on the streamable transport, and its smoke check fails startup naming this property if it is missing.
The streamable transport streams, so the security chain must permit
ASYNC. Atools/listresponse is written as SSE via an async dispatch; on that re-dispatch a stateless chain has no authentication left, andauthenticated()would deny a response that has already started — aborting a half-written chunked body rather than returning a clean 403. PermittingASYNC(as this module'sSecurityConfigdoes, alongsideERROR) widens nothing: an async dispatch can only exist for a request that already passed the chain on its initial dispatch.
ENABLE_MCP=1 ./deploy.sh up --pods 2 # force-enables OIDC + OPA + the user-service
scripts/postman/run-agent-tool-matrix.sh # the E1-E11 matrix, through the gatewayThe tool surface is gateway-fronted like every other service here — the scripted client enters at
http://localhost:9085/mcp, never a published pod port. Three details are load-bearing:
- The route.
ENABLE_MCPseeds anmcproute (/mcp*, priority 65 — above the/*catch-all and the user-service's 60, belowinternal-blocked's 70) carrying the user-service plugin set:openid-connect+response-rewrite+ tracing/CORS, and deliberately not the catalogopagateway plugin. This server authorizes itself, with its own tool-gate. Without the route,catalog-all(/*, priority 0) already matches/mcpand the whole matrix would quietly hit the catalog pods instead. - Three outbound base-URLs, all overridden in-network. The shipped defaults are
localhost, which inside a container is the container. The role-source one is the trap: point it wrong and every tool call denies withtool-gate-ceiling-unavailable, which reads like a broken policy and is a broken URL. - The actor comes from which client minted the token. The realm carries one client per demo
actor —
catalog-agent-{readonly,overreach,revoked}— each with a hardcoded-claim protocol mapper mintingact_chainwith that actor's id. So the same human can also obtain an ordinary no-actor token throughcatalog-gateway, which is exactly what the human-parity cells need. One agent client could not mint three actors, and a user-attribute mapper would bind the actor to the human.
agent-tool-matrix.postman_collection.json is the MCP client — a deterministic scripted one that
speaks the real streamable framing (initialize answers plain application/json and assigns
Mcp-Session-Id as a response header; a notification answers 202 with an empty body, not SSE;
every later request is SSE-framed). Every assertion is on the actual cut — which tool names, which
denials, which layer — never on response shape alone.
| Persona (same principal unless noted) | tools/list |
get_product on its own catalog |
|---|---|---|
| human — no actor claim | all four tools | allowed |
agent-readonly — capped below medium risk |
exactly list_catalogs, get_catalog |
denied tool-gate |
agent-overreach — capability lists WRITE, GRANT, every verb |
the human's four, never more | allowed |
the same agent-readonly, acting for a low-privilege principal |
[] |
denied |
The middle two rows are the whole argument: the capability narrows and never grants, and the
contrast is drawn on one token — the same human is permitted get_product directly. Alongside
them: a foreign catalog answers target-gate / ACCESS_DENIED, so a caller can tell the two layers
apart end to end; a tool omitted from the roster is still denied, not merely hidden; and killing
OPA mid-suite empties the roster and denies every call, with the pre-kill vector — same allows,
same denies — returning exactly on restart.
Three cells are rig drills the runner orchestrates rather than collection steps: it stops and
restarts the PDP around the outage folders, recreates the pod with agent-gate OFF (proving the
catalog's own gate still refuses what the tool-gate no longer does), and recreates it with the
readonly actor's capability emptied — a revocation that removes the tool from the roster and denies
it at call time.
Both were found driving the live rig, after the unit and integration suites were green — and both are the same shape: a third-party seam behaving differently from the mental model of it.
- The type-level ceiling under-approximated. It asked the user-service for governed targets of
the requested type, but membership lives on the governing root ([[HIERARCHICAL-AUTHORIZATION|ADR 0018]])
while the role it resolves to carries the whole hierarchy's permissions — so
?resourceType=productis legitimately empty for someone who may read every product under a catalog they govern. The tool surface removed access the caller had over REST, and denied the human and the agent for the same reason, erasing the contrast. Now enumerated over the requested type plusexample.mcp.authz.role-source.grant-scope-types(default[catalog]). Diagnostic tell: a human roster identical to a narrowed agent's is a ceiling bug, not a policy bug. - A target-gate denial arrived unlabelled. Spring AI's annotation-scanned call handler catches
whatever an
@McpToolmethod throws and flattens it to a plain-text error result, so the gate's owncatchnever fires in the real path — and the layer distinction this slice exists to make was silently gone. A request-scoped, read-once record now carries layer and code across that seam. The unit tests could not see it: they build the specification themselves, with a handler that throws straight through.
Not defects — demo scope, named so a reader does not mistake them for the design:
- The two list tools expose no paging.
list_catalogsandlist_categoriesproxy paged REST endpoints without forwardingpage/perPage, so a caller silently gets the API's default first page. A real tool surface would either forward the parameters or state the cap in the tool description; four read tools over a demo catalog do not need it, and adding it would put a pagination contract in a slice about authorization. - The ceiling is resolved per turn, not cached across them. That is deliberate (a revocation lands on the next turn), and it costs one fan-out per turn per target type — bounded by the starter's resolve memo within a turn, and chunked so a principal governing many roots cannot build a request line the servlet container refuses.
MCP transport / OAuth authorization (the scope fence sits above the validated token, delegated to
the ecosystem's MCP security layer) · the host-side ToolCallingManager leg · HITL / approval flows ·
policy-filtered retrieval · listChanged notifications (the SDK offers only a global broadcast;
revocation bites at call time immediately, and at the next re-list for the view) · a real LLM in the
loop · extracting an opa-abac-agent library module (the slice's exit criterion).
The existence oracle is accepted and documented: a roster-hidden tool still answers tools/call
with the layer-naming advisory deny, revealing that it exists. All demo tools are publicly
documented, and hiding existence was never a goal.
- ADR [[0028-agent-tool-call-authorization|0028]] — the two-layer decision model, enforcement by composition, the additive dual-identity subject shape, example-first packaging.
- ADR [[0014-supplier-outage-error-distinct|0014]] — the tri-state supplier contract the capability
seam copies · ADR [[0023-request-scoped-resolution-memoization|0023]] — the memo scope this slice
narrows to a turn · ADR [[0016-action-enrichment-affordance-metadata|0016]] — the batch
allowAllprimitive the roster reuses. - [[ABAC-AUTHORIZATION]] — how an
AbacContextis built and asked · [[PERMISSION-MODEL]] — thepermission_categoriesexpansion the ceiling is derived through · [[HTTP-RESILIENCE]] — the edge-wrapping discipline every outbound call here follows. - [[MULTI-TENANT-ISOLATION]] — where asserting a caller-supplied role was removed as fail-open.