Skip to content

Claim roles are a platform capability, never node data access — closes the paywall - #825

Merged
rbuergi merged 3 commits into
mainfrom
fix/exact-path-read-rls
Aug 5, 2026
Merged

rbuergi merged 3 commits into
mainfrom
fix/exact-path-read-rls

Conversation

@rbuergi

@rbuergi rbuergi commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

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 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, one credential, one second:

search path:AgenticPrimerDe scope:subtree nodeType:Edu/Module
  → only the free lesson. Gated lessons correctly hidden.
get @AgenticPrimerDe/03-MehrMagie
  → the full paid lesson.

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)

  • 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 is 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. The earlier PaywallIdentityMatrixTests seeded permissions through the fixture helper rather than assignment nodes — which is why its deny cases passed while production leaked.

Case Before After
DbRoleClaim_DoesNotGrantNodeRead (the production scenario verbatim) ❌ leaked ✅ denied
ViewerClaim_DoesNotGrantNodeRead ❌ leaked ✅ denied
ApiToken_WithClaimRoles_IsStillDeniedGatedContent ❌ leaked ✅ denied
Buyer_CanReadGatedContent ✅ ✅ (over-strict-gate pin)
Cover_VisitorAllowed, ApiToken_…_StillReadsPublicContent, AuthenticatedNotEntitled_IsDenied ✅ ✅

Harness 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 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)

Prod verification after deploy: as an unentitled MCP credential, get @AgenticPrimerDe/02-CodeWunsch and get @SST/… must be denied; get @AgenticPrimerDe (cover) and ordinary public reads must still work; search unchanged.

⚠️ Behavioral note for operators: accounts whose reads previously worked only via platform/DB roles (no assignment nodes) will now be denied — that is the intended model. Real access comes from entitlements and grants.

🤖 Generated with Claude Code

…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>
Copilot AI lite review requested due to automatic review settings August 5, 2026 20:34

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 PermissionEvaluator to stop folding claim roles into roleIds for node permissions, while preserving claim roles only for the API-token capability gate (Permission.Api) via ClaimsCarryApi.
  • Add high-fidelity Postgres integration tests (PaywallRealGateShapeTests) that seed access control using the same AccessAssignment node shapes written by Store’s PluginGate.
  • 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.

Comment on lines +772 to +777
var accessService = Mesh.ServiceProvider.GetService<AccessService>();
using (accessService?.ImpersonateAsSystem()
?? System.Reactive.Disposables.Disposable.Empty)
{
await SetupAccessRightsAsync();
}
@github-actions

github-actions Bot commented Aug 5, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

1 020 tests   1 013 ✅  4m 55s ⏱️
   11 suites      7 💤
   11 files        0 ❌

Results for commit 1658fc3.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 5, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

   11 files     11 suites   5m 56s ⏱️
1 339 tests 1 339 ✅ 0 💤 0 ❌
1 415 runs  1 415 ✅ 0 💤 0 ❌

Results for commit 1658fc3.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 5, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

718 tests   714 ✅  5m 16s ⏱️
  9 suites    4 💤
  9 files      0 ❌

Results for commit 1658fc3.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 5, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

1 532 tests   1 428 ✅  5m 30s ⏱️
   11 suites    104 💤
   11 files        0 ❌

Results for commit 1658fc3.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 5, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

1 626 tests   1 625 ✅  5m 13s ⏱️
   11 suites      1 💤
   11 files        0 ❌

Results for commit 1658fc3.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 5, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

1 616 tests   1 429 ✅  7m 12s ⏱️
   10 suites    187 💤
   10 files        0 ❌

Results for commit 1658fc3.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 5, 2026 •

Copy link
Copy Markdown
Contributor

Test Results

   63 files     63 suites   34m 4s ⏱️
7 851 tests 7 548 ✅ 303 💤 0 ❌
7 927 runs  7 624 ✅ 303 💤 0 ❌

Results for commit 1658fc3.

♻️ This comment has been updated with latest results.

rbuergi and others added 2 commits August 5, 2026 23:25
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Paywall bypass: MCP exact-path get returns gated paid content

2 participants