Authorization
Authorization in Agent Mesh answers one question: may this caller perform this operation on this service on this node right now? Four sources contribute to the answer, and the destination node combines them:
- The caller’s credential (Block 0 authority facts signed by the control plane).
- The standing mesh policy (roles, bindings, HTTP narrowings, and egress destinations distributed by the control plane as Datalog rules).
- Any appended
TaskAuthorizationRuleblocks (tar_block) on the credential, which narrow authority for a specific task or sub-agent hop. - The hosting node’s local
attenuationconfiguration.
The credential and the standing policy say what the caller is granted. A
task block can only narrow those grants. The node’s local
attenuation can narrow them or
widen them, because its policies run before the baseline policies and the
first match wins. A local deny overrides a mesh grant. A local allow
admits a caller that the mesh did not grant. No layer admits a caller that
fails a check, such as the target check, or an appended task block.
Services and task operations
A node publishes services, each with a type and a name: mcp://calculator,
inference://vllm-eu, a2a://triage, egress://bigquery.googleapis.com, and
the built-in system://mesh.catalog that answers discovery queries.
Standing mesh roles grant access to services by name (allowed_services),
grant the right to serve them (allowed_publish) and can narrow HTTP
methods and paths (http). On top of standing roles, a
holder can append TaskAuthorizationRule blocks (tar_block) to scope a
token for a single task or sub-agent hop—narrowing which services, MCP tools
(operation.allowed_tools), HTTP methods and paths
(operation.allowed_methods, operation.allowed_paths), and upstream cloud
IAM permissions and resources (operation.allowed_permissions,
allowed_resources) that token may use.
Mesh policy: roles and bindings
The mesh policy is a document held by the control plane. You edit it through
POST /policies or the console. agentmesh-one can also seed it on first boot
from a file (--policy-file).
Roles name a set of standing permissions:
{
"name": "developer",
"allowed_services": ["mcp://code-reviewer", "mcp://build-runner.*", "inference://*"],
"allowed_publish": ["mcp://build-runner.*"],
"allowed_targets": ["group:dev-nodes"],
"allowed_labels": ["region=*"],
"http": [
{ "service": "mcp://code-reviewer", "methods": ["POST"], "paths": ["/mcp"] }
],
"custom_datalog": []
}
allowed_services: the services holders may call, astype://name.*can stand for the whole name, for a leading component (mcp://*.internal) or for a trailing one (mcp://build-runner.*). A bare*means every service.allowed_publish: the services holders may serve, in the same syntax. A member announces a service to the directory, and is accepted as its provider by callers, only when its own credential grants it here. If absent, holders may publish nothing. Calling and serving are granted separately: a role that may callmcp://calculatordoes not serve it.allowed_targets: the nodes holders may call, as facts about the destination node’s identity:node:<peer-id>,user:<sub>,email:<addr>,group:<name>,idp_role:<name>, or*. If absent, holders may call no node.allowed_labels: the labels a node with this role may declare at enrollment:key=value,key=*or*. If absent, the node may declare no labels.http: optional method and path restrictions for entries inallowed_services.custom_datalog: extra Datalog facts or rules for holders of the role.
Bindings attach roles to identities:
{ "role": "developer", "members": ["group:eng", "email:alice@example.com"] }
A member is one of: user:, email:, group: or idp_role: followed by a
value from the identity’s OIDC claims; node: followed by a peer ID; or the
special value mesh:system:authenticated, which matches every holder of a
valid credential. That includes members that enrolled with a bootstrap token
and delegated sessions, not only the identities that the identity provider
authenticates. Be careful with this member. On a public identity provider
it means everyone, so bind it only to roles with few grants.
Roles are never members and never claims. role:x is not a valid member,
and an identity provider cannot give out a mesh role by putting it in a
roles claim. Such a claim becomes an idp_role fact, and a binding can
choose to honour it. The two built-in roles, mesh:role:node and
mesh:role:router, follow the same rule. A binary that enrolls with an
OIDC identity gets the role only if a binding gives the role to that
identity. A binary that enrolls with a bootstrap token gets the token’s
role without a binding.
The control plane validates a policy when it is posted. It rejects a policy that references an undefined role, uses an unknown member prefix, or would give a single identity more grants than the Biscuit authorizer can evaluate.
From policy to facts
At enrollment, refresh, and token exchange (POST /token/exchange), the
control plane resolves the identity’s roles from the bindings and writes the
result into the credential’s authority block as Datalog facts: one role(...)
fact per role, plus the grants compiled from the lists of every role. For
example, allowed_services: ["mcp://calculator"] becomes
granted_service_set("mcp", ["calculator"]), mcp://* becomes
granted_service_all("mcp"), and mcp://*.internal becomes
granted_service_suffix("mcp", ".internal"). Wildcards keep their dot, so
*.acme.example matches svc.acme.example but not evil-acme.example.
The control plane also renders the policy as Datalog rules such as
role("developer") <- group("eng") and
granted_service_set("mcp", ["calculator"]) <- role("developer"). Nodes
fetch this text every --control-plane-sync-interval (15 minutes by default,
sooner when a policy update event reaches them) and add it to their
authorizer as it arrives. When a node verifies a credential, these rules run
against the identity facts in it. A grant added to the policy therefore
reaches every node within the sync interval, with no need to reissue
credentials. Removing a grant takes effect through the credential instead:
the facts already in a token stay valid until the token is refreshed or
expires.
What the hosting node checks
When a request for service S arrives from peer P, the node validates the
Biscuit structure and runs two enforcement stages:
Stage 1: Standing Datalog policy (Block 0)
The node builds a Biscuit authorizer over Block 0 and adds, in this order:
- The request facts:
service("mcp", "calculator")for the requested service, andconnection_peer_id(P)from the authenticated connection. When the node handles the request as HTTP it addsmethod("GET")andpath("/v1/models"), the path as the backend will see it; for a destination outside the mesh (egress://) it addshost(...)andport(...). - The baseline checks:
client_peer_id($id), connection_peer_id($id)(the token belongs to the peer that presents it), and the expiration check against the current time. - The node’s own identity facts, taken from its own credential, as
target_fact("group", "dev-nodes")and similar. The caller’sallowed_targetsare matched against these facts. - The node’s local rules, from the
attenuationblock of its configuration file: extra facts, extra checks, andallowanddenypolicies. - The baseline policies:
allow if service($t,$n), granted_service_exact($t,$n)and the equivalent policies for sets, prefixes, suffixes, per-type and global wildcards, plus the target checkallow_network_target(...) or target_unrestricted(true), and the rules that turn an HTTP grant into a service grant when the request’s method and path match it. - The synced mesh policy rules described above.
Biscuit evaluates every check and requires all of them to pass. It then
walks the policies in order and applies the first allow or deny that
matches.
Stage 2: Task authorization rules (tar_block 1..k)
If the Biscuit carries appended blocks (1..k, up to MaxAttenuationBlocks = 8),
UnmarshalInbound verifies before building the authorizer that every appended
block has 0 rules, 0 checks, and 1 tar_block("<base64url-proto>") fact.
After Stage 1 succeeds, the verifier evaluates the decoded
TaskAuthorizationRule chain against the request:
- The current time must be strictly before every block’s
expire_time(when set). - In every appended
TaskAuthorizationRuleblock, at least oneTaskRulemust match the request’sservice, HTTPmethodandpath(whenallowed_methodsorallowed_pathsare non-empty), and MCP tool name ontools/call(whenallowed_toolsis non-empty).
Because every block must match, appending a new tar_block at a sub-agent hop
computes the intersection ($\text{Standing Policy} \cap \text{TAR}_1 \cap \dots \cap \text{TAR}_k$)
and can never widen authority.
Why a caller cannot forge a fact or smuggle Datalog
Two kinds of Datalog fact meet in the authorizer. The facts in the
credential’s authority block were written and signed by the control plane:
roles, grants, labels, and client_peer_id. The facts about the request
(service, method, path, host, port, connection_peer_id, time)
are added by the node that received the request, from what arrived on the
wire. A caller writes neither.
When a holder attenuates a token by appending a block, Agent Mesh never evaluates
holder-authored Datalog rules or checks. Appended blocks are restricted to a
single tar_block("<base64url-proto>") fact, which is invisible to Block 0’s
Datalog rules and is evaluated by the verifier’s own TaskAuthorizationRule
matcher. This guarantees that:
- An untrusted holder cannot trigger expensive Datalog backtracking via
crafted
check ifqueries. - The
TaskAuthorizationRuleenforced by the mesh PEP is byte-for-byte identical to the rule intersected byCloudTokenExchangerat egress.
Workloads and agents acting through a node or gateway
When multiple workloads, users, or sandboxed agents share a agentmesh-node (or
call through agentgateway / Istio), they do not share the node’s own
permissions:
- On
/mcpand/v1/*(or via RFC 8693POST /oauth/tokenand Envoyext_authz/ext_proc), the caller presents its own platform JWT (OIDC ID token, Kubernetes projected SA JWT, or SPIFFE JWT-SVID) or a task-attenuated Biscuit. agentmesh-nodeexchanges platform JWTs viaPOST /token/exchangeinto a Delegated Session Biscuit carrying the caller’s ownuser(),email(),group(), androle()facts, bound to the node viaclient_peer_id()andactor_node().- The destination node authorizes the request against the caller’s delegated
Biscuit and its
tar_blockchain, never the origin node’s own roles.
Local rules
The attenuation block in agentmesh-node.yaml gives the hosting node the final
say. Use it for constraints that the operator of that node wants regardless
of what the mesh policy grants:
attenuation:
rules:
- 'maintenance(true) <- time($t), $t > 2026-12-31T00:00:00Z;'
checks:
- 'check if label("jurisdiction", "eu");' # every caller must carry this label
policies:
- 'deny if service("mcp", "db-writer"), group("contractors");'
- 'deny if maintenance(true);'
rules derive new facts, checks must all hold, and policies are
evaluated before the baseline policies. Every predicate in Datalog carries at
least one term (presence-only facts are written name(true)). A syntax error
in any statement stops the node at start, so a broken rule cannot weaken the
node without notice.
Labels
Labels are key=value pairs. A node declares them in its configuration, and
the control plane writes them into the node’s credential as label(k, v)
facts, one per label, if a role the node holds allows them. Labels let
policy describe where a node is or what it is for. Agent Mesh does not define a
fixed set of keys: region, jurisdiction, team, compliance, or
whatever the operator needs. A router declares labels the same way
(agentmesh-router --label), under the same allowed_labels of its role,
and carries them in its credential the same way.
Labels are used in four places:
- A provider restricting callers adds
check if label("region", "eu")to itsattenuation.checks. Every caller’s credential must then carry that label. - A caller choosing providers sends
X-Mesh-Required-Labels: region=euon the node’s/v1inference endpoints or on a proxied A2A request, or passesrequired_labelstocall_remote_tool. Before it sends any request data, the calling node fetches the provider’s credential through the mutual handshake, verifies it, and confirms that every label named is attested in it. When the request names the provider, the node refuses the request if the provider cannot show them all. On the/v1inference endpoints and on aCONNECTtunnel the node picks the provider itself. There the node skips a provider that fails the check and tries the next one. - An operator drawing a boundary sets
egress.require_labelsin the node configuration. Every provider this node talks to must attest all of those labels, whether or not the caller asked for any. The caller can add further requirements but cannot remove the operator’s. - A node choosing routers sets
--router-selector key=valueto attach only to routers that attest every pair, and--router-prefer key=valueto attach to routers that attest more of those pairs before routers that attest fewer. The control plane lists on/infothe labels it attested in each router’s credential, and the node chooses from that list before it connects. The handshake checks that the router’s credential is valid and holds the router role, but it does not check the labels again. This places a node on routers; it grants the routers nothing, and a call still reaches any peer on any router.
The header, the operator floor and the router selector follow the same
matching rule: a map of key=value pairs, one value per key, and the peer
must attest every pair. Listing more pairs narrows the set of acceptable
peers, as it does in a Kubernetes label selector or a Prometheus matcher.
The only labels that authorize anything are the signed ones in a credential.
See also
- Policy reference: every field, pattern and fact name.
- Agent architecture:
TaskAuthorizationRule(tar_block),CloudTokenExchanger, and gateway integration. - Node configuration reference: the
attenuation,labelsandegressblocks. - Reaching services outside the mesh: the node as a policy enforcement point and credential broker for outbound API calls.