Security
This page documents Orleans.Lattice.Api.State 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 state API is a read-only surface, but read-only is not the same as public. Tree ids, key ranges, value previews, and live mutation feeds are sensitive, so the gRPC binding fails closed: every protected state read or observation call is authorized, and the default posture denies all protected traffic. The GetAuthScheme advertisement RPC is intentionally unauthenticated so a client can discover how to sign in.
The authorization seam
ILatticeStateApiAuthorizer is the single per-call authorization seam. The binding installs an interceptor that calls it on every unary and streaming RPC before the request reaches the service - except the unauthenticated GetAuthScheme advertisement RPC, which the interceptor exempts so a client can discover how to sign in before it holds a credential. The package ships two reference implementations:
DenyAllStateApiAuthorizer- rejects every protected call it sees. This is the default, registered withTryAddso it only applies when you have not registered your own.AllowAllStateApiAuthorizer- accepts every protected call it sees. Use it only behind an already-authenticated outer boundary (a service mesh, a gateway, or mutual-TLS termination that has already established trust).
AddLatticeStateApiGrpc registers DenyAllStateApiAuthorizer via TryAddSingleton, so a custom authorizer registered before or after it wins.
The seam describes mapped calls with a LatticeStateApiAuthorizationContext carrying the underlying ServerCallContext (Call), the LatticeStateApiOperation - one member per protected RPC, from ListTrees to ListDeadLetters - and the TargetTreeId the call acts on, so a policy can scope by operation and by tree. TargetTreeId is the request's TreeId for the per-tree reads, CancelScan, the change feed, and the dead-letter RPCs; a catalog request's SourceTreeId; the one tree a metrics request names when it names exactly one; and null otherwise (GetClusterInfo, ScanTagMembers, and a multi-tree or unscoped metrics request). A method the interceptor does not map uses LatticeStateApiOperation.Unknown rather than a benign default, so a deny-by-default policy refuses anything unmapped instead of letting it pass as a catalog read.
The three index-wide tag-browsing operations (ListCoveredTrees, ListIndexTags, ScanTagMembers) span every tree a tag index covers, so they carry no meaningful target tree: ScanTagMembers always presents a null TargetTreeId, while ListCoveredTrees and ListIndexTags present only the request's SourceTreeId, which both of them ignore - null in normal use, but caller-controlled. They authorize at the cluster / index level like ListTrees and ListViews, not per subject tree. A policy that grants tag-index browsing therefore grants it across all covered trees; scope it by operation (deny these three) rather than by target tree if a tenant must not see cross-tree membership. The subject-tree-scoped ListTagValues (which does carry a TargetTreeId) remains available for per-tree tag enumeration.
Turnkey credential authorizer
AddEnvVarCredentialAuthorizer registers a reference EnvVarCredentialAuthorizer that validates an inbound authorization: Basic base64(user:pass) header against environment-variable-backed PBKDF2-SHA256 password hashes, with a per-username failed-attempt lockout. It replaces any ILatticeStateApiAuthorizer registered before it, the default-deny one included. Because it reads a Basic credential off the wire, it must run behind TLS - terminate TLS at the channel (or an outer boundary) so the credential is never sent in clear text. It is an authentication front door, not a per-tree authorization policy: it does not consult the call's operation or target tree.
Each username / password pair configured this way is a State-API credential user: a deployment-time login that gates the State API endpoint as a whole (generated with tools/New-LatticeStateCredential.ps1, run under PowerShell 7.2+ (pwsh) on any platform, which hashes the password and prints the LATTICE_STATE_USER_<name> environment-variable assignment for the host to set). It is distinct from a membership subject or an identity-directory principal - it does not carry groups or per-tree authorization, and it is not the same "user" the Orleans.Lattice.Membership directory or an ILatticeIdentityDirectory provider deals in. Use it to decide who may reach the endpoint; use the auth / membership stack to decide what a subject may do once inside.
The turnkey authorizer's hash format is public: LatticePasswordHash encodes, parses, and verifies (in constant time) the pbkdf2-sha256$<iterations>$<base64-salt>$<base64-derived-key> hashes the credential script prints (TryParse yields a LatticePasswordHashComponents carrying the iteration count, salt, and derived key) - LatticePasswordHash.DefaultIterations (750,000) iterations by default, the same default the script uses - and EnvVarCredentialAuthorizer reads the stored hashes through the IEnvironmentVariableReader seam, whose default ProcessEnvironmentVariableReader reads the current process environment.
Identity and advertisement seams
Two further public seams are TryAdd-registered by AddLatticeStateApiGrpc, so a host replaces either by registering its own implementation first:
ILatticeStateApiCredentialBridgelifts the caller identity off each inbound call onto the ambient Lattice credential that drives the per-tree / per-key visibility filtering. The default readsCredentialHeaderNameand strips a case-insensitiveCredentialSchemeprefix; supply your own for, say, a client-certificate or signed-edge-header identity. It runs after, and independently of,ILatticeStateApiAuthorizer, and anullresult leaves the caller anonymous, which auth-backed visibility denies.ILatticeStateApiAuthSchemeSourcesupplies what the unauthenticatedGetAuthSchemeRPC returns. The default is backed byLatticeStateApiGrpcOptions.AdvertisedAuthSchemes; because the advertisement is served without a credential, an implementation must return public configuration only.
Visibility boundaries
Silo-internal system trees (the reserved _lattice_* prefix) are hidden from every public surface. The read facade refuses them (GetEntry, ScanEntries, GetTreeStructure, GetEntryHistory) and the change feed (ObserveChanges) refuses a subscription to one, so internal WAL keys, change kinds, and HLC timestamps never leak through the API. Materialised-view (view-*) trees stay readable and observable, mirroring the read paths.
CatalogRequest.IncludeSystemTrees is an operator-convenience filter, not a security boundary: it only adds the trees a catalog hides by default to the listing for diagnostics (on ListTrees the internal _lattice_ trees, the materialised-view backing trees and the sys- system data trees; on ListViews the sys- system views). It neither unlocks nor locks reading or observing their contents: the read and observation paths ignore the flag, rejecting the internal _lattice_ trees outright while materialised-view and sys- system data trees stay readable and observable under the caller's ordinary per-tree read access, listed or not (a sys- name is refused outright under an asserted tenant). It must not be relied on to gate access.
Default-deny posture
With AddLatticeStateApiGrpc and nothing else, LatticeStateApiGrpcOptions.RequireAuthorization is at its default and the default-deny authorizer is in place, so the endpoint rejects all traffic. You open it one of two ways:
Register a real authorizer that validates the caller (a token, a client certificate, a claim):
var builder = WebApplication.CreateBuilder();
builder.Services.AddLatticeStateApiGrpc(o => o.RequireAuthorization = true);
builder.Services.AddSingleton<ILatticeStateApiAuthorizer, AllowAllStateApiAuthorizer>();
Or, when an outer boundary already guards the endpoint, turn enforcement off explicitly:
var builder = WebApplication.CreateBuilder();
builder.Services.AddLatticeStateApiGrpc(o => o.RequireAuthorization = false);
The key property is that neither happens by accident. An operator who forgets to configure authorization gets a closed door, not an open one.
Transport
The client carries no transport policy: TLS, deadlines, retries, and call credentials all live on the GrpcChannel / CallInvoker the caller supplies (see Client). In production, terminate TLS at the channel and authenticate the caller through the authorizer.
For local development and the StateExplorer sample, the surface runs over HTTP/2 without TLS (h2c) to stay dependency-free - acceptable on a loopback address, not in production.