Repository navigation
Claim roles are a platform capability, never node data access — closes the paywall - #825
Conversation
…s the paywall The bypass, finally at its root: the API token attaches the user's DB/platform roles (e.g. the portal-admin role) as CLAIM roles, and PermissionEvaluator folded AccessContext.Roles into every node's effective permissions AFTER the per-scope deny subtraction. A claim role was therefore a GLOBAL, UNDENIABLE grant on the entire mesh — while the SQL fold (search, listings, RLS) never sees claims and correctly denied the same nodes. The two read paths disagreeing IS the bypass: measured on memex 2026-08-05 with one credential in one second, `search` hid the gated paid lessons and `get` by exact path served them in full (79,650 chars of AgenticPrimerDe/02-CodeWunsch to an unentitled caller). It is also why SST was readable, and why three upstream identity fixes (#819, #821, #823) — all real hardening — could not close it: the identity was correct; the evaluation was wrong. The model (AGENTS.md): a platform role grants the PLATFORM gates, deliberately NOT cross-partition data access — it must not read a course it has not bought. The change, in PermissionEvaluator: - ComputeRoleState no longer folds claim roles into roleIds. Node data permissions come from AccessAssignment nodes and policies ONLY — matching the SQL path row for row. - Claim roles keep their one legitimate job: the API-token CAPABILITY gate. An IsApiToken caller passes when its NODE permissions carry Api or its claims include an Api-bearing built-in role (ClaimsCarryApi) — without this half, every MCP token would zero out on PublicRead-policy content. Tests — PaywallRealGateShapeTests: the gate seeded with the EXACT node shapes Store's PluginGate writes (root Public+Anonymous Viewer grants, per-child Viewer-DENIED RoleAssignments, the buyer's root grant), read through the same entry point the MCP get tool uses. Before the fix 4 of 7 were red — including DbRoleClaim_DoesNotGrantNodeRead, the production scenario verbatim. After: all 7 green, with the buyer, the public cover and the ApiToken-reads-public cases pinning against an over-strict gate. The earlier PaywallIdentityMatrixTests seeded permissions through the test fixture helper rather than assignment nodes, which is why its deny cases passed while production leaked. Harness fallout, fixed the honest way: tests free-rode on the claim-Admin superuser. Now - TestUsers.DevLoginAdminAccess() — real STATIC root Admin assignment nodes for the DevLogin identities, chained into the default ConfigureMesh and the suites that hand-roll theirs. The static grant also keeps the evaluator's synchronous fast path alive where tests deliberately stall synced queries (CompileSourceSnapshotWedgeTest). - SetupAccessRightsAsync runs under ImpersonateAsSystem — provisioning grants is a System act in tests exactly as in production (PluginGate/SystemInstall); it cannot bootstrap under a user who needs the grant being written. Suites: Hosting.Monolith 467 passed / 0 failed. Hosting.PostgreSql 623 passed / 3 failed — the 3 GroupMembershipRecomputeTests failures reproduce identically on clean main (shared-fixture state, pre-existing). Closes #822. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR fixes a security bypass where AccessContext.Roles (claim roles) were being folded into node data permissions inside PermissionEvaluator, allowing exact-path reads (get) to bypass paywall denies that the SQL/RLS query path correctly enforced. The change restores the intended model: claim roles are platform capabilities, while node data access is determined only by AccessAssignment nodes and policies, aligning evaluator behavior with the SQL/RLS path.
Changes:
- Update
PermissionEvaluatorto stop folding claim roles intoroleIdsfor node permissions, while preserving claim roles only for the API-token capability gate (Permission.Api) viaClaimsCarryApi. - Add high-fidelity Postgres integration tests (
PaywallRealGateShapeTests) that seed access control using the same AccessAssignment node shapes written by Store’sPluginGate. - Update test harness setup to include real DevLogin admin AccessAssignment nodes and to provision access rights under system identity.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| test/MeshWeaver.Hosting.PostgreSql.Test/StaticRepoImporterTests.cs | Adds DevLogin root admin AccessAssignment nodes to keep setup reads/writes valid without claim-role node permissions. |
| test/MeshWeaver.Hosting.PostgreSql.Test/SpaceOnboardingIntegrationTests.cs | Same DevLogin admin seeding for onboarding integration setup under Postgres. |
| test/MeshWeaver.Hosting.PostgreSql.Test/PgOnlyProdShapeTests.cs | Seeds DevLogin admin assignments for prod-shape Postgres-only tests. |
| test/MeshWeaver.Hosting.PostgreSql.Test/PaywallRealGateShapeTests.cs | New integration tests reproducing the production paywall bypass using Store-accurate gate node shapes and MCP get-equivalent reads. |
| test/MeshWeaver.Hosting.PostgreSql.Test/EffectivePermissionPostgresTest.cs | Ensures effective-permission tests don’t rely on claim-role superuser behavior by seeding DevLogin admin assignments. |
| test/MeshWeaver.Hosting.PostgreSql.Test/BootTypeDefEchoTests.cs | Adds DevLogin admin assignment nodes so setup paths remain authorized after claim-role removal. |
| test/MeshWeaver.Hosting.PostgreSql.Test/AnonymousSpaceReadSecurityTests.cs | Seeds DevLogin admin assignment nodes for setup under Postgres RLS. |
| test/MeshWeaver.Hosting.Monolith.Test/NodeTypeReleaseGateTest.cs | Seeds DevLogin admin assignment nodes so release-gate setup doesn’t depend on claim roles granting node permissions. |
| src/MeshWeaver.Mesh.Contract/Security/PermissionEvaluator.cs | Stops folding claim roles into node permissions; adds ClaimsCarryApi for API-token capability gating only. |
| src/MeshWeaver.Hosting.Monolith.TestBase/TestUsers.cs | Introduces DevLoginAdminAccess() producing root-scope Admin AccessAssignment nodes for DevLogin identities. |
| src/MeshWeaver.Hosting.Monolith.TestBase/MonolithMeshTestBase.cs | Automatically seeds DevLogin admin assignments; runs access-rights provisioning under ImpersonateAsSystem() during initialization. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| var accessService = Mesh.ServiceProvider.GetService<AccessService>(); | ||
| using (accessService?.ImpersonateAsSystem() | ||
| ?? System.Reactive.Disposables.Disposable.Empty) | ||
| { | ||
| await SetupAccessRightsAsync(); | ||
| } |
Test Results (shard 4)1 020 tests 1 013 ✅ 4m 55s ⏱️ Results for commit 1658fc3. ♻️ This comment has been updated with latest results. |
Test Results (shard 3) 11 files 11 suites 5m 56s ⏱️ Results for commit 1658fc3. ♻️ This comment has been updated with latest results. |
Test Results (shard 0)718 tests 714 ✅ 5m 16s ⏱️ Results for commit 1658fc3. ♻️ This comment has been updated with latest results. |
Test Results (shard 5)1 532 tests 1 428 ✅ 5m 30s ⏱️ Results for commit 1658fc3. ♻️ This comment has been updated with latest results. |
Test Results (shard 2)1 626 tests 1 625 ✅ 5m 13s ⏱️ Results for commit 1658fc3. ♻️ This comment has been updated with latest results. |
Test Results (shard 1)1 616 tests 1 429 ✅ 7m 12s ⏱️ Results for commit 1658fc3. ♻️ This comment has been updated with latest results. |
Test Results 63 files 63 suites 34m 4s ⏱️ Results for commit 1658fc3. ♻️ This comment has been updated with latest results. |
CI surfaced the full blast radius of the claim-role fix — 75 tests across 7
projects. Every one was a test relying on the removed behavior, none a product
flow (DevLogin_BrandNewUser_SelfProvisions passing under the new evaluator
proves onboarding needs no claim crutch). Three moves:
- ConfigureMeshBase chains TestUsers.DevLoginAdminAccess(): a real STATIC root
Admin assignment node for the harness's ambient DevLogin identity (Roland).
Every suite funnels through the base, so test-body scaffolding writes work
without per-suite patches. TestUser is deliberately EXCLUDED — suites use it
as a SUBJECT (grant Viewer/Editor per test, assert the affordances), and a
baked-in root Admin would shadow every such role. The node id deliberately
differs from the runtime convention ({subject}_Access → Roland_DevLoginAccess):
several suites CREATE _Access/Roland_Access themselves and a static node at
the same path made that create collide (completes empty). The evaluator folds
assignments by accessObject, not node id.
- SecurityServiceTests: the two tests that PINNED the bypass as a feature are
flipped to pin the new contract. ClaimBasedAdmin_GrantsImmediately_
EvenWhenNoStaticAssignment ("claim-based Admin must grant all permissions") is
now ClaimBasedAdmin_DoesNotGrantNodePermissions; the delivery-context test
asserts restored claim roles resolve Permission.None without assignment nodes.
- The five PostgreSql suites that hand-roll ConfigureMesh (and so skip the base)
chain DevLoginAdminAccess explicitly.
Suites: Security 345/0, Monolith 467/0, Auth 116/0, Graph 802/0, AI 979/0,
Content 245/0, NodeOperations 85/0, Grpc 8/0. PostgreSql 623/3 — the 3
GroupMembershipRecomputeTests failures are pre-existing local fixture
pollution; green on CI's fresh database.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI shard 5 timed out the per-test seeds' fold barriers (each of seven tests seeded its own plugin; the process-wide security-access scope streams pile up and the 60s deny-fold barrier expired late in the shard). Seed ONCE for the class instead — lazily from the first test body, because a top-level Space cannot be created during mesh INIT (SetupAccessRightsAsync-time creates of a partition-owning node complete empty; only assignment nodes work there), and with a per-run unique plugin name (the PostgreSql fixture database persists across runs — a constant name collides with the previous run's plugin and the seeding CreateNode completes empty). Barriers raised to 120s. Same nodes, same reads, same assertions — the red-before/green-after proof of the evaluator fix is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes #822.
The root cause, finally
The API token attaches the user's DB/platform roles (e.g. the portal-admin role) as claim roles, and
PermissionEvaluatorfoldedAccessContext.Rolesinto every node's effective permissions after the per-scope deny subtraction. A claim role was therefore a global, undeniable grant on the entire mesh — while the SQL fold (search, listings, RLS) never sees claims and correctly denied the same nodes.The two read paths disagreeing is the bypass. Measured on memex 2026-08-05, one credential, one second:
It is also why SST was readable by exact path, and why #819 / #821 / #823 — all real hardening on the identity plumbing — could not close it: the identity was correct; the evaluation was wrong.
The model this restores (AGENTS.md): a platform role grants the PLATFORM gates, deliberately NOT cross-partition data access — it must not read a course it has not bought.
The change (
PermissionEvaluator)ComputeRoleStateno longer folds claim roles intoroleIds. Node data permissions come from AccessAssignment nodes and policies only — matching the SQL path row for row.IsApiTokencaller passes when its node permissions carryApior its claims include an Api-bearing built-in role (ClaimsCarryApi) — without this half, every MCP token would zero out on PublicRead-policy content.Tests —
PaywallRealGateShapeTestsThe gate is seeded with the exact node shapes Store's
PluginGatewrites (root Public+Anonymous Viewer grants, per-childViewer DENIEDRoleAssignments, the buyer's root grant), read through the same entry point the MCPgettool uses. The earlierPaywallIdentityMatrixTestsseeded permissions through the fixture helper rather than assignment nodes — which is why its deny cases passed while production leaked.DbRoleClaim_DoesNotGrantNodeRead(the production scenario verbatim)ViewerClaim_DoesNotGrantNodeReadApiToken_WithClaimRoles_IsStillDeniedGatedContentBuyer_CanReadGatedContentCover_VisitorAllowed,ApiToken_…_StillReadsPublicContent,AuthenticatedNotEntitled_IsDeniedHarness fallout, fixed the honest way
Tests free-rode on the claim-Admin superuser:
TestUsers.DevLoginAdminAccess()— real static root Admin assignment nodes for the DevLogin identities, chained into the defaultConfigureMeshand the suites that hand-roll theirs. The static grant also keeps the evaluator's synchronous fast path alive where tests deliberately stall synced queries (CompileSourceSnapshotWedgeTest).SetupAccessRightsAsyncruns underImpersonateAsSystem— provisioning grants is a System act in tests exactly as in production (PluginGate/SystemInstall); it cannot bootstrap under a user who needs the grant being written.Suites
Hosting.Monolith: 467 passed / 0 failedHosting.PostgreSql: 623 passed / 3 failed — the 3GroupMembershipRecomputeTestsfailures reproduce identically on cleanmain(shared-fixture state, pre-existing)Prod verification after deploy: as an unentitled MCP credential,
get @AgenticPrimerDe/02-CodeWunschandget @SST/…must be denied;get @AgenticPrimerDe(cover) and ordinary public reads must still work;searchunchanged.🤖 Generated with Claude Code