Repository navigation
The two identity reads run as System, and read every grant - #2583
Conversation
`OnboardingMiddleware.LoadUserRoles` decides a viewer's roles, so it is a security-fold read wearing ordinary clothes. It was written as neither, and carried two defects that both fail the same silent way — a shorter list of grants is indistinguishable from holding fewer roles, so each one surfaces only as screens answering "Access denied", with nothing thrown and nothing logged. 1. `limit:10`, and it never reached SecurityQueries.Enumeration. A viewer with more than ten AccessAssignment nodes got an arbitrary ten and lost the rest. This is the truncation #2011 fixed for the security fold, and SecurityQueries exists to make it structurally impossible: "a query string that never reaches this class is the only way back to the defect." This was one. It fires on GROWTH, so it appears on the largest install first. Red-proofed: 14 grants seeded, `found 10` on the unfixed code. 2. It ran under the caller's identity. `workspace.GetQuery` re-applies per-user RLS at the consumer, and that filter resolves permissions through PermissionEvaluator — which reads AccessAssignment nodes. So establishing an identity re-entered the permission fold from the request path, and the answer was gated on the identity being established. Seeing System, WrapWithPerUserRls short-circuits to the raw upstream: "no per-user filter, no service resolution, no recursion". SecurityService .ObserveScopeAssignments already reads these nodes that way, "by design" — this aligns an outlier with the existing contract rather than inventing a bypass. It grants a viewer nothing: the filter can only ever REMOVE grants they legitimately hold. FindUserByEmail gets the same treatment. Leaving the FIRST read of the pipeline wrapped keeps the same edge reachable, and its failure mode is the one this file exists to prevent (#637): an RLS-stripped User node reads as "no such account", and the caller's response is to bounce a signed-in user to /onboarding — inviting a second account. Its `limit:1` stays; that is a unique-key point lookup, not a set being truncated. RunAsSystem, not `Observable.Using(() => ImpersonateAsSystem(), …)`, whose store and restore land on different threads and leave the subscriber latched. The identity test is labelled in-file as a RATCHET, not a red-proof: it passes on the unfixed code too, because the per-user filter is inert without an EffectivePermissionsDelegate, which a monolith test mesh does not configure. It pins that the answer is identity-independent. The truncation test is the half that goes red. MeshWeaver.Auth.Test: 148 passed, 0 failed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
This closes finding 8 of #2302 (the Findings 9 ( |
There was a problem hiding this comment.
Pull request overview
This PR hardens the sign-in/onboarding identity reads so they can’t silently under-read authorization data (either by query paging/truncation or by being filtered by the caller’s ambient identity), which previously manifested as unexplained “Access denied” outcomes.
Changes:
- Wrap
LoadUserRoles’ AccessAssignment query throughSecurityQueries.Enumeration(...)so it can’t be truncated by an in-querylimit:10. - Run both
FindUserByEmailandLoadUserRolesreads under System identity viaRunAsSystem(...)to avoid per-user RLS recursion/truncation during identity establishment. - Add regression/ratchet tests plus a “What’s New” entry documenting the fix.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
memex/Memex.Portal.Shared/Authentication/OnboardingMiddleware.cs |
Runs the two identity-defining reads as System and routes the role query through SecurityQueries.Enumeration to prevent silent truncation. |
test/MeshWeaver.Auth.Test/LoadUserRolesIsASystemReadTests.cs |
Adds coverage for “reads all grants” (red-proof) and identity-independence (ratchet) of role resolution. |
src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-28-all-your-roles-are-read.md |
Documents the user-facing impact and the behavioral change. |
Suppressed comments (1)
memex/Memex.Portal.Shared/Authentication/OnboardingMiddleware.cs:532
- Same issue as
FindUserByEmail:RunAsSysteminvokes thework()factory insideSubscribewithout converting synchronous exceptions intoOnError. IfSecurityQueries.Enumeration(...)orworkspace.GetQuery(...)throws synchronously, the exception escapes the subscription and bypassesIdentityRead.Bounded’sCatch/Timeoutclassification.
Wrap the inner query pipeline in Observable.Defer so synchronous failures are classified as IdentityReadOutcome.Unavailable rather than surfacing as an unhandled request error.
return IdentityRead.Bounded(
accessService.RunAsSystem(() =>
workspace.GetQuery(
$"auth:userRoles:{username}",
// Through the SecurityQueries seam, which overwrites any limit with
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
The defect this PR fixes was reachable because nothing enforced the rule. SecurityQueries already says so about its own half — "a query string that never reaches this class is the only way back to the defect" — and LoadUserRoles was exactly such a string. IdentityReadsRunAsSystemGuard now fails the build when a pre-identity read runs unwrapped, naming file, method and line. Red-proofed by removing one wrapper: InvitationService.cs: FindPendingInvitation reads via GetQuery without RunAsSystem (line 52) Scope is narrow on purpose. "Every auth query must be System" would be WRONG: ApiTokenService.GetTokensForUser answers "show me MY tokens" for an established viewer, and per-user filtering there is the feature, not the bug. The rule covers only reads that run BEFORE a viewer has a mesh identity — every GetQuery in OnboardingMiddleware, plus FindPendingInvitation by name. It carries an anti-vacuity floor (>= 3 calls examined) so a rename cannot leave it green while guarding nothing, and it fails with "update this guard" if the files move — which is live, not hypothetical, with Memex.Portal.Shared mid-carve-out. Writing it turned up a third instance. InvitationService.FindPendingInvitation's summary ALREADY claimed it "runs as System"; it did not. The prose described a guarantee the code never made. It is a pre-identity read against the Admin partition, where an invitation filtered out is indistinguishable from one never issued — onboarding would treat an invited person as not invited and block them. Now the code matches its documentation. MeshWeaver.Auth.Test: 148 passed, 0 failed. Guard: passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Pushed a second commit adding the guard and a third fix. The invariant is now enforced, not remembered
This is the point the fix would otherwise miss: Scope is narrow deliberately. "Every auth query must be System" would be wrong: Third instance, found by writing the guard
|
Review catch on this PR, and it was a real regression I introduced. Replacing `Observable.Defer(...)` with `RunAsSystem(...)` moved synchronous composition throws OUT of the fault channel: SubscribeScopedObservable invoked the factory with no catch, so the exception escaped Subscribe instead of arriving as OnError. IdentityRead.Bounded classifies faults off the SEQUENCE — it maps OnError to IdentityReadOutcome.Unavailable — so an escaping throw bypassed that mapping and turned "we could not find out" back into an unclassified request failure. That is the #637 collapse this file exists to prevent, and the code I deleted carried a comment saying exactly why it was there ("Deferred so a synchronous throw while composing is classified too"). Fixed at the ROOT rather than at the two call sites. Re-adding Defer where the reviewer pointed would work and would leave the trap armed for the other ~65 RunAs* call sites, with correctness resting on each author remembering. The scope now mirrors Observable.Defer: the FACTORY is guarded, and an exception raised by the subscribe itself still propagates as Rx expects. The comment that made the wrong claim is corrected in place rather than deleted, so the file records what was wrong and what makes it right. Red-proof discriminates: against main's ImpersonationScopeExtensions the two throw tests fail and the control test (A_normal_sequence_is_unaffected) stays green, so they pin this behaviour and not something incidental. The SubscribeRestoring branch (caller identity present) is covered too, since the factory is guarded before either branch is chosen. MeshWeaver.Messaging.Hub.Test: 303 passed, 0 failed. MeshWeaver.Auth.Test: 148 passed, 0 failed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Test Results (shard 2)946 tests 946 ✅ 5m 9s ⏱️ Results for commit 706b56c. ♻️ This comment has been updated with latest results. |
Test Results (shard 4) 10 files 10 suites 4m 49s ⏱️ Results for commit 706b56c. ♻️ This comment has been updated with latest results. |
Test Results (shard 3)469 tests 465 ✅ 5m 0s ⏱️ Results for commit 706b56c. ♻️ This comment has been updated with latest results. |
Test Results (shard 5)1 573 tests 1 572 ✅ 6m 3s ⏱️ Results for commit 706b56c. ♻️ This comment has been updated with latest results. |
Test Results (shard 0)894 tests 894 ✅ 5m 34s ⏱️ Results for commit 706b56c. ♻️ This comment has been updated with latest results. |
Test Results (shard 1)983 tests 983 ✅ 6m 25s ⏱️ Results for commit 706b56c. ♻️ This comment has been updated with latest results. |
Test Results 42 files 42 suites 33m 2s ⏱️ Results for commit 706b56c. ♻️ This comment has been updated with latest results. |
The two identity reads run as System, and read every grant
OnboardingMiddleware.LoadUserRolesis the read that decides a viewer's roles — a security-fold read wearing ordinary clothes. It was written as neither, and carried two defects. Both fail the same silent way: a shortened grant list is indistinguishable from holding fewer roles, so each surfaces only as screens answering "Access denied", with nothing thrown and nothing logged. The caller's own remarks already describe that outcome for the timeout case; these are two more ways to reach it.1.
limit:10— proven, and it is #2011 re-introducedThe query never reached
SecurityQueries.Enumeration, whose doc states the rule it breaks:A viewer with more than ten
AccessAssignmentnodes got an arbitrary ten; the rest of their roles vanished. The trigger is growth, not a change — so it appears on the largest install first, and nobody will have touched anything.Red-proof: 14 grants seeded →
Expected collection to have 14 item(s) … but found 10on the unfixed code. Green after.2. It ran under the caller's identity
workspace.GetQueryre-applies per-user RLS at the consumer (WrapWithPerUserRls), and that filter resolves permissions throughPermissionEvaluator— which readsAccessAssignmentnodes. So establishing an identity re-entered the permission fold from the request path, gating the answer on the identity it exists to build.The escape hatch is documented at
SyncedQueryDataSourceExtensions.cs:244— seeing System, the wrap short-circuits to the raw upstream: "no per-user filter, no service resolution, no recursion".SecurityService.ObserveScopeAssignmentsalready reads these very nodes that way, "by design". This aligns an outlier with the existing contract rather than inventing a bypass.It grants a viewer nothing. The filter can only ever remove grants they legitimately hold.
FindUserByEmailgets the same treatment: leaving the first read of the pipeline wrapped keeps the same edge reachable, and its failure mode is the one this file exists to prevent (#637) — an RLS-strippedUsernode reads as "no such account", and the caller's response is to bounce a signed-in user to/onboarding, inviting a second account. Itslimit:1stays; that is a unique-key point lookup, not a set being truncated.RunAsSystem, neverObservable.Using(() => ImpersonateAsSystem(), …)— store and restore land on different threads and leave the subscriber latched (AGENTS.md).🚨 What the tests do and do not prove
Stated plainly because one of them is easy to misread, and it is labelled as such in-file:
Every_grant_is_read_not_the_first_page_of_themfound 10of 14 on the unfixed code.The_answer_does_not_depend_on_who_is_askingThe identity test passes on
maineven with both ambient context slots set to an unprivileged stranger — I checked, rather than assuming.WrapWithPerUserRlsreturns the upstream unchanged when the hub has noEffectivePermissionsDelegate, and a monolith test mesh does not configure one, so the per-user filter is inert there and the recursion cannot be reproduced at this level. The test pins the invariant — that the answer is identity-independent — and fails if a future change makes this read consult the asker.MeshWeaver.Auth.Test: 148 passed, 0 failed (same count as before the change, so no test was lost).🤖 Generated with Claude Code