Table of Contents

Release 2026-08-29: Security

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 2026-08-29-3.md, and llms.txt lists every page.

Part of Release 2026-08-29, in Changelog.

Security

  • Per-tenant quota and rate admission now runs strictly after access-gate authorization, closing a cross-tenant existence oracle, usage disclosure, and rate-budget denial-of-service. Every user-origin write verb on the ILattice facade grain consulted the tenant admission controller before its Enforce*Async authorization call, and the admission controller reads the active tenant straight from the ambient LatticeActiveTenantContext. That value is a caller-asserted, client-supplied assertion by design: ActiveTenantRequestContextKey is deliberately absent from LatticeCapabilityStrippingCallFilter.ReservedCapabilityKeys, precisely because a client is expected to supply it, which is why every consumer is obliged to re-validate it. The access gate is the component that performs that validation - TenantGateEnforcer resolves the target tree's owning tenant and refuses a subject asserting a tenant it holds no membership of - so running admission first meant the controller was handed an unverified tenant name and acted on it. The consequences were all cross-tenant. LatticeTenantAdmissionController.IsAdmittedAsync calls TryAcquire, which mutates the named tenant's GCRA token bucket (advancing its theoretical arrival time and its granted counter), so an unauthorized caller could drain any tenant's rate budget by repeatedly writing to a tree it had no grant on - a pure denial-of-service against a victim tenant. Its tenant-view lookup answered whether the named tenant exists at all, an enumeration oracle over the tenant roster. And TenantQuotaEvaluator throws a quota breach whose message carries the victim's current usage and ceiling, which LatticeDataApiGrpcService.ToResourceExhausted returns verbatim to the caller alongside the quota trailers - an irony worth recording, since the surrounding code deliberately withholds the tenant id from those trailers while the exception message disclosed the id, the usage, and the cap together. This one was externally reachable, not in-cluster only: HeaderLatticeDataApiActiveTenantBridge lifts the lattice-active-tenant header and stamps it ambient after only a syntactic TenantId.TryParse, and its own documentation states that it "performs no authorization" - so an authenticated caller holding no grant on any tree could name a victim tenant over the wire and reach all three effects. The fix is an ordering one and is deliberately minimal: the single ThrowIfWriteNotAdmittedAsync call at each of the fifteen write verbs (including the bulk-load and chunked-append paths) now sits immediately after that verb's authorization call, so by the time admission reads the ambient tenant the gate has already validated the caller's membership of it. The two hand-managed ValueTask fast paths (SetAsync and DeleteAsync) were restructured rather than merely reordered, because their slow-path continuations re-invoked enforcement internally; the post-gate tail is now shared, so the ordering is expressed exactly once per verb and the synchronous zero-allocation path is preserved. This restores the same authorize-then-account discipline the backup path already follows, where LatticeBackupRestoreService authorizes before opening the tenant restore scope. Deriving the tenant from the target tree instead would not have fixed this: the attacker also chooses the tree, so writing to t/{victim}/{tree} would still have charged the victim before authorization - the ordering is the load-bearing part. A cluster with tenancy off is byte-for-byte unchanged: the default NullTenantAdmissionController reports itself inactive, so admission short-circuits to a completed ValueTask before reading the active tenant, and a host with no authorization add-on short-circuits on the null gate before resolving a subject. Covered by a new per-verb ordering suite that asserts, for every user-origin write verb, that a gate denial surfaces as LatticeAuthorizationDeniedException and that the admission controller is never consulted at all - the observable proxy for "no victim tenant state was read or mutated", and the assertion the previous ordering failed - plus companion cases proving an authorized write still consults the quota seam exactly once and an authorized-but-unadmitted write is still refused by the tenancy layer. (Orleans.Lattice 9.4.0)
  • lattice_list_regions is now gated and re-authorized on every invocation, so peer-region topology is no longer readable by a caller holding no grants. The MCP session configurator advertised the region-discovery meta-tool unconditionally, alongside lattice_capabilities, and the invoke-time McpToolAuthorizationGate was applied only to the per-group facade tools, so neither enforcement point covered it. Any authenticated caller - including one whose effective access set was empty, and one a registered IMcpToolAuthorizer denied outright - could therefore read every reachable peer region id, the connected cluster's identity, and the per-group gRPC endpoint each region is served from: precisely the routing map needed to aim a follow-on attempt at a peer region, and precisely the disclosure the tool's own contract limits to what is "scoped to the caller's effective permissions". The tool is now advertised only to a caller that holds at least one facade grant and is permitted by the authorizer, and the advertised instance is wrapped in a new internal AuthorizedMetaTool that re-runs the same gate on each invocation, so advertisement and invocation stay in lock-step exactly as they do for a group tool - a tool that disappears from a listing but still answers when called by name is not gated. lattice_capabilities remains the single deliberately ungated advertisement, as the security instructions require, and the session instructions no longer mention the region tool when it was not advertised. Internal types only, so no public API changes. Covered by new configurator regressions and an end-to-end assertion that a default-deny authorizer both hides lattice_list_regions from the tool listing and faults a direct call to it. (Orleans.Lattice.Api.Mcp 9.4.0)
  • The file WAL storage provider can no longer be steered outside its configured root by a dot-segment tree id. FileWalStorageProvider composes a shard directory as RootDirectory / {treeId} / {shardId} after percent-encoding each segment, but the encoder treated . as an unreserved character and guarded only the empty segment, so a tree id of .. encoded to itself and Path.Combine - which concatenates without normalising - yielded a path resolving to the root's parent. A tree id is caller-influenced on every surface that accepts a tree name, so a caller could create and grow a wal.log outside the directory an operator deliberately scoped the provider to, and overwrite an unrelated file the silo process is able to write. The segment's trailing dot run is now percent-encoded, which closes two defects at once. An all-dot segment (., .., ...) can no longer name a relative path token, so it only ever resolves to a literal child directory. And because Windows silently strips a trailing dot from a path component, the ids a and a. previously resolved to the same directory - two distinct trees sharing one WAL, the second overwriting the first's log with no error - while a longer run such as a.. could fail directory creation outright; escaping the trailing run means an encoded segment can never end in a dot, so the encoder's injectivity now holds at the filesystem and not merely in the string. Ordinary ids are untouched and stay on the existing allocation-free path, including ids that merely contain or begin with dots (a.b, ..a), and the encoding stays injective because % is itself always escaped, so a literal %2E in a tree id encodes to %252E. Covered by new encoder regressions (including an invariant test that no tree id encodes to a segment ending in a dot, and an injectivity sweep across the escape), an end-to-end test that appends under a .. tree id and asserts nothing was written outside RootDirectory, and an end-to-end test that two ids differing only by a trailing dot own distinct WAL files. (Orleans.Lattice.Storage.File)
  • A tree-administration verb taking two caller-supplied tree ids now applies the reserved-namespace guard to both. SetTreeAliasAsync and SnapshotTreeAsync validated only their first tree id against the reserved-namespace guard. Guarding only the first would let a caller alias its own tree onto a reserved internal tree and read straight through it, or drain a tree into a reserved destination - a crossing dressed up as an alias or a snapshot. Both ids are now guarded. The change is visible with or without the tenancy add-on: aliasing onto a _lattice_ tree, or naming a t/-prefixed destination, is now refused where it previously was not. (#1689) (Orleans.Lattice 9.4.0, Orleans.Lattice.Api.TreeAdmin 9.4.0)
  • LatticeGrain.GetAllTreeIdsAsync now authorizes before it enumerates the cluster catalog. The verb performed no access-gate call whatsoever - unlike its immediate siblings SnapshotAsync, SetPublishEventsEnabledAsync, and SetHistoryRetentionAsync in the same file - so an in-cluster client could enumerate every registered tree id in the cluster with nothing between it and the registry. It now enforces a whole-tree Read first. (#1678) (Orleans.Lattice 9.4.0)
  • Four read verbs on the ILattice facade grain now call the access gate, closing a tree-metadata and existence disclosure that also bypassed tenant isolation. DiagnoseAsync, GetStorageUsageAsync, TreeExistsAsync, and GetHistoryRetentionAsync performed no gate call at all - the same defect class as the GetAllTreeIdsAsync gap fixed earlier in this release window, in the same two files, and almost certainly missed by that sweep since TreeExistsAsync sits directly above it. Any caller able to address the grain could therefore name an arbitrary tree and read its live-key count, tombstone count, physical and virtual shard counts, per-shard key distribution, recent split activity, and its leaf-state / snapshot / retained-WAL / total byte footprint, and could probe whether any tree id is registered. This was reachable with the tenancy add-on off, under DefaultEffect = Deny with zero grants and an anonymous subject, because a facade verb that never consults the gate is not narrowed by policy at all. With tenancy on it was additionally a cross-tenant isolation bypass: tenant isolation is composed inside the gate (PolicyAccessGate consults ITenantGateEnforcer only on the gate path), so a verb that never reaches the gate never reaches tenant ownership, active-tenant validation, cross-tenant grants, or residency either - and because a tenant tree id is the caller-composable string t/{tenant}/{name}, TreeExistsAsync enumerated the tenant roster and each tenant's tree names by dictionary probing. The external facades were never the exposure path (LatticeTreeAdmin authorizes before dialing, and LatticeStateQuery wraps the existence probe in its own visibility check - that the state API had to compensate this way was itself the evidence the grain did not hide existence on its own); the reachable path was a plain in-cluster Orleans client calling the facade grain directly. The three metadata verbs now enforce a whole-tree Read exactly as their siblings in the same files do, which also refuses a partial (prefix) allow rather than narrowing it, on the established grounds that a per-shard count or byte aggregate cannot be pruned per key without still disclosing the keys it counted; this matches what TreeAdminAccessAuthorizer.AuthorizeTreeReadAsync already required of the same reports, so the external tree-admin surface is unchanged. TreeExistsAsync deliberately differs on both axes: a denial answers false rather than throwing, so the documented guarantee that a caller cannot distinguish "exists but I cannot read it" from "does not exist" is now enforced on the grain itself instead of only in the layer above, and a partial allow still reports existence, since a caller who may read part of a tree keeps no secret by learning it is there and every consumer already prunes per key. No system-origin bypass was needed and no internal consumer changed: the admission write-guard, LatticeAdminGrain, the storage-usage poller, and tenant metering all dial ILatticeStorageUsage directly rather than through the facade, and the usage and stats grains call back only into GetRoutingAsync, which stays ungated by design, so there is no re-entrancy path through the new gate. A host with no authorization add-on is byte-for-byte unchanged: enforcement short-circuits on the null gate before the caller subject is ever resolved. Covered by a new metadata access-gate regression suite that pins, per verb, that the gate is consulted at all and with the correct whole-tree read shape (the assertion the original gap would have failed), that a denial is honoured, that a system-origin turn still bypasses, that an authorized caller is still served, and that a grant on one tree discloses nothing about another - plus a default-host zero-cost regression asserting the four verbs answer unchanged for an anonymous caller with no gate registered. (#1721) (Orleans.Lattice 9.4.0)
  • The TreeAdmin cluster-telemetry authorizer is now fenced on the control plane for plane-isolation consistency. TreeAdminAccessAuthorizer.AuthorizeClusterTelemetryAsync evaluated the cluster-wide storage-accounting (telemetry) view over the data-plane sentinel scope "*", which PolicyAccessGate takes on the ordinary data-plane path; under the opt-in LatticeAuthOptions.DefaultEffect = Allow posture that elevated all-tree observability scope inherited the data-plane default, unlike every sibling elevated scope, which is fenced on the control plane. The authorizer now routes the request through the reserved sys-auth-policy tree, which the gate evaluates with control-plane isolation and denies when unmatched regardless of DefaultEffect; per-tree verbs and the deny-by-default paths are unchanged. Read-only, and only reachable under the non-default Allow posture. Covered by a new regression. (#1646) (Orleans.Lattice.Api.TreeAdmin 9.4.0)
  • The Explorer's selection state is no longer a process-wide singleton on the multi-user web head. IExplorerSelection - which holds the currently selected CatalogItem and publishes SelectionChanged to the navigation and detail panels - was registered with TryAddSingleton, while every other identity-bearing Explorer service (the connection, the credential store, the auth session, the catalog reader) is correctly scoped per Blazor circuit. On the standalone and co-hosted web heads, which authenticate each operator separately over a per-browser credential cookie, that single instance was shared by every signed-in circuit: one operator's selected item leaked its Id, SourceTreeId, ProjectionProviderKey, IndexName, and RestoreShadowOfTreeId straight into every other operator's detail panel with no authorization re-check - including trees the recipient's own catalog read had filtered out, since LatticeStateVisibilityFilter prunes the catalog but nothing re-gated an already-materialised selection handed between circuits - and any operator could silently re-target every other operator's detail panel by selecting an item. The registration is now TryAddScoped, so a selection is confined to the circuit that made it. The MAUI head is unaffected in behaviour (it has a single circuit). No public API change: IExplorerSelection and ExplorerSelection keep identical members and signatures; only the DI lifetime changes. Covered by new regressions asserting the registered lifetime and that a selection made in one scope is neither visible to, nor notified to, another. (Orleans.Lattice.Explorer 9.4.0)
  • The Entra Graph directory search term can no longer escape its quoted KQL clause. GraphEntraDirectoryClient.BuildSearch interpolates the caller-supplied term into a quoted Microsoft Graph $search KQL clause ("displayName:{term}" OR "mail:{term}") and sanitised it by stripping only the double-quote character. Stripping the quote alone is insufficient: a term ending in a backslash escapes the clause's own closing quote, so the clause stays open and the remainder of the interpolated template is parsed as caller-supplied KQL operators rather than as literal search text, letting a directory-search caller alter the filter shape of the query the application issues under its own app-only User.Read.All / Group.Read.All Graph permissions. Sanitisation now removes both the quote and the backslash, so the term is always literal under every KQL escaping dialect, and allocates only when the term actually contains one of those characters. Covered by new regressions on both the users and groups search paths. (Orleans.Lattice.Membership.Entra.Graph 9.4.0)
  • CreateViewAsync now enforces the reserved-namespace guard every sibling tree-admin verb applies. LatticeTreeAdmin.CreateViewAsync was the only mutating verb on the facade that omitted ThrowIfReserved, so it accepted a source tree inside the reserved _lattice_ system namespace that all twenty sibling call sites reject. The gap was reachable rather than theoretical: the view maintainer deliberately supports a system-tree source (ResolveSourcePhysicalAsync short-circuits the registry alias for one), and ViewSourceTreeValidator only rejects a view-over-view, so a caller holding tree-admin authority over the reserved scope could stand a materialised view over an internal tree - the tree registry, a write-ahead log, queue state - and have the maintainer continuously mirror its contents into an ordinary view- tree, where they are readable under that view's own (ordinary) read policy rather than the library's internal-namespace protection. The source tree id is now validated against the reserved prefix before authorization and before any provider code runs. Covered by new regressions proving the rejection fires even when authorization would have allowed the source. (Orleans.Lattice.Api.TreeAdmin 9.4.0)
  • MergeAsync now authorizes the tree it reads, not only the tree it writes. ILattice.MergeAsync(sourceTreeId) drains every entry of a caller-supplied source tree into the calling tree, but the access gate was consulted only for the destination (EnforceWholeTreeAsync is bound to the activation's own TreeId), and the drain reads the source through the shard and leaf grains, which sit below the gate seam. A caller holding Admin on any ordinary tree it owns could therefore name an arbitrary source and siphon it wholesale into a tree it controls, where the copied entries are readable under that tree's own read policy: the reserved sys- authorization and identity stores, another tenant's t/<tenant>/... trees, or simply any tenant-neutral tree the caller had no grant on. The coordinator's only namespace check covered the _lattice_ prefix, so sys- and t/ sources passed it. MergeAsync now enforces a uniform read over the source before the merge is scheduled - a plain deny and a partial-coverage (filtered) allow are both refused, because a merge copies the source in its entirety or not at all - and rejects a reserved (_lattice_, sys-, or tenant-scoped) source outright on the user-origin path, with the same guard mirrored defensively in the merge coordinator. This is the guard LatticeTreeAdmin.CreateViewAsync already applies to its caller-supplied source. System-origin turns and the default (ungated) host are unaffected and pay nothing. No public API change. Covered by new unit regressions on the reserved-source guard and new integration regressions proving a denied or partially-authorized source refuses the merge while a uniformly authorized one still succeeds. (Orleans.Lattice 9.4.0)
  • The replication batch apply path can no longer be made to apply a peer-chosen merge algebra. The batch applier segments an inbound batch into runs and evaluates the receiver-side enrollment and merge-mode gate once per run, on the run's representative first entry, but runs were keyed on (treeId, originClusterId) only while dispatch inside the run switched on each entry's own peer-supplied WalRecord.Mode. A peer could head a run with a single conforming entry and smuggle entries carrying an arbitrary CRDT algebra behind it, folding state into an enrolled tree under a merge mode the operator never configured - silently, with no dead-letter and no mode-mismatch metric - while the single-entry ApplyAsync path re-classified every entry and would have rejected them. The merge mode is now part of the run key, so a mode change starts a new run that is classified on its own merits and dead-lettered when it disagrees with the locally resolved mode; the conforming prefix still applies, WAL order is preserved on both the sequential and per-tree parallel plans, and a legitimate (mode-homogeneous) batch segments exactly as before at O(1) extra cost per entry. Covered by new regressions on the smuggled-tail and conforming-prefix cases. (Orleans.Lattice.Replication 9.4.0)
  • Six in-cluster facade verbs on the core ILattice grain now consult the access gate before answering, closing an unauthorized read of tree state and a value disclosure that the gate was never asked about. All six are reachable only from inside the cluster - no external facade, gRPC binding, MCP tool, or Explorer route exposes them - so this is internal hardening with no externally reachable exposure, and a deployment that registers no ILatticeAccessGate is unaffected. Three distinct gaps are closed. First, GetOrSetAsync enforced only LatticeOperation.Write, but its read-hit path returns the pre-existing stored bytes when the key is already present, so a subject holding write-without-read could read any value it could name by writing a throwaway one. It now additionally enforces LatticeOperation.Read on the key. That Read check is deliberately unconditional and up front rather than applied lazily on the read-hit path: a lazy check would succeed for an absent key and deny for a present one, which is itself a key-existence oracle, so enforcing before the lookup makes the denial independent of stored content. It is also a separate enforcement call rather than a combined Write | Read flag, because the compiled policy engine matches a rule's operations with all-bits semantics ((ruleOperations & requested) == requested), so a single combined request would demand one rule carrying both bits and would wrongly deny a subject that legitimately holds Write and Read through two separate rules. Second, GetMaterialiserLagAsync reached the projection machinery with no gate call at all while both of its siblings in the same partial (RebuildLeafProjectionAsync and CompactShardAsync) enforce Admin; it now enforces a whole-tree Read, which refuses a partial-coverage allow rather than silently narrowing. Third, the four bulk-maintenance lifecycle status verbs - IsMergeCompleteAsync, IsSnapshotCompleteAsync, IsResizeCompleteAsync, and IsReshardCompleteAsync - answered ungated even though every verb that initiates the corresponding operation enforces Admin or TreeLifecycle, leaking whether a named tree exists and whether maintenance is in flight on it; all four now enforce a whole-tree Read. Gating those four verbs would otherwise have broken an availability feature, so the in-silo callers that poll them without a caller identity are explicitly scoped: HotShardMonitorGrain's timer-driven sampling pass now polls them inside a LatticeAccessGateContext.EnterSystemOrigin scope, because on a deny-by-default tree the gate would have failed closed and OnTimerTickAsync's catch-and-warn handler would have swallowed the denial as a routine warning, silently disabling auto-split. That scope is safe to assert because the system-origin marker is a reserved capability key that LatticeCapabilityStrippingCallFilter strips from any genuine external client, so it cannot be forged inbound. Every fix is pinned by a regression test that fails without it. (#1733) (Orleans.Lattice 9.4.1)
  • The tree-admin status projections no longer risk denying a caller after the mutation they authorized has already been applied. Gating the four ILattice lifecycle status verbs (see the entry above) exposed a latent ordering hazard in LatticeTreeAdmin: its three private projection helpers - ReadReshardStatusAsync, ReadSnapshotStatusAsync, and ReadResizeStatusAsync - are reached from eight call sites, and the trigger paths among them authorize TreeLifecycle or TreeAdmin, not Read. Because LatticeOperation grants are independent per bit, a subject holding TreeLifecycle without Read would have successfully triggered the operation and then been denied while projecting the resulting status, throwing after the mutation had landed. The facade has already made its authorization decision at its own boundary before reaching these helpers, so each helper now drives the underlying ILattice status read inside a LatticeAccessGateContext.EnterSystemOrigin scope - the narrowest seam that preserves the facade's existing, documented authorization contract. The alternative of adding an up-front Read authorization to the trigger paths was rejected because it would change that contract for callers who legitimately hold only the lifecycle grant. No public API changes. (#1733) (Orleans.Lattice.Api.TreeAdmin 9.4.1)