Caller-credential propagation (LatticeCredentialContext)
This page is part of 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 caller-credential-propagation-latticecredentialcontext.md, and llms.txt lists every page.
Part of Lattice Public API Reference.
LatticeCredentialContext is a transport-only ambient seam that carries
an opaque caller credential from the client edge down to the silo on the
Orleans RequestContext, following the same marker idiom as
LatticeOriginContext and LatticeIdempotencyContext. It is the channel
the (separately registered) Membership layer resolves into a subject. The
core library never interprets it - its resilient scan and range-delete
helpers only re-assert the caller's credential around each reconnect - so
an unset credential adds no cost and changes no read/write semantics.
Stamp a credential ergonomically at the boundary of a logical operation:
using (LatticeCredentialContext.Use("edge-token", scheme: "Bearer"))
{
await tree.SetAsync("k", new byte[] { 1 }, cancellationToken);
}
Use accepts the opaque token plus three optional hints an authenticator
can consult without re-parsing the token: a scheme / issuer hint, a
pre-resolved principalId, and a small metadata bag. With(LatticeCredential?)
takes the full payload - a LatticeCredential (Token, Scheme, PrincipalId,
Metadata) - directly. When no scope is entered, Current is
null and IsActive is false (a single dictionary lookup, no
allocation).
The marker carries a user credential only. Library-internal system /
maintenance / replication-origin calls are authored by infrastructure and
carry no credential: they never open a credential scope. When
infrastructure fans a system-origin sub-operation out from within a turn
that did carry a user credential, it wraps that sub-operation in
LatticeCredentialContext.Suppress() so the ambient credential is stripped
for the duration and cannot leak onto a system-authored call.
Access-gate operation flags
A host that registers an ILatticeAccessGate receives one LatticeOperation flag for the logical operation being authorized. Composite calls can carry the union of several flags. The core flags are:
| Flag | Value | Authorizes |
|---|---|---|
Read |
1 | Single-key reads. |
Write |
2 | Single-key writes. |
Delete |
4 | Single-key deletes. |
RangeRead |
8 | Contiguous range reads and scans. |
RangeDelete |
16 | Contiguous range deletes. |
CrdtApply |
32 | Applying a CRDT delta or merge to a key. |
AtomicWrite |
64 | Initiating a multi-key or cross-tree atomic write. Individual legs still require their own write/delete grant. |
BulkLoad |
128 | Bulk-load or snapshot-restore writes that populate a tree in bulk. |
Admin |
256 | Routine tree administration. On ILattice: SnapshotAsync, MergeAsync, the per-tree event and history-retention overrides, RebuildLeafProjectionAsync, CompactShardAsync, and RepairOrphanedLeavesAsync; on the tree-administration facade: tree creation, alias assignment, and per-tree configuration updates. Existence checks are Read. |
Backup |
512 | Capturing a tree, prefix, or key for backup. |
Restore |
1024 | Restoring a captured backup into a target tree, prefix, or key. |
SchemaAdmin |
2048 | Schema-management changes. |
Telemetry |
4096 | Cluster-wide telemetry reads. |
Replication |
8192 | Runtime replication-management operations. |
TreeLifecycle |
16384 | Destructive or structural whole-tree lifecycle operations: drop, recover, and purge (DeleteTreeAsync, RecoverTreeAsync, PurgeTreeAsync), resize and undo-resize, reshard, WAL-placement moves, or orphaned-leaf repair through the tree-administration facade. |
AppInstall |
32768 | Changing the cluster's installed app set: installing, upgrading, enabling, disabling, and uninstalling an installable app. Scopeless and cluster-wide, like Telemetry. |
SchemaAdmin, Telemetry, Replication, TreeLifecycle, and AppInstall are deliberately separate from Admin; granting one does not imply any other capability.
Reading an empty range read under a gate
A denied read does not throw. A denied point read reports the key as absent,
and a denied range read resolves to a reject-all key filter and returns a
clean, successful, empty result. So
KeysAsync, EntriesAsync, their predicate overloads, both CountAsync
overloads, and the snapshot cursors all report "you may not look here" and "there
is nothing here" identically - no exception, no log, every instrument healthy.
That is deliberate. A denied scan stays cheap and non-fatal, and making it throw would break every existing caller. The cost is that emptiness alone is uninterpretable under a gate, so a caller that draws a conclusion from an empty range read must confirm the range was actually readable:
static async Task<bool> RangeIsGenuinelyEmptyAsync(
ILattice tree, string startInclusive, string endExclusive, CancellationToken ct)
{
await foreach (var key in tree.KeysAsync(startInclusive, endExclusive, cancellationToken: ct))
{
return false; // Not empty at all.
}
// Empty. Ask whether that is a fact about the store or about authorization.
var coverage = await tree.GetRangeReadGateCoverageAsync(startInclusive, endExclusive, ct);
return coverage == LatticeRangeReadGateCoverage.Unrestricted;
}
GetRangeReadGateCoverageAsync reports Unrestricted, Filtered, or Denied.
Only Unrestricted licenses reading emptiness as absence: under Filtered an
unknown subset of keys is withheld, and under Denied every key is. It is a
coverage classification and never names the withheld keys, for the same reason
GatedMultiReadResult.PrunedByAccessGate is a count - identities would make any
range read an authorization oracle.
Call it only when a range read came back empty and you are about to act on that emptiness. The scan hot path pays nothing.
A background component is the classic victim, because its turn carries no caller credential at all: under a fail-closed gate every one of its scans returns empty, so it concludes the store is empty and does nothing, forever, with a healthy log at every layer. If a component reads on a background turn, give it a credential (see the trusted system-origin scope below) rather than relying on this check.
Trusted system-origin scope
A co-hosted infrastructure extension that must run a trusted, gate-bypassing
sub-operation on a caller's behalf - for example the Orleans.Lattice.Api.Mcp
server resolving a caller's effective permissions before advertising tools -
enters that scope through the public LatticeSystemOrigin seam rather than
reaching into the library's internals:
using (LatticeSystemOrigin.Enter())
{
// Nested calls in this scope are treated as system-origin and admitted by
// the access gate without a user identity. LatticeSystemOrigin.IsActive
// reports whether such a scope is currently open.
await tree.SetAsync("k", new byte[] { 1 }, cancellationToken);
}
Entering the scope bypasses the access gate for its lifetime, so it is reserved for co-hosted code that already runs inside the silo's trust boundary. It is the public counterpart of the internal marker the first-party add-ons use, and it exists so an out-of-package extension need not take an internal-visibility grant into the core assembly to perform the bypass.