Repository navigation
Paywall bypass: MCP exact-path get returns gated paid content #822
Description
Impact
A signed-in user with no entitlement can read gated paid course content via MCP get by exact path on memex.
Repro (any account without an entitlement):
get @AgenticPrimerDe/02-CodeWunsch → returns 79,650 chars of the paid lesson
get @AgenticPrimerDe → cover, correctly readable
02-CodeWunsch carries an explicit deny: Public|AgenticPrimerDe/02-CodeWunsch|Read|is_allow=false.
Ruled out (all measured on production 2026-08-05)
| Hypothesis | Evidence against |
|---|---|
| Gating data wrong | denies present and correct; SQL query path hides the same node |
| Permission projection empty | per-partition schemas populated (sst = 46 rows). admin.user_effective_permissions is the WRONG table — it's per-partition |
Missing node_type index |
all 136 schemas have it |
| Database slow / at fault | 136-schema union answers in 57 ms |
Identity lost in MeshOperations.FetchNode |
fixed in #819, deployed 3.0.0-ci.1829 → still leaked |
| Identity absent at the MCP tool call | fixed in #821, deployed 3.0.0-ci.1832 → still leaked |
UserContextMiddleware not covering /mcp |
ordering is correct (UseMiddleware<UserContextMiddleware> before MapMeshMcp), with a comment documenting this exact bug being fixed previously |
Key evidence
1. The read reaches the grain as System:
GrainCallFilter: grain=messagehub/AgenticPrimerDe/02-CodeWunsch,
method=DeliverMessage, userId=system-security
system-security holds Permission.All, so MeshNodeStreamCache.GetStreamRaw's gate passes legitimately. That gate deliberately falls through to its shared upstream — opened under the cache's own system read identity — when no user identity is ambient. Absent identity does not fail closed.
2. MeshOperations.Get's entry log never appeared during a probe with Logging__LogLevel__MeshWeaver.AI=Debug, one portal pod, /mcp confirmed routing to it. Caveat: the grep was tail=3000 shortly after a restart, so startup volume may have pushed it out — this needs re-testing on a warm pod before being treated as fact.
Suggested next steps
- On a warm pod with
MeshWeaver.AI=Debug, probe and confirm whether"Get called with path"appears. That settles whether the repo'sMeshOperations.Getis the executing code. - If it appears: the
AccessContextis null at tool-invocation time despite correct middleware ordering → the MCP SDK likely dispatches tool calls on a fresh async context. Fix by flowing identity fromIHttpContextAccessor/the MCP session rather than theAsyncLocal. - Consider making the cache gate fail closed for partition-rooted user paths when no identity is ambient. This is the durable fix but has mesh-wide blast radius (the fall-through exists for background/hub-principal flows) and needs its own test pass.
Related
- Exact-path reads run under the caller's identity, so gating applies #819, MCP read tools run as the calling user, not as system-security #821 — both correct-in-principle hardening (reads run as the caller); neither closed this breach. Tests added in Exact-path reads run under the caller's identity, so gating applies #819 (
GatedNodeExactPathReadTests) pin visitor-denied / buyer-allowed / cover-public and are worth keeping, but they pass with and without the change, so they never reproduced this. - Symptom was previously filed (2026-07-29) as an operational quirk: "children vanish from listings/subtree search … while direct get by path still works". That last clause is this bug.
🤖 Generated with Claude Code
Activity
rbuergi commentedon Aug 5, 2026
Still open after #823 — but the search-vs-get measurement finally isolates it
#823is merged and live (3.0.0-ci.1835). The bypass reproduces unchanged, and a node I had never fetched (03-MehrMagie) came back in full — so this was never a cached response either.The decisive measurement — same MCP connection, same credential, same second
What that proves, and what it kills
search; it sees onesearchPublicdeny and hides 02/03MeshOperations"Get called with path"linesSo the whole lost/absent identity family of hypotheses — which #819, #821 and #823 were built on — is dead. The per-node hub read simply never consults the partition RLS that
GenerateAccessControlClauseapplies on the query path.Why the tests in #823 did not catch it
PaywallIdentityMatrixTests.AuthenticatedButNotPurchased_IsDeniedpassed before any fix. The tests hand-seed their own gate rather than using the realAddPluginGatingshape (_Policy publicRead:false+ root Public/Anonymous Viewer + per-childViewer DENIED (gated)), so the harness denies where production serves. Only the no-ambient-identity case ever failed — a genuine defect, correctly fixed by #823, but not this one.#823 is still worth keeping: no-identity reads previously ran as System (
Permission.All).Next steps, in order
AddPluginGatingshape and confirm it fails before touching code.IMeshNodeStreamCache.GetStream/ the per-node hub read) to apply the same partition RLS as the query path.Do not ship a fourth fix without step 1 going red first.
Claim roles are a platform capability, never node data access — close…
Claim roles are a platform capability, never node data access — close…
rbuergi commentedon Aug 6, 2026
Verified on production (
3.0.0-ci.1842, unentitled MCP credential, post-rollout)get @AgenticPrimerDe/02-CodeWunsch(gated paid — previously served 79,650 chars)get @AgenticPrimerDe/03-MehrMagie(previously served in full)get @SST/StandReDataget @AgenticPrimerDe(cover)get @AgenticPrimerDe/01-Zauberwunsch(free lesson)searchsearchandgetagree for the first time. Operator note: accounts whose reads worked only via platform DB roles (no assignment nodes) are now denied — intended; real access = entitlement or grant.