Security
This page documents Orleans.Lattice.Api.Mcp 9.9.0, in the documentation for Orleans.Lattice 9.9.0 (release line 9.9), built 2026-10-04. It is also published as markdown, with every table and list, at security.md, and llms.txt lists every page.The MCP server exposes read, write, and control facades to an AI agent, so it is built to fail closed: an unauthenticated or unauthorized session can enumerate nothing and call nothing until the host explicitly opts in. Three layers combine to deliver that posture.
1. Endpoint authorization
With RequireAuthorization at its true default, MapLatticeMcp() applies ASP.NET Core RequireAuthorization() to the mapped transport, so an anonymous request never reaches the MCP server at all. Set it to false only when an outer authentication boundary (a gateway, a reverse proxy, another middleware) already guarantees the caller is authenticated.
2. The coarse authorizer seam
Every inbound request passes through an ILatticeApiMcpAuthorizer before it reaches a facade. The binding registers DenyAllMcpAuthorizer by default (via TryAdd), so a host that maps the surface without configuring authorization rejects every call rather than exposing the cluster unauthenticated. A host opts in by registering a permissive or custom authorizer:
The authorizer is consulted at both enforcement points through one lock-step gate: tool advertisement (tools/list, in the per-session discovery configurator) and tool invocation (tools/call, in the credential-stamping tool). A tool the authorizer denies is therefore never advertised and cannot be invoked directly, so there is no discovery-versus-invocation gap. The gate is fail-closed: it denies when there is no HttpContext to authorize against or no authorizer registered, so the surface stays closed until a host deliberately opts an authorizer in. Only the lattice_capabilities meta-tool is exempt from the coarse gate.
public sealed class ReadOnlyStateMcpAuthorizer : ILatticeApiMcpAuthorizer
{
public Task<bool> IsAuthorizedAsync(
LatticeApiMcpAuthorizationContext authorizationContext,
CancellationToken cancellationToken)
{
// Let only the introspective state tools past the coarse gate; the
// per-tree / per-key access gate still applies afterwards.
var allowed = authorizationContext.ToolName
.StartsWith("lattice_state_", System.StringComparison.Ordinal);
return Task.FromResult(allowed);
}
}
AllowAllMcpAuthorizer is the opt-in "defer everything to the access gate" implementation: it permits every request through the coarse gate and lets the per-tree / per-key enforcement on the gated ILattice surface make the real decision. Register it when the coarse gate adds no value beyond the subject-scoped decisions the access gate already makes.
3. The credential bridge
The coarse gate decides only whether a request may reach a facade; it does not decide what the caller may see once there. That is the job of the credential bridge. The default bridge reads the authenticated principal off the HttpContext and resolves a LatticeCredential; at each tool invocation the resolved credential is stamped onto the ambient LatticeCredentialContext, so every facade call the tool makes runs under the caller's subject and flows through the same per-tree / per-key access gate the gRPC bindings and the data path already use. The bridge is fail-closed: it resolves a credential only for an authenticated principal, and returns none (anonymous) otherwise. It is the public ILatticeApiMcpCredentialBridge seam, TryAdd-registered, so a host can substitute its own bridge.
The CredentialHeaderName and CredentialScheme options control which inbound header the token is read from and which scheme prefix is stripped before the remaining token is used as the credential. An authenticated session that carries no usable token header - a certificate- or cookie-authenticated caller, or a bare scheme with no token - still resolves to a non-anonymous credential: the bridge uses the resolved principal id as the token.
The bridge resolves the caller's principal id from the durable object-id (oid) claim first, falling back to sub and then the identity name. For an Entra delegated (user) token sub is a pairwise (user, client-app) identifier that differs from the stable oid the silo auth model keys subjects on, so keying discovery on oid ensures the subject the tool list is filtered for is the same subject the access gate enforces on - grants authored once by oid apply consistently across every client app.
3a. The active-tenant bridge
On a cluster running the optional tenancy add-on, per-tenant capacity governance (write admission and quota enforcement) is scoped by the call's active tenant, a channel distinct from caller identity. A parallel bridge lifts the caller's asserted active tenant from a single inbound header - lattice-active-tenant by default, set by ActiveTenantHeaderName - and stamps it onto the ambient LatticeActiveTenantContext at each tool invocation, right beside the credential stamp. In-silo the stamped tenant flows to the grain on the Orleans request context; on a remote (split) head the credential-forwarding interceptor re-emits it as a gRPC metadata header, so both topologies reach the same silo-side enforcement.
Like the credential, the header carries only an assertion: the tenancy add-on's resolver re-validates it against the caller's authenticated subject membership downstream, and the access gate authorizes the operation before any per-tenant accounting is consulted, so a caller cannot escalate by asserting a tenant it is not a member of. The per-tenant admission controller is deliberately not part of that check - it runs strictly after authorization and is an accounting step, not an authorization one. The bridge performs no authorization of its own and is fail-closed - an absent, blank, or syntactically invalid header asserts no tenant. It is the public ILatticeApiMcpActiveTenantBridge seam, TryAdd-registered, so a host can substitute its own; setting ActiveTenantHeaderName empty disables header-based tenant selection. On a non-tenancy cluster the whole path is inert and allocation-free.
The stamp is applied at every facade tool invocation, beside the credential stamp, and additionally at the lattice_list_regions discovery tool, which stamps the tenant but no credential (it is a discovery meta-tool, not a facade tool, so it does not run under the caller's stamped credential). Both stamps run through the same IHttpContextAccessor and bridge, so the tenant a facade tool acts as is the same tenant discovery is scoped to.
3b. Tenant-scoped region discovery
lattice_list_regions projects the host's routing topology: each entry carries a region id, a cluster id, and the per-group gRPC endpoint of that region. On a cluster running the tenancy add-on that is operator information, so the tool answers differently depending on whether the call asserts a tenant.
A non-default tenant assertion is validated before it scopes anything: the head re-resolves it through ITenantContextResolver - the seam the tenancy add-on registers, which checks the assertion against the caller's own membership - and honours it only when the resolved tenant is the asserted one.
| Caller | What lattice_list_regions returns |
|---|---|
No tenant asserted (an operator, or any caller on a non-tenancy cluster), or any call to a head that cannot resolve tenant standing (a remote head without the TenantAdmin endpoint) |
The full routing topology, unannotated and byte-for-byte as before tenant scoping existed. |
| The reserved default tenant | The same full topology - the default tenant is the pre-tenancy behaviour by definition. |
A non-default tenant the caller may not act as: the head's ITenantContextResolver refuses the assertion or resolves it to a different tenant, or the head registers no validating resolver |
The current region alone, with no tenantScope annotation, so the asserted tenant id is never echoed back. The tenant's standing is never looked up, so the answer does not reveal whether that tenant exists. |
| A validated non-default tenant, standing resolved | The current region, plus only those peers in the tenant's actionable set (allowed union resident). Every entry is annotated with a tenantScope object. |
| A validated non-default tenant whose standing the head's tenancy resolver cannot establish | The current region alone. Never a fallback to the full topology. |
In the shipped registrations the two validated-tenant rows are not reached. The discovery tool stamps the tenant but no credential (see 3a), so on a co-hosted head the tenancy add-on's resolver sees an anonymous caller and refuses every non-default assertion, and a remote head registers no validating resolver at all. A non-default tenant assertion is therefore currently answered with the current region alone and no tenantScope annotation - except by a remote head without the TenantAdmin endpoint, which answers it unscoped under the first row.
The actionable set is the union of the tenant-facing region sets described in the tenancy guide: the regions the operator has authorized the tenant into, and the regions the tenant is currently resident in. A region outside it is neither usable by that caller (routing a call there is refused by the residency gate) nor modifiable by it (lattice_tenant_set_residency refuses anything outside the allowed set), so omitting it removes disclosure without removing capability.
The current region is always advertised, even when the tenant has no standing in it. The caller is already talking to it, so concealing it would break the caller's own session rather than conceal anything; its tenantScope reports the standing truthfully, which may be isAllowed: false with status None.
The tenantScope annotation is additive and optional: it is present only on a tenant-scoped answer. An operator answer, and every answer on a non-tenancy cluster, carries no tenantScope property at all.
{
"regionId": "eu-west",
"clusterId": "cluster-eu",
"isCurrent": false,
"tenantScope": {
"tenantId": "acme",
"isAllowed": true,
"status": "Online",
"isResident": true
}
}
Scoping is keyed on the asserted active tenant, not on the caller's role. An operator administering the platform sends no lattice-active-tenant header, so the operator path costs no extra authorization round trip - it simply never enters the scoping branch. This mirrors how ITenantEnumerationFilter scopes tree, tag-index, covered-tree, and view enumeration.
Tenancy off is free. The tenancy probe is a single ambient-context read - no service resolution, no allocation - and it is false whenever nothing stamped a tenant. A host with no tenancy add-on therefore keeps the original path, exactly as before - and with no region-identity verification configured and every cluster id known, that path returns the router's frozen snapshot by reference. A lattice-active-tenant header sent to such a host changes nothing: scoping also requires a live tenancy region-visibility resolver, so the call is answered unscoped. The same holds on a remote head configured without the TenantAdmin endpoint, which has no way to resolve a tenant's standing - so on a tenancy estate every remote head that tenant callers reach needs that endpoint for discovery to be scoped.
lattice_capabilities is deliberately unchanged: it advertises only the current region's per-group endpoints, which the caller is already connected to, so it discloses no peer topology and needs no scoping.
Permission-scoped discovery
Because the bridge resolves the caller's subject, the per-session discovery configurator can filter the advertised tool list to the caller's effective permissions before it is returned: a facade group's tools are listed only when the caller holds an Allow grant for at least one operation the group covers, so a caller is never shown - and then denied - a group it holds no grant for. The filter is coarse by design: discovery reads grant presence, not Deny rules, and ignores a grant's scope with one exception - the scopeless capabilities, Telemetry and AppInstall, count only from a whole-tree rule written at cluster-wide scope (LatticeScope.ClusterWide()), so a tree-scoped rule carrying the Telemetry bit neither lists the telemetry group nor unlocks its tools, and neither does a key- or prefix-scoped rule on the cluster-wide tree id, because the access gate never consults one for a scopeless request. Inside a listed group the data group applies one finer, per-tool minimum: each of its mutating tools is listed only when the caller's Allow grants include a mutating data-plane operation (Write, Delete, RangeDelete, CrdtApply, AtomicWrite, or BulkLoad), so a caller holding only Read / RangeRead is offered the data reads alone - and, because the session's tool list also serves invocation, cannot call a write it was not offered. The repository-context tools apply the same minimum to their mutating tools. Within a listed group the facade's access gate therefore remains the authority and refuses at call time a tree or a verb the caller's grants do not cover. The lattice_capabilities meta-tool reports the same group-level view.
Tools contributed by installable apps are filtered by app role, not by facade group: each is listed only to a caller that holds the tool's declared role by binding - a member of a membership group the install binds to that role - and the access gate can then only take the role away, never confer it. The tool is withheld when, on each of the role's scopes, the gate refuses at least one of the role's operations, so an explicit deny on a bound member wins and the caller's other rights never list an app tool. The check is repeated at invocation, so disabling the app, re-binding the role or a new deny takes effect mid-session. They pass through the same coarse authorizer and never appear in lattice_capabilities.
Discovery reads the caller's grants through the auth facade (ILatticeAuthAdmin, registered on the silo by AddLatticeAuthApi()) as a trusted system-origin read; with no auth facade registered it grants no group, so an authenticated caller is offered no facade-group tool - only lattice_capabilities, plus any installable-app tools whose role it holds, which are gated by role binding rather than by that facade. A remote head reaches that facade over its Auth endpoint instead - see Remote hosting.
Rejected calls are sanitised
A call the MCP host classifies as the caller's mistake (see Tools) is answered with an error result and counted on orleans.lattice.api.mcp.tool.client_errors rather than thrown. Its message is sanitised once, before it is returned or logged: every control character and Unicode line or paragraph separator is replaced with ?, and a message longer than 2,048 characters is cut there and ends in ..., so a caller-chosen key, path or argument name cannot forge a record in a line-oriented log. The names of undeclared arguments are narrowed further: at most five are named, each cut to 64 characters, with any character other than an ASCII letter or digit, _, - or . replaced. A fault the host does not classify - an authorization denial included - is still thrown, never downgraded to a client error.
Least privilege by default
The server ships no tools. Each module is added explicitly, and within a module the destructive verbs (data writes, backup control, auth administration, replication control, schema management, tree lifecycle and control, and tenant lifecycle and residency) stay hidden unless the host enables them. A minimal deployment exposes only the read tools it needs; a control deployment opts each destructive verb in deliberately.
OAuth discovery is anonymous by design
When a host opts into OAuth 2.0 Protected Resource Metadata (RFC 9728) by setting ProtectedResourceMetadata (see Setup), the metadata document at /.well-known/oauth-protected-resource is mapped with AllowAnonymous(), so it is served even when RequireAuthorization is true and the host installs a fail-closed fallback authorization policy. This does not weaken the posture: the document carries only public information a client needs to begin signing in - the resource identifier, the authorization server URLs, and the scopes to request - and never a token, a grant, or any cluster state. It must be anonymous because a client fetches it precisely because it was rejected with a 401. The MCP transport itself stays default-denied; the only change to a 401 is an added resource_metadata hint pointing at that public document.
Next
- Tools - the modules, their opt-in flags, and the full catalogue.
- Remote hosting - how the credential flows to a cluster over gRPC.