AccessContext Propagation β the identity baton
A user's identity (AccessContext) flows from the authentication boundary through every async hop, every message hand-off, every handler, and every downstream post. The framework makes this look like a single-threaded execution under one principal β even when work crosses thread pools, schedulers, hubs, and grain activations.
This document explains the model, the six propagation phases, the sanctioned exceptions, and the anti-patterns to avoid.
π¨ For the OWNER case β read Owner Injection. When a node's own hub acts with no live caller (a watcher tick, a deferred sync write, a cold-start activation), the node owner is the standing identity, injected everywhere and carried forward via
CircuitContext(notContext, which is wiped across Rx hops). Genuine infra (doc sync, cache hydration) runs as System; an empty context is rejected, never faked. That page covers the cold-start submit deadlock this prevents.
π¨π¨π¨ THE INVARIANT: AccessContext must ALWAYS be set β never null
Every message a hub posts carries an AccessContext. There is no null, and there is no fourth source β exactly three:
| Source | Who | How |
|---|---|---|
| User | User-facing hubs β the per-circuit portal hub, HTTP-request hubs, per-node hubs handling a user's request | The user's AccessContext is live on the AsyncLocal (AccessService.Context / CircuitContext) when the hub posts. This is the default posting identity. |
| System | Framework infrastructure β routing (the courier) and persistence (the store) | The hub is declared WithPostingIdentity(PostingIdentity.System); its own otherwise-unattributed posts are stamped system-security automatically (bypasses RLS β the courier/store is not user-gated). |
| Owner | Threads & activities | AccessContextScope.FromNode(node) β node.CreatedBy β the post runs under the thread/activity owner's credential. π¨ In a REACTIVE pipeline resolve the identity and use access.RunAs(owner, () => work), never Observable.Using(() => AccessContextScope.FromNode(β¦), β¦): FromNode/AsSystem are SwitchAccessContext/ImpersonateAsSystem under another name, and Using latches the identity on the subscribing thread (#1790). |
Posting identity is a per-hub CONFIGURATION decision, declared at hub startup
It must be unambiguous which identity a hub posts under β this is a config property, not a per-callsite concern. Set it once when the hub is built:
// DEFAULT β user-facing hub. Posts as the ambient user; UNHAPPY when no user context.
config // PostingIdentity.User (implicit)
// Framework infrastructure (routing, persistence). Posts as system-security.
config.WithPostingIdentity(PostingIdentity.System)
The three modes (PostingIdentity enum, consumed by UserServicePostPipeline):
User(default). The hub posts as the user β it wants the identity set on the AsyncLocalAccessService.Context. When that AsyncLocal is not set and the message is not exempt, the post is UNHAPPY: the PostPipeline logs an error (the CI tripwire on theMeshWeaver.AccessContextchannel) and fails the delivery immediately β no identity, no delivery. The awaitinghub.Observe(...)gets a cleanOnError; the post is never thrown out ofPost(Post is fire-and-forget from countless callsites β a synchronous throw would be unobserved or crash an unrelated path) and never silently left null (that masked the empty-agent-registry / prod EventCalendar bug).System. Routing and persistence. The hub's own otherwise-unattributed posts are stampedsystem-security. Never overwrites a user identity already on the delivery (a forwarded user delivery, or a response inheriting the request's identity viaResponseFor) β System is only the fallback for the hub's OWN posts.- Cannot post β fail the delivery. The
User-mode fallback above is this: a hub that can resolve no identity does not get to post a non-exempt application message.
Exempt traffic β the only messages that may carry a null context
Genuinely identity-free framework traffic is exempt from the never-null rule and is delivered (not failed) even with no context:
[SystemMessage]β heartbeats, hub-lifecycle, subscription management,SetCurrentRequest,Save/DeleteMeshNodeRequest.[CanBeIgnored]β fire-and-forget control (Shutdown / Dispose / HeartBeat) with no awaiting requester.DeliveryFailureβ the courier's own error channel (inherits the request's identity viaResponseForwhen there is one; failing it would turn a NACK into a NACK-of-a-NACK).
π¨ Cross-cutting: only hubs REGISTERED IN THE MESH may post
This invariant only holds if every posting hub is a real, registered participant in the mesh β exactly how the portal hub is keyed and registered (PortalApplication β hub.GetHostedHub(CreatePortalAddress(circuitId), β¦), one hub per circuit, addressable + routable). A hub that is not registered in the mesh is not allowed to post β except a hosted hub, which is registered with (and owned by) its parent via GetHostedHub. The rule:
- Posting hub β registered hub. If something needs to originate messages, give it a proper mesh address and register it (a top-level hub in the mesh catalog, or a hosted sub-hub under a registered parent). Don't post from an ad-hoc, unaddressable object.
- Declare its posting identity at registration. A registered user-facing hub defaults to
Userand must carry the user's context; a registered infrastructure hub isWithPostingIdentity(System). - The portal is the worked example. It is per-circuit, keyed on the stable circuit id, carries the circuit user (via
ICircuitContextAccessor.UserContextβ a per-hub PostPipeline step that stamps the circuit user when a post has no ambient context), and is reachable through the mesh's stream-routedportal/*address type. Mirror that shape for any new posting hub.
This is a cross-cutting concern: it touches hub construction (MessageHubConfiguration.WithPostingIdentity), the post pipeline (UserServicePostPipeline), the circuit/auth boundary (CircuitAccessHandler β CircuitContextAccessor), routing and persistence (declared System), and every hub-registration site. Reference: memory feedback_access_context_always_set; tests AccessContextNeverNullTest, PostPipelineAccessContextTest, SubscribeRequestIdentityRoutingTest.
Mental model β piecewise single-threaded flow
Think of the system as a chain of short, synchronous pieces of work. At any moment, exactly one piece is running on one thread under one principal. That principal is the identity baton.
The baton follows a predictable life cycle:
- Starts at the authentication boundary (Blazor circuit, Minimal API, or
ApiTokenAuthenticationHandler). - Is set on
AsyncLocalat the start of every piece viaAccessService.Context. - Is read from
AsyncLocalevery time the piece posts a message, stamped ontodelivery.AccessContext. - Travels with the delivery across the async boundary β thread pool, Rx scheduler, grain hop, network.
- Is read from
delivery.AccessContextby the next piece's dispatch code and restored ontoAsyncLocalbefore the handler body runs. - Is restored to its previous value when the piece finishes (wrapped in
try/finally).
Inside any single piece, application code can ignore identity entirely β it's already correct. The framework owns every hand-off.
The baton is never absent during a piece's execution. Application code can trust
AccessService.Contextto reflect the originating user β that's the whole point.
The identity baton travels through six phases; Phase 6 loops back to Phase 2 whenever the handler posts further messages or starts reactive chains.
Security guarantees
The baton model exists to enforce a small set of strong security promises. Each is enforced by a specific framework primitive and verified by a specific test class β break one and the corresponding test fails loudly.
| Guarantee | How it's enforced | Violation test |
|---|---|---|
| Identity is never lost across an async hop. Every write is attributed to the originating user, even across Rx scheduler hops, hub-to-hub routing, and grain reactivation. | delivery.AccessContext is part of the message payload. Framework write primitives wrap return observables with CarryAccessContext so AsyncLocal is restored on every emission. |
SubscribeRequestIdentityRoutingTest and ExecuteThreadMessageTest.SubmitMessage_PersistsMessageNodes_WithUserIdentity β every CreatedBy must be a user identity, never sync/, mesh/, node/, or activity/. |
No write is attributed to a hub address by accident. Hub addresses are never CreatedBy on stored data. |
AccessService.SetContext logs an Error with stack trace when a hub-shaped principal lands on AsyncLocal. PostPipeline reads AsyncLocal β the wrong principal can't be stamped on outgoing deliveries. |
The error log itself β CI parses for [Error] [MeshWeaver.AccessContext] SetContext: hub-shaped principal and fails the run if any appear outside the sanctioned-init phase. |
| Sanctioned non-user identities have minimum-necessary permissions. A read-only hydrator can't write; an onboarding creator can't read another user's data. | Each sanctioned identity has per-NodeType access rules granting ONLY its specific operations. No wildcard ("all sync/* get protocol perms") β every grant is exact. |
One test per sanctioned identity verifies that misuse fails with UnauthorizedAccessException and a meaningful message. |
Cross-user reads are gated at every cache hit. The MeshNodeStreamCache hydrates under a sanctioned read-only identity, but every GetStream(path) call re-validates the requesting user's Read permission. |
MeshNodeStreamCache.GetStream always wraps the upstream in an access gate keyed on (path, userId). The cache is the hydrator; the user is the reader. |
UserAccessTests.SecurePersistence_NodeInPrivateNamespace_HiddenWithoutGrant β caches loaded under a sanctioned identity must still return UnauthorizedAccessException to unauthorized callers. |
| Fail-closed under uncertainty. No silent fallback. | On a User-mode hub with no resolvable context and a non-exempt message, UserServicePostPipeline fails the delivery β return d.Failed(reason) β rather than delivering a null-context message that would fail closed deep in AccessControl. The "stamp hub-self as principal" fallback was removed on 2026-05-21. |
Watch the MeshWeaver.AccessContext channel for [Error] PostPipeline: AccessContext must never be null for an application post β no identity, no delivery. Every occurrence is either a missing wrap (fix it) or a framework-lifecycle message that should be exempt ([SystemMessage]). |
The whole model rests on failure modes being loud and tested, not silent. The error log, the violation tests, and the fail-closed default together make a privilege-escalation regression visible the moment it's introduced.
Phase-by-phase contract
Phase 1 β Entry: authentication middleware sets AsyncLocal
Authentication happens at the platform boundary. Exactly one place per platform sets the baton:
| Platform | Where AsyncLocal gets set |
|---|---|
| Blazor circuit | CircuitAccessHandler β CreateInboundActivityHandler wraps every inbound activity in using accessService.SwitchAccessContext(userContext) |
| Minimal API | UserContextMiddleware β wraps each request in a SetContext scope |
| API token | ApiTokenAuthenticationHandler after ValidateTokenRequest resolves the user |
| Background timer / cron | Explicit accessService.ImpersonateAsSystem() at the entry point β see Sanctioned exceptions |
| Test base | TestUsers.DevLogin(Mesh) calls SetCircuitContext(Admin) once at fixture init |
After this phase, the entire AsyncLocal-respecting execution path runs under the user.
Phase 2 β Posting: AsyncLocal β delivery.AccessContext
Any call to hub.Post(...), hub.Observe(...), or meshService.CreateNode/Update/Delete/CopyNode(...) reaches the PostPipeline (MessageHubConfiguration.UserServicePostPipeline). The decision order is:
- If
delivery.AccessContextis already set (e.g. viaPostOptions.WithAccessContext(...),ImpersonateAsHub, orImpersonateAsSystemat the post site) β use it; do not overwrite. - Else read
accessService.Context ?? accessService.CircuitContext; if non-null β stamp on the delivery. - Else, if the hub is
PostingIdentity.Systemand the message is not exempt β stamp the well-knownsystem-securityidentity (the routing/persistence fallback). - Else, if the message is not exempt β log an Error on the
MeshWeaver.AccessContextchannel and FAIL the delivery (d.Failed(reason)), short-circuiting the rest of the pipeline.NotifyAsyncseesState == FailedandReportFailureposts aDeliveryFailureback, so an awaitinghub.Observe(...)gets a cleanOnError. It does not throw (Post is fire-and-forget from countless call sites) and does not deliver a null-context message. - Exempt messages (
[SystemMessage],[CanBeIgnored],DeliveryFailure) keep their null context and are delivered normally β they carry no security-relevant payload.
The baton is now on the delivery, ready for hand-off.
The
PostingIdentityused here is read fromsyncPipeline.Hub.Configuration, the finalwith-copied configuration β not from the configuration instance the pipeline delegate was captured against, which predates anyWithPostingIdentity(...)call.
Phase 3 β Crossing the boundary: delivery carries the baton
delivery.AccessContext is part of the message payload. It survives:
- The action-block scheduler (
Dataflow.ActionBlockβ ThreadPool dispatch) - Hub-to-hub routing (in-process via
MessageService.RouteMessageAsync, cross-grain viaMessageHubGrain.DeliverMessage, or remote via JSON serialisation) - Rx schedulers (framework primitives capture into a closure and re-set on emission β see Phase 6)
- Thread-pool re-scheduling generally
If you find a place where identity is lost during Phase 3, it's a framework bug β patch the carrier, not the application.
Phase 4 β Receiver: delivery.AccessContext β AsyncLocal (beginning the next piece)
Before the handler body runs, two cooperating pieces of code set AsyncLocal from delivery.AccessContext:
MessageService.NotifyAsyncβMessageHubConfiguration.UserServiceDeliveryPipelinesets AsyncLocal for the delivery pipeline body.MessageHub.HandleMessageAsyncsets AsyncLocal for the rule-chain / handler dispatch body, andMessageHub.RestoreUserContextOnEmissionre-stampsdelivery.AccessContexton every emission of the response observable.
Both wrap their inner work in try/finally so the previous value is restored when the piece finishes β the action-block thread that dispatched message N is correctly re-entrant for message N+1.
After this phase, application code runs with AccessService.Context = delivery.AccessContext.
Restoring the caller on the receiving hub
There is a third restore site, and it exists because it runs before the two above.
AccessControlPipeline β the [RequiresPermission] gate β is added with AddDeliveryPipeline,
and the pipeline list composes outside-in (Aggregate), so the gate is the outermost wrapper
and UserServiceDeliveryPipeline the innermost. At the moment the gate evaluates, Phase 4 has not
happened yet: AccessService.Context still holds whatever the action-block thread was left with β
typically system-security from the hub's own bootstrap ImpersonateAsSystem, or nothing at all.
That matters because the permission fold snapshots the caller on the calling thread
(PermissionEvaluator.ResolveFoldServices captures accessService.Context ?? accessService.CircuitContext,
because AsyncLocal does not flow through the Rx schedulers cache.GetQuery uses), and it reads
exactly two flags off that snapshot:
| Flag | What it decides |
|---|---|
IsApiToken |
The API-token clamp β zeroes the permission set for a Bearer context this path's live policy does not admit (api: false). |
IsHub |
The hub-credential early return β a hub reading its own vertical chain. |
So the gate restores delivery.AccessContext itself, before subscribing the fold. Two rules:
- The cue is a REAL PRINCIPAL, never a payload shape. The restore is keyed on the delivery
carrying a context with a non-empty
ObjectIdthat is not hub-shaped. It is deliberately not keyed on anything the caller happens to have filled in. - Hub-shaped ids keep the mesh-wide treatment. A
sync/β¦,mesh/β¦,node/β¦,activity/β¦orportal/β¦id is not a user identity and is never installed as one β the same ruleAccessService.SetContext's leak tripwire,UserServiceDeliveryPipeline'sshouldStampandMeshNodeStreamCache's pass-through all encode.
π¨ Why this is written down: the gate's own input used to decide whether the gate ran (#2976). The condition was
delivery.AccessContext is { Roles: { Count: > 0 } }β restore only when the caller carries role claims. Its comment justified that in terms of claim-based role resolution, a mechanism that no longer exists (the 2026-08-05 paywall fix took claim roles out of node permissions; #2974 removed their last foothold).AccessContext.Rolesis read nowhere inPermissionEvaluatorβ so the condition outlived its reason, and it was never the right one.The consequence was silent and permissive. Most identity providers emit no role claims, so
ApiToken.Rolesis normally empty; such a token reached a per-node hub withRoles = [], the restore was skipped,capturedContextwas null,IsApiTokenwas never seen, and the clamp did not run β the Bearer delivery was evaluated as an interactive session. The exact-read path was never exposed (MeshNodeStreamCache.GetStreamRawcaptures the caller itself and wraps the evaluation inSwitchAccessContext), soapi: falseheld for a read that named the page and was skipped for every message-routed check against it.This is the runtime cousin of the CI rule in AGENTS.md β a gate never tests its own inputs β because "the check did not run" and "the check passed" were indistinguishable from outside: no exception, no log, a served read. When a condition guards whether a security decision is made, it must name the thing the decision needs (here: a principal to evaluate), never a field the decision does not read.
Pinned by
RoutedApiTokenClampTest, whose control arm is the same person, same node, same routed message without the Bearer flag β so the pin measures a capability decision rather than a blanket deny.
Phase 5 β Handler runs under user identity
The handler body executes synchronously (or as a single observable chain β no await; see AsynchronousCalls.md). Every read of AccessService.Context returns the originating user. Every check (securityService.HasPermission(...), RlsNodeValidator.Validate(...)) is evaluated under the right principal.
Phase 6 β Handler posts β back to Phase 2 (chain continues)
The handler typically posts further messages or starts reactive chains. Two cases arise:
Direct post within the synchronous body. hub.Post(...) reads AsyncLocal β stamps the new delivery. Identity baton carries forward.
Cold observable + Subscribe (the Rx case). A framework write primitive returns a cold IObservable<T> whose side effect runs on Subscribe β but Subscribe may emit the callback on a different thread (an Rx scheduler) where AsyncLocal is wiped. The framework solves this internally via CarryAccessContext:
// AccessContextCaptureExtensions.CarryAccessContext (cross-cutting wrap)
public static IObservable<T> CarryAccessContext<T>(
this IObservable<T> source, AccessService? access, bool restoreNullCapture = false)
{
if (access is null) return source;
// Capture Context ONLY β never `?? CircuitContext`. PostPipeline already reads
// `Context ?? CircuitContext` at post time, and synthesising CircuitContext here
// would leak the Blazor circuit identity into background Subscribe paths.
var captured = access.Context;
if (captured is null && !restoreNullCapture) return source;
// Each OnNext/OnError/OnCompleted runs inside a SwitchAccessContext SCOPE that is
// disposed as the callback returns β AsyncLocal is touched only for the callback.
return new CarryAccessContextObservable<T>(source, access, captured);
}
π¨ Never re-implement this as
source.Do(_ => access.SetContext(captured)). That was the earlier shape and it was reverted (2026-05-22 / re-fixed 2026-05-28):SetContextwithout a matching restore mutates AsyncLocal on whatever thread Subscribe ran on β often the caller's β and leaves the captured identity live for every subsequent operation on that logical execution context. That is the McpUpdate user1/user2 cross-contamination bug. The scope-per-callback shape above is the fix; a bareDo(...)re-introduces the leak.
restoreNullCapture is the second half of the contract. The write-result observables
(MeshNodeStreamHandle.Update/Overwrite) pass true: a null capture is restored as null, so
the framework identity ambient on the emission thread (the stream cache's read path runs
ImpersonateAsSystem) cannot leak into the caller's callback and turn a nested write into a
post-as-System escalation. Read/query pipelines default to false β a null capture passes through
unwrapped, preserving whatever identity is ambient at emission. Flipping that default to clamp is
blocked on migrating the ops/MCP call sites that currently depend on the leaked ambient identity.
This is applied by every mesh write primitive β MeshService.CreateNode/Update/Delete/CopyNode, MeshNodeStreamHandle.Update, IMeshNodeStreamCache.Update, and the content-file writes hub.ImportContent(path)β¦Post() / hub.SyncContentFiles(path)β¦Post() β so callers keep writing the natural shape:
meshService.CreateNode(node).Subscribe(_ => β¦); // identity preserved across the Subscribe seam
Eager capture is half the contract, and the half that is easy to forget. A primitive that only wraps its result with
CarryAccessContextstill reads the ambientAsyncLocalwhen itsObservable.Deferbody runs β i.e. on the subscribing thread. The two content-import builders shipped that way and were the last write primitives without an eager snapshot: an import that built its operations underImpersonateAsSystemand then drained them through aConcatpump had every node write land (those capture eagerly) and every content write failed closed for a nullAccessContext(MeshWeaver.Reinsurance#46 β 412 applied, 409 refused, one call site). Snapshot the context where the caller calls you; pin it on the post witho.WithAccessContext(captured); wrap the result withCarryAccessContext. All three, or the primitive is not identity-safe. Pinned byContentImportAccessContextTest.
Phase 2 then happens again from the Subscribe callback's thread, under the right identity. The chain continues.
Sanctioned exceptions β fine-grained, exact, controlled
Some components are the actor, not a proxy for a user: cache hydrators, redistributor hubs, framework bootstrapping. These are sanctioned by giving the component a named, dedicated identity β never an accidental hub-address-derived shape β paired with precise access rules that grant only what the component actually needs.
Three rules apply to every sanctioned identity:
- Named, dedicated identity β e.g.
cache/mesh-node-cache, notmesh/xxx. The address reflects the component's role, not the hub it happens to live on. Hub addresses (mesh/xxx,sync/xxx,node/xxx) are accidental β they describe placement, not purpose. - Fine-grained permissions β each identity is granted ONLY the specific operations it actually needs. Not "all protocol operations for
sync/*"; rather "cache-read forcache/mesh-node-cacheon these specific paths". - Tested boundary β every sanctioned identity has a test verifying that misuse fails. Posting a write under a read-only identity must yield
UnauthorizedAccessException. Without this test, the sanctioning is voodoo.
Example 1 β cache/mesh-node-cache (read-only hydrator)
The MeshNodeStreamCache pre-loads MeshNodes from storage to serve cache hits for every user. It cannot run under a user identity because it services many. It is sanctioned via:
- Identity:
cache/mesh-node-cache(single, reserved, internal-only constant). - Grants: Read on the paths the cache hydrates. NOT Create, NOT Update, NOT Delete.
- Compensating control: every
GetStream(path)call re-validates the requesting user's Read permission before returning data β the cache is the hydrator, not the gate. - Test: post a
CreateNodeRequestundercache/mesh-node-cacheβ expectUnauthorizedAccessException("Access denied: Create permission required β¦").
If anyone else tries to impersonate cache/mesh-node-cache (the constant is internal to the cache assembly), they fail at AccessControl because the address grants only what the cache needs.
Example 2 β IsPortalIdentity (user-node onboarding)
User onboarding is the canonical example of a hub-as-actor seat configured correctly:
- Identity: any address matching
portal/*(the running portal hub's own address). - Grant:
UserNodeType.WithPortalCreateβAddAccessRule([Create, Update], (_, userId) => IsPortalIdentity(userId)). - Why it's fine: portal hubs are the natural actor for onboarding β the hub identity IS the role here. No narrower seat would add safety because exactly one component class uses this code path and that class IS what
portal/*matches.
Leave this pattern in place. The fine-grained refactor (dedicated component address, as in Example 1) applies when the hub address is accidental β the code happens to live on a mesh hub or sync hub but the role has nothing to do with message routing or sync streaming. When the hub IS the role, IsXxxIdentity(hub-prefix) is the right shape.
Example 3 β sync stream protocol vs sync stream user-data
This is the bug to be careful about. Sync streams carry two completely different kinds of traffic:
| Traffic type | Originator | Who should be on AccessContext |
|---|---|---|
SubscribeRequest, UnsubscribeRequest, HeartBeatEvent (protocol meta) |
The sync hub itself | A dedicated identity, e.g. protocol/sync-stream, granted only protocol operations |
SetCurrentRequest carrying a user's data binding update |
The user behind the data binding | The user β never sync/xxx, never protocol/sync-stream |
A SynchronizationStream.OnNext that unconditionally stamps ImpersonateAsHub(Hub.Address) collapses both onto the same hub address. The receiving owner then sees sync/xxx for a user's edit, and CreatedBy on the resulting MeshNode is sync/xxx. That's the user-identity-disappears bug.
Correct shape: protocol messages stamp protocol/sync-stream; user-data messages carry the user via CarryAccessContext (no impersonation at the post site). Two message types, two code paths, two identities β never collapsed.
Example 4 β system-security (true infrastructure)
accessService.ImpersonateAsSystem() switches the baton to "system-security", granted Permission.All unconditionally by PermissionEvaluator. Use only for genuine framework infrastructure with no user context AND no narrower sanctioned identity that fits β schema migration, framework-level bootstrap, internal recursive lookups inside PermissionEvaluator itself.
Every
ImpersonateAsSystemcallsite is a deliberate choice to bypass all access checks. Grep for them; review each. If a narrower identity would suffice, use that instead.
π¨ An impersonation scope must not ESCAPE the operation it was opened for
accessService.RunAsSystem(work) β not a hand-written Observable.Using(access.ImpersonateAsSystem, _ => work) β at every site, whether the scoped observable is returned to a caller or subscribed on the spot.
The hand-written idiom is correct for the work itself and leaks in two directions at once β forwards onto what the subscriber composes, and BACKWARDS onto the thread that subscribed:
// β the scope escapes: Rx forwards OnNext to the subscriber BEFORE Using disposes its resource,
// so the write below is CONSTRUCTED while the impersonation is still open β and the write
// primitives eager-capture AccessService.Context when they are CALLED.
Observable.Using(access.ImpersonateAsSystem, _ => ReadGatedSource())
.SelectMany(rows => meshService.CreateNodes(Plan(rows))); // lands as `system-security`
// β
the scope covers the read and stops at its boundary
access.RunAsSystem(ReadGatedSource)
.SelectMany(rows => meshService.CreateNodes(Plan(rows))); // lands as the CALLER
Because those primitives also re-stamp the captured identity around their own emissions (CarryAccessContext β "MessageHub sets, framework primitive preserves"), a leaked System identity is not merely inherited one hop β it is re-acquired at every hop after it. That is how a package install wrote its home root, five copy batches and its manifest as system-security while _UserActivity β the one write on that path posted directly from the request thread rather than composed on an emission β attributed to the real user (#1444).
It is an authorization concern, not only an audit one. AccessControlPipeline.HandleGetPermission carries the scar: SecurityService's bootstrap-time system scope leaked past its using-block onto the action-block thread, and trusting the ambient there returned Permission.All for every caller, anonymous included. That call site defends itself by resolving the identity explicitly; RunAsSystem fixes the class at the source so the next one does not have to know Rx's disposal order.
β¦and the other direction: the SUBSCRIBING thread keeps the identity (#1790)
Impersonation is an AsyncLocal store/restore pair, so both halves must land on ONE logical flow. Observable.Using runs its resource factory on the subscribing thread and disposes the resource when the inner observable terminates β for a cross-hub request/response, the owning hub's response thread. Nothing ever disposes it on the subscriber, so:
// β opens on THIS thread, closes on the hub's response thread
Observable.Using(access.ImpersonateAsSystem, _ => hub.Observe(request)).Subscribe(...);
// β¦everything after this line on this thread runs as system-security
Measured consequences, all silent:
- The auth bootstrap.
UserContextMiddleware.ValidateTokenViaHubsubscribes on the ASP.NET request thread. The latch left the request running withPermission.All, andInvokeAsync'sexisting.Email == userContext.Emailreuse branch then adopted the System context as the caller's own. - A script run.
ActivityLogLogger's first publish is issued from the script's own thread; the latch made the rest of the script System, and a--renderexport resolved embedded areas the submitting user may not read.
RunAsSystem / RunAsHub / RunAs open the scope at Subscribe and close it on the way out of that same Subscribe. That still covers the work: everything a cold pipeline does eagerly happens inside Subscribe β the factory runs, the primitives eager-capture the identity, the post is stamped, and any scheduled continuation captures the ExecutionContext as it stands right then. A captured ExecutionContext is an immutable snapshot, so restoring the subscribing flow afterwards cannot un-elevate work already scheduled.
π¨ ContainIdentity does not close this half β it restores around NOTIFICATIONS, never around the Subscribe that opened the scope. Where the capture is genuinely synchronous, a plain using around the call and its Subscribe is equally correct (ActivityLogLogger).
The second half of the leak β the caller's previous identity being written onto the terminating thread, which is an identity injection into a hub action block, not a cleanup β is closed inside AccessService itself: a scope's restore is thread-affine, so a dispose on a flow that never carried its store writes nothing. ImpersonationScopeSiteRatchetGuard + test/ImpersonationScopeSites.allow keep the inventory of sites still written the old way from growing.
| API | Use it for |
|---|---|
access.RunAsSystem(work) |
A system read/write, returned to a caller or subscribed on the spot. Enters the scope at Subscribe (so the cold work is covered), LEAVES it on the way out of that Subscribe, and delivers every notification under the subscriber's identity. |
access.RunAsHub(hub, work) |
The same seal for a hub identity. |
access.RunAs(identity, work) / access.RunAs(resolver, work) |
The same seal for an explicit identity the caller carries β an export rendering as the requesting user, a Blazor view re-establishing the durable circuit user. The resolver overload reads the identity on the subscribing thread. |
source.ContainIdentity(access) |
Sealing an already-composed chain β several system calls where the caller builds on the LAST one's emission. Forward direction only. |
π¨ It never invents an identity. What is restored is exactly what was ambient at Subscribe: a real user for a user-driven flow, system-security for a genuinely system-initiated one, and nothing at all when the subscriber had no identity β so a background worker that never had a user is not fail-closed by adopting it. The only behaviour that changes is the defect: a caller who had an identity, silently continuing as System.
Pinned by SystemScopeDoesNotEscapeTest (test/MeshWeaver.Messaging.Hub.Test), whose first case asserts the raw Rx ordering every other case rests on.
Implementation: define + grant + test
For each sanctioned identity, you need all three:
1. Define an internal const string for the identity's name inside the component's assembly:
// src/MeshWeaver.Hosting/MeshNodeCacheIdentity.cs
internal static class MeshNodeCacheIdentity
{
internal const string Address = "cache/mesh-node-cache";
}
Keeping it internal means only the assembly that defines it can stamp the identity β external code cannot impersonate.
2. Grant precise permissions via per-NodeType access rules:
config.AddAccessRule(
[NodeOperation.Read],
(_, userId) => userId == MeshNodeCacheIdentity.Address);
3. Test the boundary:
[Fact]
public async Task MeshNodeCache_Identity_CannotWrite()
{
using (accessService.SwitchAccessContext(new AccessContext { ObjectId = "cache/mesh-node-cache" }))
{
// π¨ Materialize, not act.Should().ThrowAsync() over an Rx ToTask bridge:
// ToTask is forbidden repo-wide (2026-08-30), and folding OnError into a
// value keeps the ORIGINAL exception type assertable. The write is cold, so
// the assertion's Subscribe IS the write.
var error = await meshService.CreateNode(someNode).Take(1).Materialize()
.Should().Match(n => n.Kind == NotificationKind.OnError);
error.Exception.Should().BeOfType<UnauthorizedAccessException>();
error.Exception!.Message.Should().Contain("Access denied");
}
}
The triple β define / grant / test β is the contract. Without all three, the sanctioning is voodoo and the system is one change away from privilege escalation.
Persistence runs as System β the persistence queue
Authorization is enforced once, at the user-facing write β the app handler / stream.Update on the owning hub's action block, where the user's identity is live and RLS gates the write. Once a change is accepted there it is already authorized. The durable DB write happens later, on the dedicated persistence hub (CreatePersistenceAddress()) inside DataSourceWithStorage.Synchronize, which fires on the workspace emission scheduler β a background thread that has wiped the AsyncLocal identity.
So the persistence write must run under System:
protected override void Synchronize(ChangeItem<EntityStore> item) =>
persistenceHub.InvokeAsync(async ct =>
{
using (accessService?.ImpersonateAsSystem()) // already authorized β durably store as System
await UpdateAsync(item, ct);
}, ex => { logger.LogWarning(ex, "Updating {DataSource} failed", Id); return Task.CompletedTask; });
If you instead let the persistence write inherit the (null) ambient context, it fails closed and the change is silently dropped β a write that "succeeded" upstream never lands. When the dropped write is an _Activity node, every progress reader then subscribes to a node that does not exist β a [ROUTE] NotFound resubscribe storm that wedges the partition (production outage 2026-06-17, compile + import activities). Persistence is the bottom of the stack: it stores what was already approved; it never re-gates and never fail-closes.
Posting/redirecting to a node: do it inside Create().Subscribe()
A SubscribeRequest β or any post/redirect β to a node that does not exist is the root of the resubscribe-storm/wedge class. Never advertise, subscribe, or redirect to a node path until you have proof it exists, and the only proof is the create's own completion:
// β
The node provably exists inside the Subscribe β only THEN advertise / redirect / let readers subscribe.
meshService.CreateNode(activityNode).Subscribe(
created => { /* now stamp the path, point readers at it, redirect, β¦ */ },
ex => logger.LogWarning(ex, "create failed β advertise NOTHING; no reader can storm a phantom"));
// β Stamp a `_Activity/compile-*` path BEFORE/without the create landing β readers subscribe
// to a phantom node β endless [ROUTE] NotFound β wedge.
RunCompile (NodeTypeCompilationHelpers) is the worked example: it provision-orders the create, observes it (bounded), and stamps LastCompilationActivityPath only on the confirmed create β null otherwise, so no reader ever subscribes to a path that was never written. Combined with "persistence runs as System" above (so the create actually lands), there is nothing to storm.
Anti-patterns (the baton drops)
| Anti-pattern | Why it's wrong | Fix |
|---|---|---|
OnNext on a sync stream stamps ImpersonateAsHub(Hub.Address) for a user-driven update |
User identity is replaced by the sync hub's address. Receiving side sees sync/xxx. User's writes get attributed to a hub. |
Carry the user's AccessContext through the Rx chain via CarryAccessContext; post SetCurrentRequest with the user's identity. |
Watcher subscriptions in hub-init code capture the hub-init AsyncLocal |
Every later emission runs under hub identity even if a user caused the trigger. | Read identity from the event (e.g. delivery.AccessContext of the trigger message), not from the AsyncLocal captured at subscribe time. |
Setting AccessService.Context to a hub-shaped principal (sync/, mesh/, node/, activity/, portal/) |
These are hub addresses, not user identities. They leak into downstream writes as fake CreatedBy. |
Only set user-shaped or system-security on AsyncLocal. AccessService.SetContext logs an error on violations. |
Per-callsite .PreserveAccessContext(...) / .Do(_ => SetContext(...)) litter |
Framework primitives already handle this. Adding it at the callsite is redundant and drifts. | Remove the redundant calls. |
meshService.CreateNode(node, ...) with an explicit o.WithAccessContext(captured) |
The framework wrap already handles this implicitly. | Remove the explicit WithAccessContext unless you have a non-AsyncLocal source for the context. |
accessService.ImpersonateAsHub(...) in application code |
Application writes should ride the user's identity. | Propagate the user's identity correctly (default path), or use ImpersonateAsSystem for genuine infrastructure, or grant the hub via a per-NodeType access rule if it's truly a redistributor. |
Catching UnauthorizedAccessException from a write and falling through silently |
Hides denials β the root cause of the prod EventCalendar bug. | Surface the denial: empty-state the UI, navigate to AccessDenied, or rethrow. |
Reading MeshNode.Content from a query row |
Query rows are stale and not gated per-user at the row level. | Use workspace.GetMeshNodeStream(path) or IMeshNodeStreamCache.GetStream(path) for live, access-checked content. |
Debug aid: the hub-shape error log
AccessService.SetContext / SetCircuitContext log an ERROR with a stack trace whenever a hub-shaped principal (sync/, mesh/, node/, activity/, portal/) is set on AsyncLocal:
[Error] [MeshWeaver.AccessContext] SetContext: hub-shaped principal sync/xxx set as AccessContext β must never happen. Source stack: β¦
When this fires:
- Sanctioned init / cache hydration / protocol traffic β expected at startup; can be downgraded once the legitimate sites are audited and tagged as sanctioned.
- Application code under user-driven flows (layout-area renders, click actions, watcher emissions caused by user writes) β this is a bug. Trace the stack to find where the user's identity was lost upstream and apply
CarryAccessContextat the seam.
IMeshNodeStreamCache.GetStream is access-checked
Beyond ensuring writes preserve user identity, reads through the process-wide IMeshNodeStreamCache are also gated by the caller's effective Read permission on the path.
π¨ The gate is evaluated LOCALLY β it does NOT post a GetPermissionRequest to the path's hub. MeshNodeStreamCache.ProbeEffectivePermissions calls meshHub.GetEffectivePermissions(path, userId) (β the static PermissionEvaluator) directly. Only when the result has Permission.Read does the gated observable forward upstream emissions; otherwise it terminates with UnauthorizedAccessException.
Why local, and why the hub round-trip was removed: GetEffectivePermissions walks the path's whole scope hierarchy β root, partition, every ancestor, the node itself β so access defined on the main node already covers every satellite under it ("who can read the main node can read all its satellites"). Asking for the PATH is therefore sufficient. The old probe targeted new Address(path), and a satellite / cell sub-path with no hub of its own β a {thread}/{messageId} the GUI subscribed to that was never persisted, or a brand-new thread β routes to NotFound, so nothing answered and the probe blocked for the full 15 s timeout: the side-panel "thread won't open" spinner. Do not reintroduce a leaf-hub permission probe on the read path.
The caller's captured AccessContext is restored around the synchronous evaluator capture (accessService.SwitchAccessContext(captured)) so claim-based (Bearer-token) roles on AccessContext.Roles resolve β PermissionEvaluator snapshots accessService.Context on the calling thread before any Rx scheduler hop.
Per-(path, userId) validations are cached in-process for 30 s (the AccessTtl constant in MeshNodeStreamCache.cs). Revocation surfaces within at most that window; the cache is not invalidated reactively. The shared upstream is unchanged β only the returned subscriber-side observable is gated.
GetPermissionRequest contract
GetPermissionRequest still exists as a message β it is how a caller asks a specific per-node hub what it grants (the test helper PermissionTestExtensions, and the request-time pipeline). It is simply no longer what the stream cache uses.
public record GetPermissionRequest : IRequest<GetPermissionResponse>;
public record GetPermissionResponse(Permission Permissions);
The request carries no path β the receiving hub answers for its own path. Callers route the request to the per-node hub at the path they care about; routing decides which hub responds. The handler (AccessControlPipeline.HandleGetPermission) resolves the per-hub-scoped PermissionEvaluator and evaluates it against the caller's delivery.AccessContext.
Worked example β Blazor data binding β SyncStream β owner update
Walking through all six phases with a concrete scenario:
Phase 1. User logs in.
CircuitAccessHandlersetsaccessService.CircuitContext = userfor the circuit's lifetime. Each inbound circuit activity wraps the dispatched callback inusing SwitchAccessContext(user)soAsyncLocalis set per-render too.Phase 5 (inside a layout area render). The user edits a text field bound to
JsonPointerReference("$.title"). The binding pushes the new value into aSynchronizationStream<MyContent>.Phase 6 (Rx hop). The stream's
OnNextfires. The Rx scheduler's thread has wipedAsyncLocalβ but the stream's subscribe-time wrap captureduserinto a closure (viaCarryAccessContexton the cold pipeline that fedOnNext). Before postingSetCurrentRequest,AsyncLocalis restored touser.Phase 2.
Hub.Post(new SetCurrentRequest(value), o => β¦)reaches PostPipeline β readsAsyncLocalβ stampsdelivery.AccessContext = user. NoImpersonateAsHub.Phase 3. Delivery travels to the owner hub.
Phase 4. Owner hub's
MessageHub.HandleMessageAsyncreadsdelivery.AccessContextβSetContext(user)β runsHandleSetCurrent.Phase 5.
HandleSetCurrentwrites through the owner-side data layer.AccessControlPipelinevalidates thatuserhas Update on the path.CreatedBy/LastModifiedByon the resulting MeshNode rows isuser.
If step 3 had stamped ImpersonateAsHub(Hub.Address) instead of carrying user, step 4 would set AsyncLocal to sync/xxx (caught by the error log), step 5 would validate sync/xxx (no grant configured) and either deny or β if a misconfigured rule grants it β attribute the write to the sync hub. That's precisely the bug this model is designed to prevent.