Repository navigation
fix(security): the API-token clamp now reaches message-routed checks (#2976) - #3002
Conversation
…2976) `AccessControlPipeline` restored the sender's `AccessContext` onto the receiving per-node hub only when it carried role CLAIMS (`delivery.AccessContext is { Roles: { Count: > 0 } }`). The 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 and #2974 removed their last foothold (the API-token capability hatch). `AccessContext.Roles` is read NOWHERE in `PermissionEvaluator`. So the condition outlived its reason, and it was never the right one. The fold reads two flags off the restored context — `IsApiToken` (the API-token clamp, which zeroes the permission set for a Bearer context this path's live policy does not admit) and `IsHub` (the hub-credential early return) — neither of which has anything to do with roles. Most IdPs emit no role claims, so `ApiToken.Roles` is normally empty: the ordinary token reached a per-node hub with `Roles = []`, the restore was skipped, `capturedContext` was null, `IsApiToken` was never seen, and the clamp DID NOT RUN. The Bearer delivery was then evaluated as an interactive session — silently, with no exception and no log. The exact-read path was never exposed (`MeshNodeStreamCache.GetStreamRaw` captures the caller itself and wraps evaluation in `SwitchAccessContext`), so `api: false` held for a read that named the page and was skipped for every message-routed check against it. The restore is now keyed on the delivery carrying a REAL PRINCIPAL — a non-empty `ObjectId` that is not hub-shaped — rather than on a payload field the decision does not read. Hub-shaped ids (`sync/`, `mesh/`, `node/`, `activity/`, `portal/`) keep the mesh-wide treatment they already had everywhere else (`AccessService.SetContext`'s leak tripwire, `UserServiceDeliveryPipeline`'s `shouldStamp`, `MeshNodeStreamCache`'s pass-through), so nothing about hub credentials changes. Note the shape, because it is why the condition survived: the gate's own INPUT decided whether the gate ran, so "the check did not run" and "the check passed" were indistinguishable — the runtime cousin of AGENTS.md's no-skip-trapdoor rule for CI, which this very file already states for its missing-evaluator branch. Evidence (measured, not assumed): * pre-fix — RoutedApiTokenClampTest.ABearerDeliveryWithNoClaims_IsClampedOnThePerNodeHub FAILS: the claimless Bearer read of an `api: false` partition is SERVED. * post-fix — both cases pass; the control arm (same person, same node, same routed message, no Bearer flag) is served both before and after, so the pin measures a capability decision, not a blanket deny. Docs: AccessContextPropagation gains "Restoring the caller on the receiving hub" (the third restore site and why it exists); AccessControl's "Known gap" note is closed; the stale rationale in `UserContextMiddleware` is corrected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SD7aiLSo59xng2TzU32xEa
There was a problem hiding this comment.
Pull request overview
This PR fixes a security propagation defect where message-routed permission checks on per-node hubs could skip restoring the caller’s AccessContext (previously gated on Roles.Count > 0), causing the API-token clamp (IsApiToken) to be missed for claimless tokens and allowing reads that should be blocked when api: false is in effect.
Changes:
- Update
AccessControlPipelineto restore the deliveryAccessContextbased on “real principal” criteria (non-emptyObjectId, not hub-shaped) rather than role-claim presence. - Add a regression test that reproduces the routed-path clamp failure for an API token with empty role claims, plus companion assertions.
- Update documentation (AccessControl + AccessContextPropagation) and related comments, and add a What’s New entry describing the fix.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| test/MeshWeaver.Graph.Test/RoutedApiTokenClampTest.cs | Adds regression coverage for the routed-path API-token clamp behavior. |
| test/MeshWeaver.Graph.Test/ApiTokenCapabilityFreshnessTest.cs | Updates commentary to reflect that AccessContext.Roles is diagnostic-only and references the new regression. |
| src/MeshWeaver.Hosting/Security/AccessControlPipeline.cs | Fixes the restore condition so routed checks see IsApiToken/IsHub when appropriate. |
| src/MeshWeaver.Hosting.AspNetCore/Portal/UserContextMiddleware.cs | Updates rationale/comments to match the new restore behavior and defect history. |
| src/MeshWeaver.Documentation/Data/WhatsNew/2026-09-02-a-partition-closed-to-the-api-is-now-closed-on-every-path.md | Adds a user-facing release note about the security fix. |
| src/MeshWeaver.Documentation/Data/Architecture/AccessControl.md | Closes the documented “known gap” and links to the new propagation explanation section. |
| src/MeshWeaver.Documentation/Data/Architecture/AccessContextPropagation.md | Documents the third restore site and why it must not be keyed on role-claim shape. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Test Results (shard 0)216 tests 216 ✅ 2m 27s ⏱️ Results for commit 1ac38a3. |
Test Results (shard 3)586 tests 586 ✅ 54s ⏱️ Results for commit 1ac38a3. |
Test Results (shard 5) 3 files 3 suites 1m 16s ⏱️ Results for commit 1ac38a3. |
Test Results (shard 2)529 tests 527 ✅ 1m 33s ⏱️ Results for commit 1ac38a3. |
Test Results (shard 1)419 tests 419 ✅ 1m 35s ⏱️ Results for commit 1ac38a3. |
Test Results (shard 4)1 687 tests 1 687 ✅ 1m 45s ⏱️ Results for commit 1ac38a3. |
Test Results 14 files 14 suites 9m 31s ⏱️ Results for commit 1ac38a3. |
Fixes #2976.
The defect
AccessControlPipelinerestored the sender'sAccessContextonto the receiving per-node hub only when it carried role claims:The hypothesis in the issue is verified:
AccessContext.Rolesis read nowhere inPermissionEvaluator. The 2026-08-05 paywall fix took claim roles out of node permissions and #2974 removed their last foothold (the API-token capability hatch), so the condition survived with its rationale gone — and it was never the right condition. The fold reads exactly two flags off the restored context:IsApiTokenapi: false).IsHubNeither has anything to do with roles.
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 in the fold,IsApiTokenwas never seen and the clamp never ran — the Bearer delivery was evaluated as if it were an interactive session. Silently: no exception, no log, a served read.Not a hole in the exact-read path —
MeshNodeStreamCache.GetStreamRawcaptures the caller's context itself and wraps evaluation inSwitchAccessContext, so MCPgetclamps correctly.api: falsetherefore held for a read that named the page and was skipped for every message-routed check against it.The shape is worth naming: the gate's own INPUT decided whether the gate ran, so "the check did not run" and "the check passed" were indistinguishable from outside. That is the runtime cousin of AGENTS.md's no-skip-trapdoor rule for CI — which this very file already states for its missing-evaluator branch.
The fix
Restore whenever the delivery carries a real principal — a non-empty
ObjectIdthat is not hub-shaped — rather than on a payload field the decision does not read.Hub-shaped ids (
sync/,mesh/,node/,activity/,portal/) keep exactly the treatment the rest of the mesh already gives them (AccessService.SetContext's leak tripwire,UserServiceDeliveryPipeline'sshouldStamp,MeshNodeStreamCache's pass-through): a hub address is not a user identity and is never installed as one. So nothing about hub credentials changes, and theSetContexterror tripwire is not turned into a per-delivery log flood. The empty-ObjectIdanonymous context is likewise skipped — it names nobody to restore, and shadowing a circuit context with it would change verdicts for anonymous rendering without fixing anything.Cost on the per-delivery hot path
The issue flags that this sits inside the per-delivery path on every per-node hub, so what the change adds is worth naming rather than assuming. Per
[RequiresPermission]delivery whose context previously carried no claims: onestring.IsNullOrEmpty, oneLooksLikeHubPrincipal(fiveStartsWithover a short id), and oneAccessService.SetContext— anAsyncLocalwrite plus aLogDebugthat fires only when the principal actually changes. Thenew StackTrace(...)insideSetContextis on the hub-shaped-principal branch, which this condition never reaches. No new allocation, no new DI resolve, no new query, and nothing added to the fold itself.Test evidence — measured, not assumed
New
test/MeshWeaver.Graph.Test/RoutedApiTokenClampTest.cs: a[RequiresPermission(Read)]message posted from a client hub at the per-node hub that owns aPublicRead = true, Api = falsepartition, carrying an API-tokenAccessContextwith emptyRoles. Real monolith mesh (MonolithMeshTestBase), no mocks.Before the fix (
src/unchanged, test added):After the fix:
The control arm — the same person, the same node, the same routed message without the Bearer flag — is served both before and after, so the pin measures a capability decision rather than a blanket deny.
Full local suites, Debug:
MeshWeaver.Graph.Test1086/1086,MeshWeaver.Documentation.Test166/166, plusMeshWeaver.Hosting.TestandMeshWeaver.Data.Test. Release-warnaserror, 0 warnings / 0 errors:MeshWeaver.Hosting,MeshWeaver.Hosting.AspNetCore,MeshWeaver.Documentation,MeshWeaver.Graph.Test,MeshWeaver.Documentation.Test.Work products conserved
Doc/Architecture/AccessContextPropagationgains "Restoring the caller on the receiving hub" — the third restore site, why it exists (the gate composes outsideUserServiceDeliveryPipeline, so Phase 4 has not happened yet when it evaluates), the two flags the fold reads, and the AccessControlPipeline restores a delivery's AccessContext only when Roles is non-empty — an API token with no claims reaches a per-node hub unclamped #2976 finding written as the rule it is an instance of.Doc/Architecture/AccessControl's "Known gap" note (which the issue cites) is closed and points at the new section.UserContextMiddleware— "…and becauseAccessControlPipelineuses a non-emptyRoleslist as its cue" — is corrected;AccessContext.Rolesis now a diagnostic and nothing else.Category: Fix): A partition closed to the API is now closed on every path.🤖 Generated with Claude Code
https://claude.ai/code/session_01SD7aiLSo59xng2TzU32xEa