---
title: "Security"
url: "https://nsta1.github.io/Orleans.Lattice/docs/lattice.api.state/security.html"
source: "https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/docs/lattice.api.state/security.md"
package: "Orleans.Lattice.Api.State"
version: "9.9.0"
documents: "Orleans.Lattice 9.9.0 (release line 9.9)"
built: "2026-10-04"
all-pages: "https://nsta1.github.io/Orleans.Lattice/llms.txt"
bundle: "https://nsta1.github.io/Orleans.Lattice/docs/lattice.api.state/llms-full.txt"
---
# Security

Part of the [Api.State documentation](README.md).

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 with `TryAdd` so 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:

- `ILatticeStateApiCredentialBridge` lifts the caller identity off each inbound call onto the ambient Lattice credential that drives the per-tree / per-key visibility filtering. The default reads `CredentialHeaderName` and strips a case-insensitive `CredentialScheme` prefix; supply your own for, say, a client-certificate or signed-edge-header identity. It runs after, and independently of, `ILatticeStateApiAuthorizer`, and a `null` result leaves the caller anonymous, which auth-backed visibility denies.
- `ILatticeStateApiAuthSchemeSource` supplies what the unauthenticated `GetAuthScheme` RPC returns. The default is backed by `LatticeStateApiGrpcOptions.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):

```csharp verify
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:

```csharp verify
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](client.md)). In production, terminate TLS at the channel and authenticate the caller through the authorizer.

For local development and the [`StateExplorer`](../../samples/StateExplorer/README.md) sample, the surface runs over HTTP/2 without TLS (h2c) to stay dependency-free - acceptable on a loopback address, not in production.

## Next

- [Client](client.md) - configuring the channel and credentials.
- [Setup](setup.md) - where authorization registration sits in the wiring.
