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..9033f95492 --- /dev/null +++ b/src/MeshWeaver.Documentation/Data/Architecture/PublicReadAndDenies.md @@ -0,0 +1,203 @@ +--- +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 +IGNORED by the C# `PermissionEvaluator` — measured. The PostgreSQL projection's own comment says its +path honours the same deny; if that holds, the two read paths disagree, which is the paywall-bypass +shape. Either way the submission inbox that prompted this is not closed by the deny.** + +Three components assert that the deny protects the segment, and one of them ships a protection built +on it. On the C# path it protects nothing. 🚨 **The SQL half of that sentence is a CLAIM, not a +reading** — it comes from the projection's source comment and no Postgres portal was exercised for +this page. Treat "hidden from every listing, readable by exact path" as the shape to go and confirm, +never as an established contract: a remedy built on an unverified half is how this defect was created +in the first place. + +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 comment says the SQL +path implements it, and the C# path measurably does not. 🚨 That comment is the author's statement of +intent, not a test result, and this page did not execute it — **the same "a stated convention is not a +measured population" trap this whole issue is an instance of.** Verify it against a live Postgres +portal before treating the divergence as established, and before building any remedy on it. What is +not in doubt either way: the paywall gating rests on this shape, so whichever path is wrong, being +wrong about it is expensive. + +## 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..299e17f8da 100644 --- a/src/MeshWeaver.PluginCatalog/PackageInstaller.cs +++ b/src/MeshWeaver.PluginCatalog/PackageInstaller.cs @@ -1067,11 +1067,37 @@ 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, 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 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. + /// + /// 🚨 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) @@ -1280,7 +1306,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..e28634e847 --- /dev/null +++ b/test/MeshWeaver.Graph.Test/PublicReadIsNotSuppressedByADenyTest.cs @@ -0,0 +1,187 @@ +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. 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 +/// 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"); + } +}