Skip to content
13 changes: 3 additions & 10 deletions api/network.go
Original file line number Diff line number Diff line change
Expand Up @@ -283,16 +283,9 @@ const (
// between a root path "/" and an empty path "".
HeaderMeshNoTrailingSlash = "X-Mesh-No-Trailing-Slash"

// HeaderMeshRequiredLabels constrains an inference request on the sidecar's
// OpenAI-compatible endpoints (/v1/*) to providers attested with every
// pair of a comma-separated list of "key=value" label requirements (see
// api/labels.go and LabelCheck); invalid entries are rejected with HTTP
// 400. It can only narrow what mesh policy allows, never widen it. Absent
// means any provider permitted by policy.
//
// Reserved as part of the sidecar contract; enforced by the provider
// scorer. Label declarations are routing hints until attested via the
// node's Biscuit (see api/labels.go).
// HeaderMeshRequiredLabels lists "key=value" pairs the provider must carry.
// It only narrows what policy allows. The node honours it on /v1
// completions, /mesh/*, /egress/* and CONNECT, and strips it there.
HeaderMeshRequiredLabels = "X-Mesh-Required-Labels"

// HeaderMeshPrincipal, HeaderMeshRoles, HeaderMeshTask, and HeaderMeshTaskID carry
Expand Down
61 changes: 39 additions & 22 deletions site/content/docs/concepts/authorization.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ fails a check, such as the target check, or an appended task block.

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.
the built-in `system://mesh.catalog`. A peer asks a node's catalog for the
services that node hosts. The routers' directory, not the catalog, tells a
caller who provides a name.

Standing mesh roles grant access to services by name (`allowed_services`),
grant the right to serve them (`allowed_publish`) and can narrow HTTP
Expand Down Expand Up @@ -68,9 +70,12 @@ from a file (`--policy-file`).
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 call `mcp://calculator` does not serve it.
provider by callers, only when its own credential grants serving it. If
absent, holders may publish nothing but an egress destination whose
`served_by` selects them. Such a destination needs no entry here, because
the control plane grants `egress://<name>` to the nodes that `served_by`
selects. Calling and serving are granted separately: a role that may call
`mcp://calculator` does 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,
Expand Down Expand Up @@ -113,15 +118,21 @@ 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
control plane writes the credential's authority block as Datalog facts: one
`role(...)` fact per role, plus the grants compiled from the lists of those
roles. 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 roles in the block depend on how the member enrolled. For an identity
that the identity provider authenticates, the control plane resolves the
roles from the bindings. For a member that enrolled with a bootstrap token,
the authority block carries only the token's role. A role bound to
`node:<peer-id>` or to `mesh:system:authenticated` reaches that member
through the policy rules described next, not through its credential.

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
Expand All @@ -130,9 +141,9 @@ 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.
credentials. A removed grant stays in the tokens that already carry it,
until each token is refreshed or expires. A grant that reaches a member only
through these rules ends when each node next fetches the policy.

## What the hosting node checks

Expand All @@ -150,20 +161,23 @@ The node builds a Biscuit authorizer over Block 0 and adds, in this order:
destination outside the mesh (`egress://`) it adds `host(...)` and
`port(...)`.
2. **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 token belongs to the peer that presents it), the expiration check
against the current time, and the target check
`allow_network_target($fact, $val) or target_unrestricted(true)`. The
node adds the target check after its local rules. The position of a
check does not change its result, because Biscuit runs every check after
it has applied all rules.
3. **The node's own identity facts**, taken from its own credential, as
`target_fact("group", "dev-nodes")` and similar. The caller's
`allowed_targets` are matched against these facts.
4. **The node's local rules**, from the `attenuation` block of its
configuration file: extra facts, extra checks, and `allow` and `deny`
policies.
configuration file: the `rules`, the `checks`, and the `allow` and
`deny` policies.
5. **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 check
`allow_network_target(...) or target_unrestricted(true)`, and the rules
that turn an [HTTP grant](../../reference/policy/#http-grants) into a
service grant when the request's method and path match it.
global wildcards, and the rules that turn an
[HTTP grant](../../reference/policy/#http-grants) into a service grant
when the request's method and path match it.
6. **The synced mesh policy rules** described above.

Biscuit evaluates every `check` and requires all of them to pass. It then
Expand All @@ -178,7 +192,7 @@ 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
- The current time must be at or before every block's `expire_time` (when
set).
- In **every** appended `TaskAuthorizationRule` block, at least one `TaskRule`
must match the request's `service`, HTTP `method` and `path` (when
Expand Down Expand Up @@ -266,8 +280,11 @@ Labels are used in four places:
to its `attenuation.checks`. Every caller's credential must then carry
that label.
- **A caller choosing providers** sends `X-Mesh-Required-Labels: region=eu`
on the node's `/v1` inference endpoints or on a proxied A2A request, or
passes `required_labels` to `call_remote_tool`. Before it sends any request
on a request to the node, or passes `required_labels` to
`call_remote_tool`. The node honours the header on `/v1/chat/completions`
and `/v1/completions`, on every `/mesh/<peer-id>/<type>/<name>/` request,
on `/egress/<destination>/` and on `CONNECT`. A node that serves the
request itself checks its own labels. 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. A provider that cannot show them all is skipped.
Expand Down
10 changes: 6 additions & 4 deletions site/content/docs/concepts/identity.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,12 @@ contains:
| `label("region", "eu")` | One fact per declared and permitted label. |
| `granted_service_*`, `granted_target_*` | What the roles allow, compiled from `allowed_services` and `allowed_targets`. [Authorization](../authorization/) describes them. |

The node does not set any of these facts. Roles come from the bindings in
the mesh policy, not from the identity provider: an issuer's `roles` claim
is stored as `idp_role()`, which grants nothing by itself. Labels are the
ones the node asked for and the policy allowed. Grants come from the policy.
The node does not set any of these facts. For an OIDC enrollment, roles
come from the bindings in the mesh policy, never from the identity provider.
A bootstrap enrollment gets only the role of its bootstrap token. An
issuer's `roles` claim is stored as `idp_role()`, which grants nothing by
itself. Labels are the ones the node asked for and the policy allowed.
Grants come from the policy.

### 2. Delegated Session Biscuit (`POST /token/exchange`, `/oauth/token`)

Expand Down
4 changes: 2 additions & 2 deletions site/content/docs/contributing/security-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,8 +274,8 @@ and evaluates it in host code alongside Block 0's Datalog policy. Within a
single `TaskAuthorizationRule`, a request matches if it satisfies at least one
`TaskRule` (an empty `rules` list denies everything). Across blocks `1..k`,
semantics are strict intersection (logical AND): the request must satisfy
Block 0 Datalog RBAC, arrive before every block's `expire_time`, and match at
least one `TaskRule` in every appended block.
Block 0 Datalog RBAC, arrive at or before every block's `expire_time`, and
match at least one `TaskRule` in every appended block.

#### Per-service-type PEP matching semantics

Expand Down
11 changes: 7 additions & 4 deletions site/content/docs/guides/exposing-services.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,9 +187,10 @@ curl -fsS -X POST https://mesh.example.com/policies \
--data @policy.json
```

Discovery (`system://mesh.catalog`) follows the same rule. A role that should
be able to browse what a node offers needs `system://mesh.catalog` in its
grants. On a mesh where everyone may browse, grant it to `mesh:role:node`.
The node's catalog (`system://mesh.catalog`) follows the same rule. A role
that should be able to browse what a node offers needs
`system://mesh.catalog` in its grants. On a mesh where everyone may browse,
grant it to `mesh:role:node`.

## Labels

Expand Down Expand Up @@ -246,7 +247,9 @@ If the service is missing, check these causes in order:

1. The backend did not answer the probe. Look for "Withholding" in the
node log.
2. The caller's role lacks `system://mesh.catalog`.
2. This node's role does not grant `mcp://calculator` in `allowed_publish`,
so the router refused its directory record. Look for "Record denied" in
the router log.
3. The two nodes have not exchanged discovery state yet. Wait a few seconds
and try again.

Expand Down
4 changes: 2 additions & 2 deletions site/content/docs/preview/agent-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,8 @@ single pass, and evaluate the rules directly in host code.
**at least one** `TaskRule`. An empty `rules` list denies everything.
- Across appended blocks `1..k`, semantics are **strict intersection (logical
AND)**: the request must be permitted by Block 0's standing Datalog policy,
arrive before every block's `expire_time`, and match at least one `TaskRule`
in **every** appended `TaskAuthorizationRule` block.
arrive at or before every block's `expire_time`, and match at least one
`TaskRule` in **every** appended `TaskAuthorizationRule` block.

Each `TaskRule` scopes:

Expand Down
2 changes: 1 addition & 1 deletion site/content/docs/reference/node-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ and `.well-known/agent-card.json` is rewritten for the mesh.

| Header | Effect |
|---|---|
| `X-Mesh-Required-Labels: k=v[,k=v]` | Forward only to a peer whose credential attests every listed pair. Otherwise `403`. Removed before forwarding. Also honoured on `/v1/*`. |
| `X-Mesh-Required-Labels: k=v[,k=v]` | Forward only to a peer whose credential attests every listed pair. Otherwise `403`. Removed before forwarding. Also honoured on `/v1/chat/completions`, `/v1/completions` and `CONNECT`. On `/egress/*` this node is the provider, so it checks its own labels. |

### Talking MCP through the proxy

Expand Down
19 changes: 11 additions & 8 deletions site/content/docs/reference/policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ notice.
|---|---|---|
| `name` | string, required, unique | The role name. `mesh:role:node` and `mesh:role:router` are the roles that the two binaries request at enrollment. Any other name is an ordinary role. |
| `allowed_services` | list of service patterns | Services that holders may call. |
| `allowed_publish` | list of service patterns | Services that holders may serve. A router keeps a member's directory record only for a service its credential grants here, and a caller sends a request to a provider it reached by name only when the provider's credential does. If absent, holders may publish nothing. See [Publishing](#publishing). |
| `allowed_publish` | list of service patterns | Services that holders may serve. A router keeps a member's directory record only for a service its credential grants here, and a caller sends a request to a provider it reached by name only when the provider's credential does. If absent, holders may publish nothing but an egress destination whose `served_by` selects them. See [Publishing](#publishing). |
| `allowed_targets` | list of target patterns | Nodes that holders may call. `*` means any node. If absent, holders may call no node. |
| `allowed_labels` | list of label patterns | Labels that a node holding this role may declare at enrollment. If absent, no labels may be declared. |
| `custom_datalog` | list of Datalog statements | Facts are minted into the credentials of holders. Rules are distributed to nodes and applied when a holder is verified. |
Expand All @@ -94,8 +94,9 @@ A fact whose only meaning is that a grant exists carries the single term
`true`, because the Biscuit grammar requires at least one term per predicate.
The same holds for `target_unrestricted(true)` below.

`system://mesh.catalog` is the built-in discovery service that every node
runs. A role that should be able to list the tools of a node needs it.
`system://mesh.catalog` is the built-in service on every node that lists the
services the node hosts. A role that should be able to list the tools of a
node needs it.

### Publishing

Expand Down Expand Up @@ -344,15 +345,17 @@ a single `tar_block("<base64url-proto>")` fact encoding `TaskAuthorizationRule`:
```

Across appended blocks `1..k` (up to `MaxAttenuationBlocks = 8`), evaluation is
strict intersection: a request must pass Block 0 Datalog policy, be before
every block's `expire_time`, and match at least one `TaskRule` in every
strict intersection: a request must pass Block 0 Datalog policy, arrive at or
before every block's `expire_time`, and match at least one `TaskRule` in every
appended `TaskAuthorizationRule` block.

## Evaluation

At enrollment, refresh, and token exchange, the control plane resolves the
identity's roles from the bindings and mints into the credential one `role()`
fact per role and the compiled `granted_*` facts of every role. The control
At enrollment, refresh, and token exchange, the control plane mints into the
credential one `role()` fact per role and the compiled `granted_*` facts of
every role. For an identity that the identity provider authenticates, the
control plane resolves the roles from the bindings. A member that enrolled
with a bootstrap token gets only the token's role. The control
plane also renders the policy as Datalog rules served as `datalog_rules` at
`GET /policies`. Nodes, routers and SDK members fetch this text
(`--control-plane-sync-interval`, 15 minutes for a node, 5 for a router) and
Expand Down
Loading