From e2d64a1827b2f936a63f2001aa57c886bfb95d3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= <6334612+rbuergi@users.noreply.github.com> Date: Sun, 20 Sep 2026 11:33:25 +0200 Subject: [PATCH 1/2] =?UTF-8?q?docs(#4716):=20a=20Public/Anonymous=20deny?= =?UTF-8?q?=20does=20not=20beat=20PublicRead=20in=20the=20C#=20fold=20?= =?UTF-8?q?=E2=80=94=20the=20SQL=20fold=20says=20it=20does?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #4716 reports that `Feedback/_Policy` grants blanket `publicRead` over the partition collecting user submissions. The exposure is REAL and still live. Its proposed fix — a per-path deny overriding the partition grant, which the triage comment called "not hypothetical" on the strength of a `PackageInstaller` remark — is FALSIFIED. Measured, not argued: * memex.meshweaver.cloud, read-only, 2026-09-20 — `Feedback/_Policy` = `{ publicRead: true }`; `Feedback/_Submissions/demo-preview` exists under it; `Feedback/_Submissions/_Access/Public_Access` is `Not found` while `Feedback/_Access/Public_Access` reads back fine, so the absence is the node's and not a filtered read. * Monolith mesh — implementing the issue's fix (read the manifest's `protectedSegments`, write the Public+Anonymous Viewer denies) leaves the inbox PUBLIC. Both deny nodes existed (`exists …/_Access/Public_Access: True`, `…/Anonymous_Access: True`) and Anonymous still read the submission. `ComputeRoleState` subtracts denied ROLES and ORs `publicGrant` in separately and afterwards; `PublicRead` is not a role, so there is nothing to take away. * The only thing that withholds it in that fold is a deeper `Read = false`, whose cap is ANDed into every role-derived permission too — core's own `PublicReadPolicyScopeTest` already pins that a role holder loses Read. A blackout, not a gate, and the Feedback inbox reads as the VIEWER. * `PostgreSqlSchemaInitializer` (MeshWeaver.Plugins) states the OPPOSITE for its own path: "a deny at a LONGER prefix still wins the per-subject longest-prefix query fold; that is the store-gating shape and it is intentional." So the root cause is a two-executor divergence on the read path — the shape this evaluator's own comments call the paywall bypass (hidden from every listing, readable by exact path). Three components assert the rule only one path implements, and the third ACTS on it: the Store's `PluginGate` pre-installed arm writes exactly those denies for a manifest's `ProtectedSegments`, naming this very inbox, so the protection it applies is none on the C# path. This change set carries no behavioural fix — deliberately. Closing the divergence means folding the public grant longest-prefix over the well-known subjects' rows, and the state for it does not reach the fold today (`ComputeScopeRoles` filters assignments to the evaluated subject). That changes permission outcomes for every subject on every path, on a fold with an incident history on these exact lines, and the Postgres half is read from a comment rather than executed. It needs a decision, not a patch. What lands instead: * `PublicReadIsNotSuppressedByADenyTest` — the three shapes, executable: the deny is inert under a policy grant, works under root role grants (which is why the rule looked true), and the read cap darkens the reviewer with the public. * `Doc/Architecture/PublicReadAndDenies` — the whole measurement, the confidence on each half stated separately, and what each of the four remedies costs. * `PackageInstaller` — the false general rule corrected where it was read from, and the satellite delegation in `GatedChildRoots` annotated. * `AccessControl.md` — the parenthetical that reads as "a deeper deny still wins" disambiguated to *cap*, which is how the belief propagated. Co-Authored-By: Claude Opus 5 (1M context) --- .../Data/Architecture.md | 1 + .../Data/Architecture/AccessControl.md | 4 +- .../Data/Architecture/PublicReadAndDenies.md | 196 ++++++++++++++++++ .../PackageInstaller.cs | 44 +++- .../PublicReadIsNotSuppressedByADenyTest.cs | 186 +++++++++++++++++ 5 files changed, 425 insertions(+), 6 deletions(-) create mode 100644 src/MeshWeaver.Documentation/Data/Architecture/PublicReadAndDenies.md create mode 100644 test/MeshWeaver.Graph.Test/PublicReadIsNotSuppressedByADenyTest.cs diff --git a/src/MeshWeaver.Documentation/Data/Architecture.md b/src/MeshWeaver.Documentation/Data/Architecture.md index b97d8e9563..e83f6b46a2 100644 --- a/src/MeshWeaver.Documentation/Data/Architecture.md +++ b/src/MeshWeaver.Documentation/Data/Architecture.md @@ -196,6 +196,7 @@ Each theme starts with its introductory page, followed by related architecture t - [Logon Actions](LogonActions) — per-user work at logon, run as the user - [Unanchored Security Reads](UnanchoredSecurityReads) — why the permission fold reads mesh-wide, and why pinning it to the viewer's partition is a silent revocation-fails-open bug - [A Denial Is an Answer](DenialIsAnAnswer) — a check on a hub with no evaluator grants Permission.All, and a refusal the mesh decided is rendered, never raised +- [PublicRead and Denies](PublicReadAndDenies) — a Public/Anonymous deny under a `PublicRead` policy is honoured by the SQL read path and ignored by the C# evaluator; what that split exposes, and what each remedy costs - [Who Owns a Partition's Access Shape](PartitionAccessOwnership) - [Partition Ownership Resolution](PartitionOwnershipResolution) — the four create-path checks that ask whether a NodeType owns its partition, what one resolution costs for a type declared in mesh content, which of them share ONE view and which deliberately keeps its own, and how a nested instance of such a type is refused from the definition's durable row without activating the type's hub - [OWASP ZAP Scan — 3.0.0 (6 September 2026)](SecurityScan_3_0_0) diff --git a/src/MeshWeaver.Documentation/Data/Architecture/AccessControl.md b/src/MeshWeaver.Documentation/Data/Architecture/AccessControl.md index b9138bab10..90505afd22 100644 --- a/src/MeshWeaver.Documentation/Data/Architecture/AccessControl.md +++ b/src/MeshWeaver.Documentation/Data/Architecture/AccessControl.md @@ -1250,7 +1250,9 @@ The predicate above used to carry a third term — `public_read_node_type OR … - **Connecting it would have been a breach.** ~24 node types declared public read, including `Thread`/`ThreadMessage` (private conversations), `Markdown`/`Code`/`Document` (most content), and `Course`/`Module`/`Exercise`/`ExerciseAttempt` (paid course content and learners' own submissions). - **The shape was unsafe regardless of the type list.** Being an unconditional `OR` in front of the node fold, it short-circuited the longest-prefix resolution — i.e. it overrode DENY rows, which is precisely where store/course paywall gating lives. And `PermissionEvaluator` has no node-type-keyed term, so the SQL and evaluator paths would have diverged. -**Declare public read with a mechanism both read paths honour instead:** a `PartitionAccessPolicy` `_Policy` node with `PublicRead = true` (issue #603 — projected as allow-`Read` rows for `Public`/`Anonymous` that *participate in* the prefix fold, so a deeper deny still wins), or a [`NodeTypeGate`](#type-declared-subtree-gates-nodetypegate) (issue #701) for a type that opens a short, explicitly listed set of surfaces on its own subtree. +**Declare public read with a mechanism both read paths honour instead:** a `PartitionAccessPolicy` `_Policy` node with `PublicRead = true` (issue #603 — projected as allow-`Read` rows for `Public`/`Anonymous` that *participate in* the prefix fold, so a deeper read **cap** still wins), or a [`NodeTypeGate`](#type-declared-subtree-gates-nodetypegate) (issue #701) for a type that opens a short, explicitly listed set of surfaces on its own subtree. + +🚨 **A deeper ROLE DENY is a different question, and the two read paths answer it differently.** The parenthetical above says *cap* on purpose: read as "a deeper deny of any kind still wins" it is wrong for the C# evaluator, and that reading is exactly how a submission inbox came to be published under a public partition. The SQL projection resolves one row set by longest prefix, so a `Public`/`Anonymous` deny at a deeper prefix wins there ("the store-gating shape and it is intentional"); `PermissionEvaluator` instead ORs the public grant in separately from the role fold, so the same deny is inert. Measured 2026-09-20 and pinned by `PublicReadIsNotSuppressedByADenyTest`. Full account, and what each remedy costs: [PublicRead and Denies](../PublicReadAndDenies) (MeshWeaver#4716). ### Public policy grants and deeper read caps diff --git a/src/MeshWeaver.Documentation/Data/Architecture/PublicReadAndDenies.md b/src/MeshWeaver.Documentation/Data/Architecture/PublicReadAndDenies.md new file mode 100644 index 0000000000..9fc7e7b999 --- /dev/null +++ b/src/MeshWeaver.Documentation/Data/Architecture/PublicReadAndDenies.md @@ -0,0 +1,196 @@ +--- +Name: PublicRead and Denies +Category: Architecture +Description: A Public/Anonymous deny beats PartitionAccessPolicy.PublicRead on the SQL read path and not in the C# evaluator — a measured two-executor divergence, which is the paywall-bypass shape. What it exposes, why three components believed the deny protected them, and what each candidate remedy costs. +Icon: +--- + +# PublicRead and Denies + +**A `Public`/`Anonymous` Viewer DENY under a `PartitionAccessPolicy` that grants `PublicRead` is +honoured by the PostgreSQL read path and IGNORED by the C# `PermissionEvaluator`. The two read paths +disagree — which is the paywall-bypass shape, and the reason a submission inbox cannot currently be +closed.** + +Three components assert that the deny protects the segment. One of them ships a protection built on +it. On the C# path it protects nothing, and on the SQL path it hides the content from every listing +while leaving it readable by exact path. + +Confidence, stated separately because it differs: + +- **MEASURED** (this repository, a real monolith mesh, 2026-09-20): the C# fold lets both well-known + subjects read a child carrying their own Viewer deny. Pinned by + `PublicReadIsNotSuppressedByADenyTest`. +- **MEASURED** (`memex.meshweaver.cloud`, read-only, 2026-09-20): the `Feedback` partition is + world-readable including its submission inbox, and carries no deny at all. +- **READ, NOT EXECUTED**: the PostgreSQL projection's own comment states the opposite behaviour. No + Postgres portal was exercised for this page, so the divergence is inferred from that comment plus + the measured C# half. **Verifying it against a live Postgres portal is the first step of any fix.** + +## The one line of the fold that decides it + +`PermissionEvaluator.FoldScopes` walks the scope hierarchy of the path being evaluated. Two +independent accumulators come out of that walk: + +- `roleIds` — the roles the subject holds, unioned per scope, and a **deny subtracts from this one** + (`roleIds = roleIds.Except(deniedRoles)`). +- `publicGrant` — the policy-driven public grant, ORed in per scope + (`if (policy.PublicRead) publicGrant |= Permission.Read`). + +The result is the role-derived permission (capped) **OR** `publicGrant`. So: + +> A deny removes a ROLE. `PublicRead` is not a role. There is nothing for the deny to take away. + +The only thing that touches `publicGrant` is a permission CAP from a policy deeper on the chain +(`publicGrant &= scopeCap`) — i.e. a `PartitionAccessPolicy { Read = false }`. + +### …and the one line of SQL that decides it differently + +`PostgreSqlSchemaInitializer` (MeshWeaver.Plugins) projects everything into one row set, +`user_effective_permissions(user_id, node_path_prefix, permission, is_allow)`, resolved **per subject +by longest prefix**. A `PublicRead` policy becomes allow-`Read` rows at the policy's namespace for +`Public` and `Anonymous`; a Viewer deny becomes deny rows at the assignment's scope. Its own comment +spells out the consequence: + +> Runs AFTER the policy-cap deny fold with `DO UPDATE is_allow = true` so at the **SAME** prefix the +> public grant wins — matching the live override order. (A deny at a **LONGER** prefix still wins the +> per-subject longest-prefix query fold; that is the store-gating shape and it is intentional.) + +So the intended rule is *"a deeper deny beats an inherited public grant"*, the SQL path implements +it, and the C# path does not. Neither is a rounding error: the store/course paywall gating rests on +exactly this shape. + +## Why the belief looked true + +Because it **is** true of the other public shape, and the two are easy to conflate. The platform +opens a partition to everyone in two different ways: + +| Shape | How the read is granted | Does a child deny close it, in the C# fold? | +|---|---|---| +| **Fully-public** — `{partition}/_Policy` with `PublicRead = true` (`PackageInstaller.EnsurePartitionPublicRead`, `PluginGate.OpenPolicy`) | a POLICY grant | **No.** The deny is inert. | +| **Scoped** — `Public`+`Anonymous` Viewer GRANTS at the partition root (`PackageInstaller.EnsureScopedPublicRead`, the Store's `CatalogGate`) | a ROLE grant, inherited downward | **Yes.** The deny removes the role. | + +The rule was written from the second row and applied to the first, where the C# fold does not honour +it. Both rows are pinned by `PublicReadIsNotSuppressedByADenyTest`, deliberately in one file, so the +difference is visible rather than something the next reader has to infer. + +## The workaround available today is a blackout, not a gate + +A deeper `PartitionAccessPolicy { Read = false }` does suppress the inherited public grant in the C# +fold. But +`GetPermissionCap()` produces one mask that is ANDed into **every** subject's permissions, admins +included — the record says so in its own summary ("Caps effective permissions at a namespace scope +for ALL users (including Admins)"). Core's own `PublicReadPolicyScopeTest` already pins the cost: +`RolePolicy/Capped/Page` denies Read to a viewer holding a real Viewer grant one scope up. + +So `Read = false` cannot express *"closed to the public, still readable by the people who triage +it"*. The only identity it does not bind is `System`, which short-circuits the fold entirely. + +**So in the C# fold there is no way to withhold an inherited public grant without capping roles** — +while the SQL fold does exactly that, for the two well-known subjects only, which is precisely the +behaviour wanted. The divergence is the defect; everything below is a consequence of it. + +## The case that found it + +`Systemorph/MeshWeaver#4716`. The `Feedback` package's manifest declares both halves of the +intent explicitly: + +```json +"preInstalled": true, +"protectedSegments": ["_Submissions"] +``` + +`Feedback/_Submissions/{id}` is where the `/feedback` flow files every submission, written as +System so a low-privilege user can submit. What was measured, read-only, on 2026-09-20: + +| Read | Result | +|---|---| +| `Feedback/_Policy` | `PartitionAccessPolicy { PublicRead: true }`, `lastModifiedBy: system-security` | +| `Feedback/_Submissions/demo-preview` | exists, `nodeType: Feedback/Feedback` (a demo item — no real user data today) | +| `Feedback/_Submissions/_Access/Public_Access` | **Not found** — no deny exists | +| `Feedback/_Access/Public_Access` | `Public — Viewer` — reads back fine, so the absence above is the node's and not a filtered read | + +Note `search` did not return `Feedback/_Submissions/*` while `get` returned the node — the known +"search can miss what get returns" asymmetry. The denominator came from `get`. + +Three separate components assert the rule that only one of the two read paths implements, and the +third one acts on it: + +1. `PackageInstaller.EnsurePartitionPublicRead`'s remarks stated it as a general rule, with no + mention that one read path ignores it. **Corrected** by the change that added this page. +2. #4716's triage comment cited those remarks to conclude that a per-path deny *"is not + hypothetical"*. +3. The Store's `PluginGate` pre-installed arm (in-mesh source in `MeshWeaver.Plugins`, so invisible + to any `dotnet build` or `grep --include='*.cs'` over this repository) writes exactly that deny + pair for every declared `ProtectedSegments` entry, under a comment naming this very inbox. On the + C# path **it is a protection that cannot protect**; on the SQL path it produces the split-visibility + shape. It is also not currently present on the live partition, so the exposure persists until it + lands — a second and independent reason. + +Core additionally never reads `protectedSegments` at all — `NodeRepoPackageSource.Peek` does not +carry it onto `PackageManifest` — so the installer could not honour the declaration even if a +mechanism existed. That is the same dead-metadata class `preInstalled`, `publicSegments`, +`contactEmail` and `tier` each were, except this one fails OPEN. + +## What each candidate remedy costs + +Nothing here is free, and none of it is a core-only change. This is the part that needs a decision, +not a patch. + +**1. Reconcile the C# fold with the SQL one — make a deeper well-known deny withhold the inherited +public grant.** This is the fix that matches the intent, needs no new concept, no manifest change and +no data migration, and is **core-only**, because the SQL side already behaves this way. The segment +becomes gated and a reviewer's own grant still reads it, since a deny touches only `Public` and +`Anonymous`. + +What it takes, concretely: `publicGrant` must stop being an OR-accumulator and become a +longest-prefix (last-writer-wins) chain over the well-known subjects' rows — allow where a +`PublicRead` policy or a Public/Anonymous grant sits, deny where a Public/Anonymous deny sits, caps +still ANDed in. 🚨 The state for that is not currently reaching the fold: `ComputeScopeRoles` filters +assignments to the EVALUATED subject, so a Public/Anonymous deny is absent from what +`ComputeRoleState` receives whenever the viewer is somebody else. So the change is not one line — +it widens the snapshot the long-lived fold consumes. + +🚨 **Do this one with the evidence in front of you, and verify the Postgres half first.** This fold +has an incident history on exactly these lines: #974 (an empty seed fails OPEN), the 2026-08-05 +paywall bypass (claim roles folded in here), and a 2026-09-11 correction whose four failures were all +"reads through a deeper read cap". It also changes permission outcomes for every subject on every +path, so it wants its own test matrix and a `MeshWeaver.Security.Test` run on the Plugins side. + +**2. Retract `publicRead` on the partition and open it with root grants instead.** Uses only shipped +machinery, makes the denies work on both paths, and needs no evaluator change. Two costs. First, `PublicSurfaceCarriesApi` keys on the POLICY's +public grant, not on an `AccessAssignment` — it is what "keeps MCP tokens working on `Doc/`, `Agent/` +and every installed package partition, which `PackageInstaller` makes readable through exactly this +policy" — so an API client holding no role loses the partition's public half. Second, **it +ping-pongs**: `PluginGate.OpenPolicy` rewrites `{ publicRead: true }` on every reconcile for a +pre-installed partition, and core's boot pass re-asserts it too. `EnsurePartitionPublicRead`'s +remarks record what that fight already cost once — a CD seal, over 26 denies retired and re-written +between two components that each believed they owned the shape. Whoever takes this option changes +**one** writer, not two. + +**3. Move the inbox out of the public partition** (#4716's own first option). Submissions go to a +partition with no public grant; the public catalog half stays public. Cleanest in the access model +and needs no new mechanism — but it is a change to where the Feedback plugin WRITES, plus a +migration of what is already filed, and it lives in `MeshWeaver.Plugins`. + +**4. Decide the submissions were always meant to be public, and record that decision on the node.** +Still a legitimate answer, and the only one no amount of code reading can choose. + +## What this page does not settle + +Which of the four, and two things it deliberately did not establish: + +- **The Postgres half was not executed.** Its behaviour here is its own comment, not a reading. Every + remedy above assumes the SQL path denies a deeper-prefix deny under a public grant; confirm that on + a live Postgres portal before building on it. +- **Whether any partition other than `Feedback` is exposed this way.** The sweep that would answer it + is every `PartitionAccessPolicy` with `publicRead: true` whose package manifest declares + `protectedSegments` — not run, because it reads across partitions on a live public instance. + +## See also + +- [Access Control](../AccessControl) — the permission model and the Admin partition +- [Query Provider Parity](../QueryProviderParity) — why a semantic honoured by one fold and not the + other is a security defect, not an inconsistency +- `PublicReadIsNotSuppressedByADenyTest` (`test/MeshWeaver.Graph.Test/`) — the three shapes, executable +- `PublicReadPolicyScopeTest` (`test/MeshWeaver.Graph.Test/`) — scope precedence, incl. the cap's cost diff --git a/src/MeshWeaver.PluginCatalog/PackageInstaller.cs b/src/MeshWeaver.PluginCatalog/PackageInstaller.cs index d9d8f425e1..cab11d807c 100644 --- a/src/MeshWeaver.PluginCatalog/PackageInstaller.cs +++ b/src/MeshWeaver.PluginCatalog/PackageInstaller.cs @@ -1067,11 +1067,33 @@ public static IObservable EnsureDeclaredAccess( /// is "no current code produces this", the survey has to cover the MESH, not the compiler's /// view of one repository. /// - /// Order matters: denies first, policy last. The denies are what actually hide the - /// content (an explicit deny beats PublicRead), and the policy node is the marker that - /// says "this partition is already in the declared shape". Writing the marker first would strand - /// a half-swept partition permanently, so the sweep runs BEFORE the policy write and a failure + /// Order matters: denies first, policy last. While the policy still withholds public + /// read the denies are what hides the content, and the policy node is the marker that says "this + /// partition is already in the declared shape". Writing the marker first would strand a + /// half-swept partition permanently, so the sweep runs BEFORE the policy write and a failure /// simply leaves the old policy in place for the next boot to retry. + /// + /// 🚨 This paragraph used to add "an explicit deny beats PublicRead" as a general + /// rule. It is the INTENT, and the C# read path does not implement it (MeshWeaver#4716). The + /// Postgres projection does: it emits the policy as allow-Read rows at this prefix and + /// records that "a deny at a LONGER prefix still wins the per-subject longest-prefix query fold; + /// that is the store-gating shape and it is intentional". But + /// PermissionEvaluator.ComputeRoleState subtracts denied roles from roleIds and ORs + /// the public grant in SEPARATELY and afterwards — a deny removes a ROLE, PublicRead is not + /// a role, so under this policy every Public/Anonymous deny is INERT on that path. Measured on a + /// monolith mesh and pinned by PublicReadIsNotSuppressedByADenyTest; the only thing that + /// does withhold it there is a deeper Read = false cap, which is ANDed into every + /// role-derived permission too — a blackout, not a gate. + /// + /// 🚨 So the two read paths disagree, which is the paywall-bypass shape (this + /// evaluator carries its own account of the last one: 79,650 characters of paid course content + /// served by exact path while search correctly denied it). A partition gated this way is + /// hidden from every listing and readable by exact path. It is load-bearing here: #4716's triage + /// cited this very sentence to conclude a per-path deny would protect a submission inbox, and the + /// Store's PluginGate pre-installed arm implements exactly that for a manifest's + /// ProtectedSegments — so the protection it applies is, on this path, none. Full + /// measurement and what each candidate remedy costs: + /// Doc/Architecture/PublicReadAndDenies. /// private static IObservable EnsurePartitionPublicRead( IMessageHub hub, PackageManifest manifest, string partition, ILogger? logger) @@ -1280,7 +1302,19 @@ private static bool IsWellKnownDeny(MeshNode node, IMessageHub hub) => /// The child walk follows the PluginGate conventions — underscore satellites are /// never gated here (their protection is the plugin machinery's ProtectedSegments /// concern) and the well-known Public segment is always public — so the installer's - /// shape and the Store's reconcile converge on the same nodes instead of fighting. Segments + /// shape and the Store's reconcile converge on the same nodes instead of fighting. + /// + /// 🚨 That delegation is sound for THIS shape and a real gap for the other one + /// (MeshWeaver#4716). The ProtectedSegments machinery it hands off to writes + /// Public/Anonymous DENIES, which work here — the read is a root role GRANT, and a deny removes a + /// role — and which the C# read path does NOT honour under the fully-public shape's + /// PublicRead policy, though the SQL path does; see the remarks on + /// . A pre-installed partition takes that other shape, so a + /// satellite it declares protected is published to that path anyway and no component reports it. + /// Do not "fix" it by gating satellites from the _ prefix: a package is entitled to publish + /// one, and the declaration is what distinguishes them. + /// + /// Segments /// come from the paths this install wrote UNIONED with the partition's current children (read /// as System); every node is create-only and the writes run SEQUENTIALLY (the access table /// deadlocks under parallel writers, 40P01). diff --git a/test/MeshWeaver.Graph.Test/PublicReadIsNotSuppressedByADenyTest.cs b/test/MeshWeaver.Graph.Test/PublicReadIsNotSuppressedByADenyTest.cs new file mode 100644 index 0000000000..27a628aa52 --- /dev/null +++ b/test/MeshWeaver.Graph.Test/PublicReadIsNotSuppressedByADenyTest.cs @@ -0,0 +1,186 @@ +using System.Reactive.Linq; +using MeshWeaver.Fixture; +using MeshWeaver.Hosting.Monolith.TestBase; +using MeshWeaver.Mesh; +using MeshWeaver.Mesh.Security; +using MeshWeaver.Mesh.Services; +using MeshWeaver.Messaging; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace MeshWeaver.Graph.Test; + +/// +/// 🚨 In THIS fold, a Public/Anonymous DENY does not suppress an inherited +/// PartitionAccessPolicy.PublicRead — and the SQL fold's own comment says it does. A +/// two-executor divergence on the read path, measured (Systemorph/MeshWeaver#4716). +/// +/// What this file pins, and what it does not. Every assertion below is a MEASUREMENT of +/// the C# PermissionEvaluator against a real monolith mesh. The Postgres half is NOT executed +/// here; what is known about it is its projection's own comment in +/// MeshWeaver.Hosting.PostgreSql/PostgreSqlSchemaInitializer.cs (MeshWeaver.Plugins), which +/// projects a PublicRead policy as allow-Read rows at the policy's prefix and then +/// states: "A deny at a LONGER prefix still wins the per-subject longest-prefix query fold; that +/// is the store-gating shape and it is intentional." So the intended behaviour is the OPPOSITE of +/// what this file measures, and the divergence is the defect — not the behaviour recorded here. +/// 🚨 So do not "fix" a red in this file by relaxing an assertion. When the folds are +/// reconciled, the first theory below flips to BeFalse and this summary is what says why. +/// +/// Why it matters: the two shapes of a read path disagreeing IS the paywall bypass. The +/// evaluator carries its own account of the last time (2026-08-05): a get by exact path served +/// 79,650 characters of paid course content to an unentitled caller while search, over the SQL +/// fold, correctly denied the same node. A Public/Anonymous deny written under a PublicRead +/// partition reproduces that shape exactly — hidden from every listing, readable by exact path. +/// +/// Three components assert the behaviour this file falsifies for the C# fold. +/// PackageInstaller.EnsurePartitionPublicRead's remarks stated it as fact; #4716's triage cited +/// those remarks to conclude a per-path deny on a submission inbox "is not hypothetical"; and +/// the Store's PluginGate pre-installed arm (in-mesh source in MeshWeaver.Plugins, invisible to +/// any build or grep over this repository) IMPLEMENTS it — for every segment a manifest declares in +/// ProtectedSegments it writes exactly this deny pair, under a comment saying that without it +/// "an open partition would publish a satellite holding user-submitted data (Feedback's +/// _Submissions inbox)". +/// +/// Where the divergence lives, in one line. ComputeRoleState subtracts denied roles +/// from roleIds, and ORs the public grant in SEPARATELY and afterwards +/// (publicGrant |= Permission.Read), accumulating it down the chain. A deny removes a ROLE, and +/// PublicRead is not a role, so there is nothing for it to take away. SQL instead resolves one +/// row set by LONGEST PREFIX, where a deeper deny row simply wins. Closing the gap means folding the +/// public chain last-writer-wins over the well-known subjects' rows, which this fold cannot do today: +/// ComputeScopeRoles filters assignments to the EVALUATED subject, so the Public/Anonymous deny +/// at a deeper scope is not even in the state ComputeRoleState receives. +/// +/// Full measurement, and what each candidate remedy costs: +/// Doc/Architecture/PublicReadAndDenies. +/// +public class PublicReadIsNotSuppressedByADenyTest(ITestOutputHelper output) : MonolithMeshTestBase(output) +{ + /// A real signed-in subject holding Viewer at a root — the "reviewer" of the third theory. + private const string Reviewer = "deny-vs-publicread-reviewer"; + + // ConfigureMeshBase, never ConfigureMesh: the default test configuration grants Public Admin, + // which would make every reading below Read regardless of the model. + /// + protected override MeshBuilder ConfigureMesh(MeshBuilder builder) + => ConfigureMeshBase(builder).AddMeshNodes( + // ── Shape 1: public read from the POLICY, plus a child deny ────────────────── + new MeshNode("PolicyOpen") { NodeType = "Markdown" }, + AssignmentNodeFactory.Policy("PolicyOpen", new PartitionAccessPolicy { PublicRead = true }), + new MeshNode("Cover", "PolicyOpen") { NodeType = "Markdown" }, + new MeshNode("Gated", "PolicyOpen") { NodeType = "Markdown" }, + AssignmentNodeFactory.UserRole(WellKnownUsers.Public, "Viewer", "PolicyOpen/Gated", denied: true), + AssignmentNodeFactory.UserRole(WellKnownUsers.Anonymous, "Viewer", "PolicyOpen/Gated", denied: true), + new MeshNode("Page", "PolicyOpen/Gated") { NodeType = "Markdown" }, + + // ── Shape 2: public read from root GRANTS, plus the same child deny ────────── + new MeshNode("GrantOpen") { NodeType = "Markdown" }, + AssignmentNodeFactory.UserRole(WellKnownUsers.Public, "Viewer", "GrantOpen"), + AssignmentNodeFactory.UserRole(WellKnownUsers.Anonymous, "Viewer", "GrantOpen"), + new MeshNode("Cover", "GrantOpen") { NodeType = "Markdown" }, + new MeshNode("Gated", "GrantOpen") { NodeType = "Markdown" }, + AssignmentNodeFactory.UserRole(WellKnownUsers.Public, "Viewer", "GrantOpen/Gated", denied: true), + AssignmentNodeFactory.UserRole(WellKnownUsers.Anonymous, "Viewer", "GrantOpen/Gated", denied: true), + new MeshNode("Page", "GrantOpen/Gated") { NodeType = "Markdown" }, + + // ── Shape 3: the only thing that suppresses the policy grant — and what it costs ── + new MeshNode("CapOpen") { NodeType = "Markdown" }, + AssignmentNodeFactory.Policy("CapOpen", new PartitionAccessPolicy { PublicRead = true }), + AssignmentNodeFactory.UserRole(Reviewer, "Viewer", "CapOpen"), + new MeshNode("Gated", "CapOpen") { NodeType = "Markdown" }, + AssignmentNodeFactory.Policy("CapOpen/Gated", new PartitionAccessPolicy { Read = false }), + new MeshNode("Page", "CapOpen/Gated") { NodeType = "Markdown" }); + + /// + /// The effective permission a SUBJECT holds on a path. Asserts the FIRST decision, so an initial + /// leak cannot hide behind a later matching answer. + /// + private Task Effective(string path, string subject, CancellationToken cancellationToken) + { + var access = Mesh.ServiceProvider.GetRequiredService(); + var context = new AccessContext { ObjectId = subject, Name = subject }; + access.SetContext(context); + access.SetHostIdentity(context); + return Mesh.GetEffectivePermissions(path, subject) + .FirstAsync().Timeout(TestTimeouts.Quick).Await(cancellationToken); + } + + private async Task CanRead(string path, string subject, CancellationToken cancellationToken) + { + var permission = await Effective(path, subject, cancellationToken); + Output.WriteLine($"{subject} on {path}: {permission}"); + return permission.HasFlag(Permission.Read); + } + + /// + /// 🚨 THE FALSIFICATION. Both well-known subjects still READ a child carrying their own + /// Viewer DENY, because the partition's policy grants public read and the fold ORs that in after + /// the deny subtraction. Every deny written to protect a segment under such a policy — including + /// every one the Store's gate writes from a manifest's ProtectedSegments — is inert. + /// + [Theory(Timeout = 60_000)] + [InlineData(WellKnownUsers.Anonymous)] + [InlineData(WellKnownUsers.Public)] + public async Task ADenyUnderAPublicReadPolicy_DoesNotWithholdRead(string subject) + { + var cancellationToken = TestContext.Current.CancellationToken; + + (await CanRead("PolicyOpen/Gated/Page", subject, cancellationToken)).Should().BeTrue( + "a deny removes a ROLE, and PartitionAccessPolicy.PublicRead is not a role — this fold ORs " + + "the public grant in after the deny subtraction, so there is nothing for the deny to take " + + "away. 🚨 This is the MEASURED behaviour and NOT the intended one: the SQL projection's " + + "own comment says a deny at a longer prefix wins, 'the store-gating shape and it is " + + "intentional'. So the two read paths disagree, which is the paywall-bypass shape — hidden " + + "from every listing, readable by exact path — and every deny written to protect a segment " + + "under such a policy is inert on THIS path (#4716). When the folds are reconciled this " + + "flips to BeFalse: change it deliberately, do not relax it to keep a run green"); + (await CanRead("PolicyOpen/Cover", subject, cancellationToken)).Should().BeTrue( + "the control: the policy really is granting public read here, so the reading above is about " + + "the deny and not about a partition nobody could read in the first place"); + } + + /// + /// The second theory, and the reason the false rule looked true: where the public read comes from + /// root Viewer GRANTS instead of a policy, the SAME deny pair works — readability is a role, and a + /// deny removes it. This is the shape the rule was written from. + /// + [Theory(Timeout = 60_000)] + [InlineData(WellKnownUsers.Anonymous)] + [InlineData(WellKnownUsers.Public)] + public async Task ADenyUnderRootGrants_DoesWithholdRead(string subject) + { + var cancellationToken = TestContext.Current.CancellationToken; + + (await CanRead("GrantOpen/Cover", subject, cancellationToken)).Should().BeTrue( + "root Viewer grants inherit strictly downward — the cover and every ungated child are " + + "readable by everyone, which is what makes this a public partition at all"); + (await CanRead("GrantOpen/Gated/Page", subject, cancellationToken)).Should().BeFalse( + "here the deny DOES hide the content: the read came from a role, so removing the role " + + "removes the read. Identical denies, opposite outcomes — the difference is whether the " + + "partition was opened with a policy or with grants, and that is the distinction the " + + "'a deny beats PublicRead' rule collapsed"); + } + + /// + /// The third theory: the one mechanism that suppresses an inherited public grant in THIS fold caps + /// every subject with it. So it cannot gate a segment somebody has to read — it darkens it for them + /// too, which is why "just cap the child" is not the remedy for the divergence above. + /// + [Fact(Timeout = 60_000)] + public async Task ADeeperReadCap_SuppressesThePublicGrantAndTheReviewerWithIt() + { + var cancellationToken = TestContext.Current.CancellationToken; + + (await CanRead("CapOpen/Gated/Page", WellKnownUsers.Anonymous, cancellationToken)).Should().BeFalse( + "a deeper Read=false cap is ANDed into the public grant (publicGrant &= scopeCap), so it is " + + "the only thing in THIS fold that withholds an inherited PublicRead"); + (await CanRead("CapOpen/Gated/Page", Reviewer, cancellationToken)).Should().BeFalse( + "…and the SAME cap is ANDed into every role-derived permission, so the Viewer holding a " + + "real grant at the partition root loses Read as well. That is a BLACKOUT, not a gate: it " + + "cannot express 'closed to the public, open to the people who triage it', which is what a " + + "submission inbox under a public partition needs (#4716)"); + (await CanRead("CapOpen/Gated/Page", WellKnownUsers.System, cancellationToken)).Should().BeTrue( + "the one identity the cap does not bind is System, which short-circuits the whole fold. A " + + "view that reads as the VIEWER — as the Feedback inbox does — therefore goes dark, so " + + "'just cap it' is not available as the fix"); + } +} From 617b5d16c282279979fb39a7df9b2e3c554a9cb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= <6334612+rbuergi@users.noreply.github.com> Date: Sun, 20 Sep 2026 11:47:10 +0200 Subject: [PATCH 2/2] review(#4969): the SQL half is a CLAIM from a source comment, not a measurement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both Copilot findings are the same defect and they are right: the lead and the installer remark asserted the PostgreSQL behaviour as fact while the confidence section of the same page says that path was never executed. On a security contract that is how a later fix comes to trust an unvalidated half — and it is the same "a stated convention is not a measured population" trap #4716 is itself an instance of. * The doc lead now states the C# half as measured and marks the SQL half 🚨 a CLAIM sourced from the projection's comment, with "hidden from every listing, readable by exact path" named as the shape to go and CONFIRM. * The "one line of SQL" section repeats the marker, names the failure mode, and says to verify on a live Postgres portal before building a remedy on it — which matters because remedy 1 is justified only if SQL really denies. * `PackageInstaller`'s remark — the place the previous false rule was READ FROM, so the one that must not over-claim — now separates the measured C# half from the projection's CLAIM, and keeps the conclusion that does not depend on it: the deny `PluginGate` writes for `ProtectedSegments` protects nothing there. * The test header no longer calls the divergence itself measured. Comment-only in `src/`; 17/17 doc guards green. Co-Authored-By: Claude Opus 5 (1M context) --- .../Data/Architecture/PublicReadAndDenies.md | 25 +++++++----- .../PackageInstaller.cs | 38 ++++++++++--------- .../PublicReadIsNotSuppressedByADenyTest.cs | 5 ++- 3 files changed, 40 insertions(+), 28 deletions(-) diff --git a/src/MeshWeaver.Documentation/Data/Architecture/PublicReadAndDenies.md b/src/MeshWeaver.Documentation/Data/Architecture/PublicReadAndDenies.md index 9fc7e7b999..9033f95492 100644 --- a/src/MeshWeaver.Documentation/Data/Architecture/PublicReadAndDenies.md +++ b/src/MeshWeaver.Documentation/Data/Architecture/PublicReadAndDenies.md @@ -8,13 +8,16 @@ Icon: /// /// 🚨 This paragraph used to add "an explicit deny beats PublicRead" as a general - /// rule. It is the INTENT, and the C# read path does not implement it (MeshWeaver#4716). The - /// Postgres projection does: it emits the policy as allow-Read rows at this prefix and - /// records that "a deny at a LONGER prefix still wins the per-subject longest-prefix query fold; - /// that is the store-gating shape and it is intentional". But + /// rule, and on the C# read path that is FALSE — measured (MeshWeaver#4716). /// PermissionEvaluator.ComputeRoleState subtracts denied roles from roleIds and ORs /// the public grant in SEPARATELY and afterwards — a deny removes a ROLE, PublicRead is not - /// a role, so under this policy every Public/Anonymous deny is INERT on that path. Measured on a - /// monolith mesh and pinned by PublicReadIsNotSuppressedByADenyTest; the only thing that - /// does withhold it there is a deeper Read = false cap, which is ANDed into every - /// role-derived permission too — a blackout, not a gate. + /// a role, so once the policy below is written every Public/Anonymous deny under it is INERT + /// there. Measured on a monolith mesh and pinned by + /// PublicReadIsNotSuppressedByADenyTest; the only thing that does withhold it on that path + /// is a deeper Read = false cap, which is ANDed into every role-derived permission too — a + /// blackout, not a gate. /// - /// 🚨 So the two read paths disagree, which is the paywall-bypass shape (this - /// evaluator carries its own account of the last one: 79,650 characters of paid course content - /// served by exact path while search correctly denied it). A partition gated this way is - /// hidden from every listing and readable by exact path. It is load-bearing here: #4716's triage - /// cited this very sentence to conclude a per-path deny would protect a submission inbox, and the - /// Store's PluginGate pre-installed arm implements exactly that for a manifest's - /// ProtectedSegments — so the protection it applies is, on this path, none. Full - /// measurement and what each candidate remedy costs: - /// Doc/Architecture/PublicReadAndDenies. + /// 🚨 The rule is still the stated INTENT, and the Postgres projection CLAIMS to + /// implement it — it emits this policy as allow-Read rows at this prefix and records + /// that "a deny at a LONGER prefix still wins the per-subject longest-prefix query fold; that is + /// the store-gating shape and it is intentional". 🚨 That is its author's comment, NOT a test + /// result: no Postgres path was executed for #4716, so "the two read paths disagree" is the thing + /// to go and confirm, not an established contract — and confirming it is the first step of any fix, + /// because a remedy built on an unverified half is how this defect arose. If it holds, the split is + /// the paywall-bypass shape this evaluator carries its own account of (79,650 characters of paid + /// course content served by exact path while search correctly denied it). + /// + /// Either way the sentence was load-bearing HERE: #4716's triage cited it to conclude a + /// per-path deny would protect a submission inbox, and the Store's PluginGate pre-installed + /// arm implements exactly that for a manifest's ProtectedSegments — so on this path the + /// protection it applies is none. Full measurement, the confidence on each half, and what each + /// candidate remedy costs: Doc/Architecture/PublicReadAndDenies. /// private static IObservable EnsurePartitionPublicRead( IMessageHub hub, PackageManifest manifest, string partition, ILogger? logger) diff --git a/test/MeshWeaver.Graph.Test/PublicReadIsNotSuppressedByADenyTest.cs b/test/MeshWeaver.Graph.Test/PublicReadIsNotSuppressedByADenyTest.cs index 27a628aa52..e28634e847 100644 --- a/test/MeshWeaver.Graph.Test/PublicReadIsNotSuppressedByADenyTest.cs +++ b/test/MeshWeaver.Graph.Test/PublicReadIsNotSuppressedByADenyTest.cs @@ -12,8 +12,9 @@ namespace MeshWeaver.Graph.Test; /// /// 🚨 In THIS fold, a Public/Anonymous DENY does not suppress an inherited -/// PartitionAccessPolicy.PublicRead — and the SQL fold's own comment says it does. A -/// two-executor divergence on the read path, measured (Systemorph/MeshWeaver#4716). +/// PartitionAccessPolicy.PublicRead — and the SQL fold's own comment says it does. The C# +/// half is measured here; the divergence that follows from it is a CLAIM about a path this file does +/// not execute (Systemorph/MeshWeaver#4716). /// /// What this file pins, and what it does not. Every assertion below is a MEASUREMENT of /// the C# PermissionEvaluator against a real monolith mesh. The Postgres half is NOT executed