From 0a8b47ba493ea94fc939d54fe81fff21aaa8d5d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Wed, 5 Aug 2026 22:34:20 +0200 Subject: [PATCH 1/3] =?UTF-8?q?Claim=20roles=20are=20a=20platform=20capabi?= =?UTF-8?q?lity,=20never=20node=20data=20access=20=E2=80=94=20closes=20the?= =?UTF-8?q?=20paywall?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../MonolithMeshTestBase.cs | 23 +- .../TestUsers.cs | 29 ++ .../Security/PermissionEvaluator.cs | 50 +++- .../NodeTypeReleaseGateTest.cs | 4 + .../AnonymousSpaceReadSecurityTests.cs | 6 +- .../BootTypeDefEchoTests.cs | 3 + .../EffectivePermissionPostgresTest.cs | 6 +- .../PaywallRealGateShapeTests.cs | 262 ++++++++++++++++++ .../PgOnlyProdShapeTests.cs | 6 +- .../SpaceOnboardingIntegrationTests.cs | 6 +- .../StaticRepoImporterTests.cs | 6 +- 11 files changed, 379 insertions(+), 22 deletions(-) create mode 100644 test/MeshWeaver.Hosting.PostgreSql.Test/PaywallRealGateShapeTests.cs diff --git a/src/MeshWeaver.Hosting.Monolith.TestBase/MonolithMeshTestBase.cs b/src/MeshWeaver.Hosting.Monolith.TestBase/MonolithMeshTestBase.cs index df1ba305f5..b149b7f6b2 100644 --- a/src/MeshWeaver.Hosting.Monolith.TestBase/MonolithMeshTestBase.cs +++ b/src/MeshWeaver.Hosting.Monolith.TestBase/MonolithMeshTestBase.cs @@ -162,7 +162,14 @@ protected MeshBuilder ConfigureMeshBase(MeshBuilder builder) /// protected virtual MeshBuilder ConfigureMesh(MeshBuilder builder) => ConfigureMeshBase(builder) - .AddMeshNodes(TestUsers.PublicAdminAccess()); + .AddMeshNodes(TestUsers.PublicAdminAccess()) + // Real STATIC root Admin grants for the DevLogin identities. Claim roles no longer + // grant node permissions (the paywall fix — PermissionEvaluator), so the login + // context's Roles=["Admin"] is a platform capability, not data access. The static + // grant keeps the evaluator's synchronous fast path alive for the harness user — + // without it, permission resolution waits on the synced access queries, which tests + // that deliberately stall synced queries (CompileSourceSnapshotWedgeTest) would wedge. + .AddMeshNodes(TestUsers.DevLoginAdminAccess()); /// /// Initializes the test base, wiring xUnit output and building (or reusing, in shared-mesh mode) @@ -756,7 +763,19 @@ public override async ValueTask InitializeAsync() } TestPhaseTrace(name, "INIT_HOSTED_SERVICES_STARTED", sw.ElapsedMilliseconds); - await SetupAccessRightsAsync(); + // Access-rights provisioning is a SYSTEM act, in tests exactly as in production + // (PluginGate / SystemInstall write grants under the System identity). It cannot run + // under the DevLogin circuit: claim roles no longer grant node permissions (the + // paywall fix — see PermissionEvaluator/PaywallRealGateShapeTests), so creating the + // very first `_Access` grant would require the permission that grant confers. + { + var accessService = Mesh.ServiceProvider.GetService(); + using (accessService?.ImpersonateAsSystem() + ?? System.Reactive.Disposables.Disposable.Empty) + { + await SetupAccessRightsAsync(); + } + } TestPhaseTrace(name, "INIT_DONE", sw.ElapsedMilliseconds); TestMemTrace(name, "INIT_MEM", forceGc: false); diff --git a/src/MeshWeaver.Hosting.Monolith.TestBase/TestUsers.cs b/src/MeshWeaver.Hosting.Monolith.TestBase/TestUsers.cs index ad974a3fb3..97ec1749fb 100644 --- a/src/MeshWeaver.Hosting.Monolith.TestBase/TestUsers.cs +++ b/src/MeshWeaver.Hosting.Monolith.TestBase/TestUsers.cs @@ -106,6 +106,35 @@ private static MeshNode CreateAccessNode(string ns, AccessAssignment assignment) MainNode = ns.Length > 0 ? ns : "", }; + /// + /// REAL root-scope Admin grants for the DevLogin identities ( / + /// ) — static AccessAssignment nodes, the same thing a dev mesh's + /// login has. Suites that hand-roll ConfigureMesh (and so skip + /// ) chain this in when their SETUP writes/reads run under + /// the DevLogin circuit. + /// + /// 🚨 This exists because claim roles no longer grant node permissions + /// (PermissionEvaluator — the paywall bypass: an API token attached DB roles as claims and + /// the evaluator folded them into every node's permissions, undeniably; see + /// PaywallRealGateShapeTests). Tests used to free-ride on Roles = ["Admin"]; access + /// now comes from assignment NODES, in tests exactly as in production. Deliberately grants + /// the two DevLogin users ONLY — Public, Anonymous and per-test viewer identities keep + /// whatever the suite seeds, so security assertions stay meaningful. + /// + public static MeshNode[] DevLoginAdminAccess() => + new[] { Admin, TestUser }.Select(u => new MeshNode($"{u.ObjectId}_Access", "_Access") + { + NodeType = "AccessAssignment", + Name = $"{u.Name} — Admin (DevLogin)", + Content = new AccessAssignment + { + AccessObject = u.ObjectId!, + DisplayName = u.Name, + Roles = [new RoleAssignment { Role = "Admin" }] + }, + MainNode = "", + }).ToArray(); + /// /// Adds sample users and access assignments as pre-seeded MeshNodes. /// Chain in ConfigureMesh: base.ConfigureMesh(builder).AddGraph().AddSampleUsers() diff --git a/src/MeshWeaver.Mesh.Contract/Security/PermissionEvaluator.cs b/src/MeshWeaver.Mesh.Contract/Security/PermissionEvaluator.cs index 52b5e6f8a5..2735aecdb7 100644 --- a/src/MeshWeaver.Mesh.Contract/Security/PermissionEvaluator.cs +++ b/src/MeshWeaver.Mesh.Contract/Security/PermissionEvaluator.cs @@ -223,7 +223,14 @@ public static IObservable GetEffectivePermissions(IMessageHub hub, s // accessService.Context (AsyncLocal doesn't flow through // the Rx schedulers cache.GetQuery uses). var currentContext = capturedContext ?? capturedCircuitContext; - if (currentContext?.IsApiToken == true && !p.HasFlag(Permission.Api)) + // API-token capability: the token may use the API surface when its NODE + // permissions carry Api (a real Viewer/Editor grant) OR its claim roles do — + // this is the ONE place claim roles act, and it is a CAPABILITY gate, never a + // data grant (claims are excluded from roleIds above; see ComputeRoleState). + // Without the claims half, removing claims from node permissions would zero + // every MCP token on PublicRead-policy content (p = Read only, no Api bit). + if (currentContext?.IsApiToken == true && !p.HasFlag(Permission.Api) + && !ClaimsCarryApi(currentContext)) p = Permission.None; logger?.LogTrace("User {UserId} has permissions {Permissions} on node {NodePath} (cap: {Cap})", userId, p, nodePath, permissionCap); @@ -476,23 +483,36 @@ private static (ImmutableHashSet RoleIds, Permission PermissionCap, Perm roleIds = roleIds.Add(Role.Admin.Id); } - AddClaimRoles(capturedContext); - AddClaimRoles(capturedCircuitContext); - - void AddClaimRoles(AccessContext? ctx) - { - if (ctx?.Roles != null - && !string.IsNullOrEmpty(ctx.ObjectId) - && ctx.ObjectId == userId) - { - foreach (var roleName in ctx.Roles) - roleIds = roleIds.Add(roleName); - } - } - + // 🚨 CLAIM ROLES ARE DELIBERATELY NOT FOLDED INTO NODE PERMISSIONS. They used to be — + // AddClaimRoles(capturedContext/capturedCircuitContext) appended AccessContext.Roles to + // roleIds RIGHT HERE, after the per-scope walk and after the deny subtraction. That made + // every claim role a GLOBAL, UNDENIABLE grant on the entire mesh: the API token attaches + // the user's DB/platform roles as claims, so a portal admin's `get` by exact path read + // gated PAID course content they had never bought (memex, 2026-08-05 — 79,650 chars of + // AgenticPrimerDe/02-CodeWunsch served to an unentitled caller), while `search` over the + // SQL fold — which never sees claims — correctly denied the same node. The two read paths + // disagreeing IS the paywall bypass. + // + // The access model (AGENTS.md, "Global admin"): a platform role grants the PLATFORM + // gates, deliberately NOT cross-partition data access — it must not read a course it has + // not bought. So node data permissions come from AccessAssignment nodes and policies + // ONLY, matching the SQL path row for row. Claim roles keep their legitimate job at the + // API-token capability check in GetEffectivePermissions (ClaimsCarryApi) — they decide + // whether the token may use the API surface, never what data it may read. + // Pinned by PaywallRealGateShapeTests (DbRoleClaim_DoesNotGrantNodeRead and siblings). return (roleIds, permissionCap, publicGrant); } + /// + /// True when the context's claim roles include a built-in role carrying + /// (Viewer, Commenter, Editor, Admin, …). Backs the API-token + /// capability gate — the only effect claim roles have on permission evaluation. + /// + private static bool ClaimsCarryApi(AccessContext ctx) + => ctx.Roles is not null + && ctx.Roles.Any(r => + BuiltInRolePerms.TryGetValue(r, out var rp) && rp.HasFlag(Permission.Api)); + private static List GetScopeHierarchy(string nodePath) { var scopes = new List { "" }; diff --git a/test/MeshWeaver.Hosting.Monolith.Test/NodeTypeReleaseGateTest.cs b/test/MeshWeaver.Hosting.Monolith.Test/NodeTypeReleaseGateTest.cs index 2b663ba78a..fd04f28cfe 100644 --- a/test/MeshWeaver.Hosting.Monolith.Test/NodeTypeReleaseGateTest.cs +++ b/test/MeshWeaver.Hosting.Monolith.Test/NodeTypeReleaseGateTest.cs @@ -45,6 +45,10 @@ public class NodeTypeReleaseGateTest(ITestOutputHelper output) : MonolithMeshTes protected override MeshBuilder ConfigureMesh(MeshBuilder builder) => ConfigureMeshBase(builder) .AddRowLevelSecurity() + // Roland's setup reads need a real grant — claim roles no longer grant node + // permissions (the paywall fix). The rel-editor / rel-viewer / ro-user assertions + // below are about OTHER identities and stay meaningful. + .AddMeshNodes(TestUsers.DevLoginAdminAccess()) .AddMeshNodes( AssignmentNodeFactory.UserRole("rel-editor", "Editor", "RelGate"), AssignmentNodeFactory.UserRole("rel-viewer", "Viewer", "RelGate"), diff --git a/test/MeshWeaver.Hosting.PostgreSql.Test/AnonymousSpaceReadSecurityTests.cs b/test/MeshWeaver.Hosting.PostgreSql.Test/AnonymousSpaceReadSecurityTests.cs index 2b94903d0a..861db5cbd6 100644 --- a/test/MeshWeaver.Hosting.PostgreSql.Test/AnonymousSpaceReadSecurityTests.cs +++ b/test/MeshWeaver.Hosting.PostgreSql.Test/AnonymousSpaceReadSecurityTests.cs @@ -59,7 +59,11 @@ protected override MeshBuilder ConfigureMesh(MeshBuilder builder) services.AddPartitionedPostgreSqlPersistence(csb.ConnectionString)) .AddRowLevelSecurity() // same access wiring as memex .AddGraph() - .AddSpaceType(); + .AddSpaceType() + // Real root Admin grants for the DevLogin identities — claim roles no longer + // grant node permissions (the paywall fix); setup writes/reads run under DevLogin + // and need actual assignment nodes, same as production. See TestUsers.DevLoginAdminAccess. + .AddMeshNodes(TestUsers.DevLoginAdminAccess()); } // The client needs a workspace/data context to host the remote stream diff --git a/test/MeshWeaver.Hosting.PostgreSql.Test/BootTypeDefEchoTests.cs b/test/MeshWeaver.Hosting.PostgreSql.Test/BootTypeDefEchoTests.cs index db0cde4646..11a9be5d76 100644 --- a/test/MeshWeaver.Hosting.PostgreSql.Test/BootTypeDefEchoTests.cs +++ b/test/MeshWeaver.Hosting.PostgreSql.Test/BootTypeDefEchoTests.cs @@ -54,6 +54,9 @@ protected override MeshBuilder ConfigureMesh(MeshBuilder builder) services.AddPartitionedPostgreSqlPersistence(csb.ConnectionString)) .AddRowLevelSecurity() .AddGraph() + // Real root Admin grants for the DevLogin identities — claim roles no longer + // grant node permissions (the paywall fix). See TestUsers.DevLoginAdminAccess. + .AddMeshNodes(TestUsers.DevLoginAdminAccess()) .ConfigureServices(s => s.AddSingleton( new CapturingLoggerProvider(_saveMeshNodeWarnings))); } diff --git a/test/MeshWeaver.Hosting.PostgreSql.Test/EffectivePermissionPostgresTest.cs b/test/MeshWeaver.Hosting.PostgreSql.Test/EffectivePermissionPostgresTest.cs index db88ec8b9c..a57b6c1e1b 100644 --- a/test/MeshWeaver.Hosting.PostgreSql.Test/EffectivePermissionPostgresTest.cs +++ b/test/MeshWeaver.Hosting.PostgreSql.Test/EffectivePermissionPostgresTest.cs @@ -58,7 +58,11 @@ protected override MeshBuilder ConfigureMesh(MeshBuilder builder) services.AddPartitionedPostgreSqlPersistence(csb.ConnectionString)) .AddRowLevelSecurity() .AddGraph() - .AddSpaceType(); + .AddSpaceType() + // Real root Admin grants for the DevLogin identities — claim roles no longer + // grant node permissions (the paywall fix); setup writes/reads run under DevLogin + // and need actual assignment nodes, same as production. See TestUsers.DevLoginAdminAccess. + .AddMeshNodes(TestUsers.DevLoginAdminAccess()); } /// diff --git a/test/MeshWeaver.Hosting.PostgreSql.Test/PaywallRealGateShapeTests.cs b/test/MeshWeaver.Hosting.PostgreSql.Test/PaywallRealGateShapeTests.cs new file mode 100644 index 0000000000..bf48dcf22c --- /dev/null +++ b/test/MeshWeaver.Hosting.PostgreSql.Test/PaywallRealGateShapeTests.cs @@ -0,0 +1,262 @@ +using System.Reactive.Linq; +using System.Text.Json.Nodes; +using System.Threading.Tasks; +using MeshWeaver.AI; +using MeshWeaver.Graph; +using MeshWeaver.Graph.Configuration; +using MeshWeaver.Hosting.Monolith; +using MeshWeaver.Hosting.Monolith.TestBase; +using MeshWeaver.Hosting.Security; +using MeshWeaver.Mesh; +using MeshWeaver.Mesh.Security; +using MeshWeaver.Mesh.Services; +using MeshWeaver.Messaging; +using Microsoft.Extensions.DependencyInjection; +using Xunit; +using MeshWeaver.Fixture; + +namespace MeshWeaver.Hosting.PostgreSql.Test; + +/// +/// The paywall, gated with the EXACT node shapes the Store's PluginGate writes — NOT the +/// test fixture's permission helper. This distinction is the whole point of the class: +/// PaywallIdentityMatrixTests seeded its gate through fixture.AccessControl.Grant +/// and every deny case passed even while production served gated paid lessons to unentitled +/// users, because production's gate is expressed as AccessAssignment NODES +/// (roles:[{role:Viewer, denied:true}] for the Public subject) that +/// PermissionEvaluator must fold — a different code path entirely. +/// +/// The decisive production measurement (memex, 2026-08-05, same MCP credential, same +/// second): search correctly hid the gated lessons while get by exact path served +/// them in full. The SQL fold and PermissionEvaluator disagreed. The disagreement: +/// the API token attaches the user's DB roles (e.g. the portal-admin role) as CLAIM roles, and +/// the evaluator folded claim roles into every node's effective permissions AFTER the per-scope +/// deny subtraction — a global, undeniable Read on the entire mesh, invisible to the SQL path +/// (which never sees claims). is that exact +/// scenario and MUST stay red against any evaluator that folds claim roles into node data +/// permissions. +/// +/// The access model this pins (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". +/// Claim roles decide the API-token CAPABILITY (); node data access +/// comes only from AccessAssignment nodes and policies, same as the SQL path. +/// +[Collection("PostgreSql")] +public class PaywallRealGateShapeTests(PostgreSqlFixture fixture, ITestOutputHelper output) + : MonolithMeshTestBase(output) +{ + private const string Buyer = "gs_buyer"; + private const string Visitor = "gs_visitor"; + + protected override MeshBuilder ConfigureMesh(MeshBuilder builder) + { + var csb = new Npgsql.NpgsqlConnectionStringBuilder(fixture.ConnectionString) + { + MaxPoolSize = 16, + ConnectionIdleLifetime = 10 + }; + return builder + .UseMonolithMesh() + .ConfigureServices(services => + services.AddPartitionedPostgreSqlPersistence(csb.ConnectionString)) + .AddRowLevelSecurity() + .AddGraph() + .AddSpaceType(); + } + + /// + /// A Viewer AccessAssignment node in EXACTLY the shape PluginGate.ViewerAssignment + /// (MeshWeaver.Plugins, Store/Plugin/Source) writes: the node lands in + /// {scope}/_Access/{subject}_Access with mainNode = {scope}, content + /// {$type:AccessAssignment, accessObject, roles:[{$type:RoleAssignment, role:Viewer(, denied)}]}. + /// Do not "simplify" this to a fixture permission helper — the fidelity IS the test. + /// + private static MeshNode ViewerAssignment(string scope, string subject, bool denied) + { + var role = new JsonObject { ["$type"] = "RoleAssignment", ["role"] = "Viewer" }; + if (denied) + role["denied"] = true; + return new MeshNode($"{subject}_Access", $"{scope}/_Access") + { + Name = denied ? $"{subject} — Viewer DENIED (gated)" : $"{subject} — Viewer", + NodeType = "AccessAssignment", + MainNode = scope, + Content = new JsonObject + { + ["$type"] = "AccessAssignment", + ["accessObject"] = subject, + ["displayName"] = subject, + ["roles"] = new JsonArray { role }, + }, + }; + } + + /// + /// Seeds one plugin with the full PluginGate shape: root Public + Anonymous Viewer grants + /// (the cover is the one public page), Public + Anonymous Viewer DENIES on the gated child + /// (a synced child is born gated), and the buyer's root Viewer grant (what + /// PluginGate.Enroll writes — entitlement is a grant at the ROOT, which the child + /// deny for Public must NOT strip). + /// + private async Task<(string Plugin, string Gated)> Seed(string suffix) + { + var plugin = $"GateShape{suffix}"; + var gated = $"{plugin}/PaidLesson"; + + await NodeFactory.CreateNode(MeshNode.FromPath(plugin) with + { Name = plugin, NodeType = SpaceNodeType.NodeType, Content = new Space() }) + .Should().Within(90.Seconds()).Emit(); + await NodeFactory.CreateNode(MeshNode.FromPath(gated) with + { Name = "Paid Lesson", NodeType = "Markdown" }) + .Should().Within(90.Seconds()).Emit(); + + foreach (var node in new[] + { + ViewerAssignment(plugin, WellKnownUsers.Public, denied: false), + ViewerAssignment(plugin, WellKnownUsers.Anonymous, denied: false), + ViewerAssignment(gated, WellKnownUsers.Public, denied: true), + ViewerAssignment(gated, WellKnownUsers.Anonymous, denied: true), + ViewerAssignment(plugin, Buyer, denied: false), + }) + { + await NodeFactory.CreateNode(node).Should().Within(90.Seconds()).Emit(); + } + + // Barrier: wait until the evaluator's synced access streams have FOLDED the seeded + // nodes — the Public deny at the child and the buyer's root grant. Without this the + // read can race the query cache: a too-early snapshot denies EVERYONE, which fails the + // buyer case and lets every deny case pass vacuously. Polling the evaluator itself is + // the honest barrier — it is the same substrate the read gate consults. + await Mesh.GetEffectivePermissions(gated, WellKnownUsers.Public) + .Where(p => !p.HasFlag(Permission.Read)) + .Should().Within(60.Seconds()).Emit(); + await Mesh.GetEffectivePermissions(gated, Buyer) + .Where(p => p.HasFlag(Permission.Read)) + .Should().Within(60.Seconds()).Emit(); + + return (plugin, gated); + } + + /// Reads through the same entry point the MCP `get` tool uses. + private async Task Read(AccessContext? identity, string path) + { + var access = Mesh.ServiceProvider.GetRequiredService(); + access.SetCircuitContext(identity); + try + { + return await new MeshOperations(Mesh).Get(path).Should().Within(60.Seconds()).Emit(); + } + finally + { + access.SetCircuitContext(null); + } + } + + /// + /// 🚨 THE PRODUCTION BYPASS. The caller is authenticated, has NO grant and NO entitlement in + /// this plugin — but their API token carries their DB role ("Admin" on their own portal) as a + /// claim role. A claim role is a PLATFORM capability; it must not read a course the user has + /// not bought. Before the fix the evaluator folded it into every node's permissions after the + /// deny subtraction: global, undeniable Read — while `search` (SQL, claim-blind) denied the + /// same node. This is `get @AgenticPrimerDe/02-CodeWunsch` returning 79,650 chars, as a test. + /// + [Fact(Timeout = 180000)] + public async Task DbRoleClaim_DoesNotGrantNodeRead() + { + var (_, gated) = await Seed("Claim"); + var result = await Read( + new AccessContext { ObjectId = Visitor, Name = Visitor, Roles = ["Admin"] }, gated); + Output.WriteLine(result); + result.Should().NotContain($"\"path\":\"{gated}\"", + "a DB/platform role attached as a claim must not read gated partition data — " + + "platform roles grant the platform gates, never a course the user has not bought"); + } + + /// Same caller shape with the modest role — the common MCP token. + [Fact(Timeout = 180000)] + public async Task ViewerClaim_DoesNotGrantNodeRead() + { + var (_, gated) = await Seed("ViewerClaim"); + var result = await Read( + new AccessContext { ObjectId = Visitor, Name = Visitor, Roles = ["Viewer"] }, gated); + Output.WriteLine(result); + result.Should().NotContain($"\"path\":\"{gated}\"", + "a claim role must never out-rank the gate's Public deny on the child"); + } + + /// Authenticated, bought nothing, no claims — inherits Public's child deny. + [Fact(Timeout = 180000)] + public async Task AuthenticatedNotEntitled_IsDenied() + { + var (_, gated) = await Seed("Plain"); + var result = await Read(new AccessContext { ObjectId = Visitor, Name = Visitor }, gated); + Output.WriteLine(result); + result.Should().NotContain($"\"path\":\"{gated}\"", + "the real gate shape (Public root grant + Public child deny nodes) must deny an " + + "unentitled signed-in user on the exact-path read, as the SQL path already does"); + } + + /// + /// The buyer: entitlement is the root Viewer grant PluginGate.Enroll writes. The child deny + /// targets the Public subject, not the buyer — the buyer's own grant must survive it. + /// This is the case that catches an over-strict fix (deny-by-role stripping every subject). + /// + [Fact(Timeout = 180000)] + public async Task Buyer_CanReadGatedContent() + { + var (_, gated) = await Seed("Buyer"); + var result = await Read(new AccessContext { ObjectId = Buyer, Name = Buyer }, gated); + Output.WriteLine(result); + result.Should().Contain($"\"path\":\"{gated}\"", + "the buyer's root Viewer grant is the entitlement — the Public child deny must not " + + "strip it"); + } + + /// The cover stays public: the root has Public+Anonymous Viewer grants and no deny. + [Fact(Timeout = 180000)] + public async Task Cover_VisitorAllowed() + { + var (plugin, _) = await Seed("Cover"); + var result = await Read(new AccessContext { ObjectId = Visitor, Name = Visitor }, plugin); + Output.WriteLine(result); + result.Should().Contain($"\"path\":\"{plugin}\"", + "the cover is the one public page of a gated plugin — an over-strict claim fix must " + + "not close it"); + } + + /// + /// The claim roles' LEGITIMATE job survives: an API token's claims still decide the + /// CAPABILITY. A token whose claims carry an Api-bearing role + /// may read what its NODE permissions allow (here: the public cover via the Public grant); + /// removing claim roles from node permissions must not brick every MCP token. + /// + [Fact(Timeout = 180000)] + public async Task ApiToken_WithClaimRoles_StillReadsPublicContent() + { + var (plugin, _) = await Seed("ApiTok"); + var result = await Read( + new AccessContext + { + ObjectId = Visitor, Name = Visitor, Roles = ["Viewer"], IsApiToken = true + }, plugin); + Output.WriteLine(result); + result.Should().Contain($"\"path\":\"{plugin}\"", + "an API token with an Api-bearing claim role must still read node-granted public " + + "content — claims gate the API capability, nodes gate the data"); + } + + /// And the same token is still denied the gated child — capability is not data. + [Fact(Timeout = 180000)] + public async Task ApiToken_WithClaimRoles_IsStillDeniedGatedContent() + { + var (_, gated) = await Seed("ApiTokDeny"); + var result = await Read( + new AccessContext + { + ObjectId = Visitor, Name = Visitor, Roles = ["Editor"], IsApiToken = true + }, gated); + Output.WriteLine(result); + result.Should().NotContain($"\"path\":\"{gated}\"", + "the Api capability lets the token call the API — it grants no Read on gated data"); + } +} diff --git a/test/MeshWeaver.Hosting.PostgreSql.Test/PgOnlyProdShapeTests.cs b/test/MeshWeaver.Hosting.PostgreSql.Test/PgOnlyProdShapeTests.cs index c80d76d919..103e7621c0 100644 --- a/test/MeshWeaver.Hosting.PostgreSql.Test/PgOnlyProdShapeTests.cs +++ b/test/MeshWeaver.Hosting.PostgreSql.Test/PgOnlyProdShapeTests.cs @@ -46,7 +46,11 @@ protected override MeshBuilder ConfigureMesh(MeshBuilder builder) .ConfigureServices(services => services.AddPartitionedPostgreSqlPersistence(csb.ConnectionString)) .AddRowLevelSecurity() - .AddGraph(); + .AddGraph() + // Real root Admin grants for the DevLogin identities — claim roles no longer + // grant node permissions (the paywall fix); setup writes/reads run under DevLogin + // and need actual assignment nodes, same as production. See TestUsers.DevLoginAdminAccess. + .AddMeshNodes(TestUsers.DevLoginAdminAccess()); } [Fact(Timeout = 60000)] diff --git a/test/MeshWeaver.Hosting.PostgreSql.Test/SpaceOnboardingIntegrationTests.cs b/test/MeshWeaver.Hosting.PostgreSql.Test/SpaceOnboardingIntegrationTests.cs index 0dd88dcb6b..e3e9bf76df 100644 --- a/test/MeshWeaver.Hosting.PostgreSql.Test/SpaceOnboardingIntegrationTests.cs +++ b/test/MeshWeaver.Hosting.PostgreSql.Test/SpaceOnboardingIntegrationTests.cs @@ -62,7 +62,11 @@ protected override MeshBuilder ConfigureMesh(MeshBuilder builder) }) .AddRowLevelSecurity() .AddGraph() - .AddSpaceType(); + .AddSpaceType() + // Real root Admin grants for the DevLogin identities — claim roles no longer + // grant node permissions (the paywall fix); setup writes/reads run under DevLogin + // and need actual assignment nodes, same as production. See TestUsers.DevLoginAdminAccess. + .AddMeshNodes(TestUsers.DevLoginAdminAccess()); } /// diff --git a/test/MeshWeaver.Hosting.PostgreSql.Test/StaticRepoImporterTests.cs b/test/MeshWeaver.Hosting.PostgreSql.Test/StaticRepoImporterTests.cs index 84ea5298ea..39c700f9c4 100644 --- a/test/MeshWeaver.Hosting.PostgreSql.Test/StaticRepoImporterTests.cs +++ b/test/MeshWeaver.Hosting.PostgreSql.Test/StaticRepoImporterTests.cs @@ -90,7 +90,11 @@ protected override MeshBuilder ConfigureMesh(MeshBuilder builder) }) .AddRowLevelSecurity() .AddGraph() - .AddSpaceType(); + .AddSpaceType() + // Real root Admin grants for the DevLogin identities — claim roles no longer + // grant node permissions (the paywall fix); setup writes/reads run under DevLogin + // and need actual assignment nodes, same as production. See TestUsers.DevLoginAdminAccess. + .AddMeshNodes(TestUsers.DevLoginAdminAccess()); } private Task> Import() => From e3e1d87d18bef108c80502f7d971c01224a771f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Wed, 5 Aug 2026 23:25:29 +0200 Subject: [PATCH 2/3] Test harness stops free-riding on claim roles; contract tests flipped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../MonolithMeshTestBase.cs | 20 +++++---- .../TestUsers.cs | 14 +++++-- .../SecurityServiceTests.cs | 42 ++++++++++++------- 3 files changed, 49 insertions(+), 27 deletions(-) diff --git a/src/MeshWeaver.Hosting.Monolith.TestBase/MonolithMeshTestBase.cs b/src/MeshWeaver.Hosting.Monolith.TestBase/MonolithMeshTestBase.cs index b149b7f6b2..5b16d54234 100644 --- a/src/MeshWeaver.Hosting.Monolith.TestBase/MonolithMeshTestBase.cs +++ b/src/MeshWeaver.Hosting.Monolith.TestBase/MonolithMeshTestBase.cs @@ -130,6 +130,17 @@ protected MeshBuilder ConfigureMeshBase(MeshBuilder builder) // creates of any non-partition-owning type. AddSpaceType is idempotent, // so tests that also call it explicitly are unaffected. .AddSpaceType() + .AddMeshNodes(TestUsers.DevLoginAdminAccess()) + // Real STATIC root Admin grant for the DevLogin identity (Roland). + // Claim roles no longer grant node permissions (the paywall fix — + // PermissionEvaluator): the login context's Roles=["Admin"] is a platform + // capability, not data access, so without a real grant every test-body + // CreateNode under the DevLogin circuit is denied. Chained HERE, in the base + // every suite funnels through, because these are the harness's own identities; + // Public, Anonymous, groups and per-test subjects are untouched, so security + // assertions about them stay meaningful. A static grant also keeps the + // evaluator's synchronous fast path alive where tests deliberately stall the + // synced queries (CompileSourceSnapshotWedgeTest). .AddMeshNodes(new MeshNode(TestPartition) { Name = "Test Data", NodeType = "Markdown" }) // 🚨 REPLACE, don't TryAdd. AddInMemoryPersistence already TryAddSingleton'd the // pid-scoped default IAssemblyStore, so a TryAdd here would be a no-op and every test @@ -162,14 +173,7 @@ protected MeshBuilder ConfigureMeshBase(MeshBuilder builder) /// protected virtual MeshBuilder ConfigureMesh(MeshBuilder builder) => ConfigureMeshBase(builder) - .AddMeshNodes(TestUsers.PublicAdminAccess()) - // Real STATIC root Admin grants for the DevLogin identities. Claim roles no longer - // grant node permissions (the paywall fix — PermissionEvaluator), so the login - // context's Roles=["Admin"] is a platform capability, not data access. The static - // grant keeps the evaluator's synchronous fast path alive for the harness user — - // without it, permission resolution waits on the synced access queries, which tests - // that deliberately stall synced queries (CompileSourceSnapshotWedgeTest) would wedge. - .AddMeshNodes(TestUsers.DevLoginAdminAccess()); + .AddMeshNodes(TestUsers.PublicAdminAccess()); /// /// Initializes the test base, wiring xUnit output and building (or reusing, in shared-mesh mode) diff --git a/src/MeshWeaver.Hosting.Monolith.TestBase/TestUsers.cs b/src/MeshWeaver.Hosting.Monolith.TestBase/TestUsers.cs index 97ec1749fb..88571077d4 100644 --- a/src/MeshWeaver.Hosting.Monolith.TestBase/TestUsers.cs +++ b/src/MeshWeaver.Hosting.Monolith.TestBase/TestUsers.cs @@ -118,11 +118,19 @@ private static MeshNode CreateAccessNode(string ns, AccessAssignment assignment) /// the evaluator folded them into every node's permissions, undeniably; see /// PaywallRealGateShapeTests). Tests used to free-ride on Roles = ["Admin"]; access /// now comes from assignment NODES, in tests exactly as in production. Deliberately grants - /// the two DevLogin users ONLY — Public, Anonymous and per-test viewer identities keep - /// whatever the suite seeds, so security assertions stay meaningful. + /// (Roland) ONLY — the harness's ambient setup identity. + /// is excluded on purpose: suites use it as a SUBJECT (grant it + /// Viewer/Editor per test and assert the resulting affordances — MenuAccessControlTest), and + /// a baked-in root Admin would shadow every such role. Public, Anonymous, groups and + /// per-test identities keep whatever the suite seeds, so security assertions stay + /// meaningful. /// public static MeshNode[] DevLoginAdminAccess() => - new[] { Admin, TestUser }.Select(u => new MeshNode($"{u.ObjectId}_Access", "_Access") + // Node id deliberately differs from the runtime convention ({subject}_Access): several + // suites CREATE `_Access/Roland_Access` themselves in SetupAccessRightsAsync, and a + // static node at the same path makes that create collide (completes empty). The + // evaluator folds assignments by accessObject, not node id, so this grants identically. + new[] { Admin }.Select(u => new MeshNode($"{u.ObjectId}_DevLoginAccess", "_Access") { NodeType = "AccessAssignment", Name = $"{u.Name} — Admin (DevLogin)", diff --git a/test/MeshWeaver.Security.Test/SecurityServiceTests.cs b/test/MeshWeaver.Security.Test/SecurityServiceTests.cs index 75e3b6d66b..82ab93bdf3 100644 --- a/test/MeshWeaver.Security.Test/SecurityServiceTests.cs +++ b/test/MeshWeaver.Security.Test/SecurityServiceTests.cs @@ -228,10 +228,14 @@ await Mesh.GetEffectivePermissions(scope, userId) } /// - /// Cross-scope regression: the sender stamps Admin onto delivery.AccessContext - /// (via the PostPipeline), routes a message to a per-node hub, and the - /// per-node hub's AccessControlPipeline must read those claim-based roles - /// when calling SecurityService.HasPermission. + /// Cross-scope regression, INVERTED by the paywall fix: the sender stamps Admin onto + /// delivery.AccessContext, the per-node hub restores it — and the restored claim roles + /// must NOT grant node permissions. Claim roles are a platform capability (the API-token + /// gate), never data access: folding them into effective permissions made every + /// platform-admin token an undeniable mesh-wide reader (the memex 2026-08-05 paywall + /// bypass — gated paid lessons served by exact-path get while search denied them). + /// The context restoration itself still matters (identity + Api capability ride on it); + /// what changed is that no amount of restored claims turns into node Read/Write. /// [Fact(Timeout = 10_000)] public async Task DeliveryAccessContext_RolesFlow_ToReceiverPermissionCheck() @@ -257,18 +261,19 @@ public async Task DeliveryAccessContext_RolesFlow_ToReceiverPermissionCheck() Roles = new[] { "Admin" } }); - // No static AccessAssignment exists for this user — the only signal is - // the claim-based Admin from the delivery context. The chain must still - // resolve Permission.All; if SecurityService didn't read claim-based - // roles, we'd see Permission.None. + // No AccessAssignment exists for this user — the only signal is the claim-based + // Admin from the delivery context. That is a PLATFORM signal, not a data grant: + // the receiver's permission check must resolve None. (Before the paywall fix this + // asserted Permission.All — which is precisely the bypass, pinned as a feature.) await sec.GetEffectivePermissions("any/scope", userId) - .Should().Be(Permission.All | Permission.Compile, - "claim-based Admin restored from delivery.AccessContext must grant " + - "all permissions on the receiver's permission check"); + .Should().Be(Permission.None, + "claim roles restored from delivery.AccessContext are a platform " + + "capability and must not grant node data permissions — a grant here is " + + "the mesh-wide paywall bypass"); } [Fact(Timeout = 10_000)] - public async Task ClaimBasedAdmin_GrantsImmediately_EvenWhenNoStaticAssignment() + public async Task ClaimBasedAdmin_DoesNotGrantNodePermissions() { // Resolve a scoped SecurityService directly so the AccessContext we // set is the one it reads. @@ -285,7 +290,11 @@ await sec.GetEffectivePermissions("any/scope", userId) "without claim-based roles, the user has no permissions"); // Stamp Admin via AccessContext.Roles — the same path the API token - // middleware uses. + // middleware uses. Claim roles are a platform capability, never node data + // access: the permissions must stay None. (Before the paywall fix this + // asserted Permission.All "immediately, even with no assignment" — the + // exact mechanism by which a portal-admin's API token read gated paid + // course content on memex, 2026-08-05.) accessService.SetCircuitContext(new AccessContext { ObjectId = userId, @@ -294,9 +303,10 @@ await sec.GetEffectivePermissions("any/scope", userId) }); await sec.GetEffectivePermissions("any/scope", userId) - .Should().Be(Permission.All | Permission.Compile, - "claim-based Admin must grant all permissions regardless of the " + - "synced-query state"); + .Should().Be(Permission.None, + "claim-based roles must not grant node permissions — node data access " + + "comes from AccessAssignment nodes and policies only, exactly like the " + + "SQL read path"); } /// From 1658fc3b4fe554862884af0f5f1db21ca029eabc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Wed, 5 Aug 2026 23:52:01 +0200 Subject: [PATCH 3/3] Gate-shape tests: one lazy class-level seed instead of seven MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../PaywallRealGateShapeTests.cs | 82 ++++++++++++------- 1 file changed, 51 insertions(+), 31 deletions(-) diff --git a/test/MeshWeaver.Hosting.PostgreSql.Test/PaywallRealGateShapeTests.cs b/test/MeshWeaver.Hosting.PostgreSql.Test/PaywallRealGateShapeTests.cs index bf48dcf22c..80fb1c50fa 100644 --- a/test/MeshWeaver.Hosting.PostgreSql.Test/PaywallRealGateShapeTests.cs +++ b/test/MeshWeaver.Hosting.PostgreSql.Test/PaywallRealGateShapeTests.cs @@ -91,35 +91,50 @@ private static MeshNode ViewerAssignment(string scope, string subject, bool deni }; } + // Unique per run (the PostgreSql fixture database persists across runs — a constant name + // collides with the previous run's plugin and the seeding CreateNode completes empty), + // static so every test instance of the class shares the one seeded gate. + private static readonly string Plugin = "GateShape" + Guid.NewGuid().ToString("N")[..8]; + private static readonly string Gated = $"{Plugin}/PaidLesson"; + /// - /// Seeds one plugin with the full PluginGate shape: root Public + Anonymous Viewer grants - /// (the cover is the one public page), Public + Anonymous Viewer DENIES on the gated child - /// (a synced child is born gated), and the buyer's root Viewer grant (what - /// PluginGate.Enroll writes — entitlement is a grant at the ROOT, which the child - /// deny for Public must NOT strip). + /// Seeds ONE plugin with the full PluginGate shape, once per test-class mesh: root + /// Public + Anonymous Viewer grants (the cover is the one public page), Public + Anonymous + /// Viewer DENIES on the gated child (a synced child is born gated), and the buyer's root + /// Viewer grant (what PluginGate.Enroll writes — entitlement is a grant at the ROOT, + /// which the child deny for Public must NOT strip). + /// + /// Seeded ONCE for the whole class, lazily from the first test body (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). One seed + /// rather than seven keeps the process-wide security-access scope streams small — the + /// per-test-seed version timed out its fold barriers on loaded CI shards. The reads under + /// test still run per test with explicit caller identities. /// - private async Task<(string Plugin, string Gated)> Seed(string suffix) - { - var plugin = $"GateShape{suffix}"; - var gated = $"{plugin}/PaidLesson"; + private static Task? _seeded; + + private Task EnsureSeeded() => _seeded ??= SeedOnce(); - await NodeFactory.CreateNode(MeshNode.FromPath(plugin) with - { Name = plugin, NodeType = SpaceNodeType.NodeType, Content = new Space() }) + private async Task SeedOnce() + { + var meshService = Mesh.ServiceProvider.GetRequiredService(); + await meshService.CreateNode(MeshNode.FromPath(Plugin) with + { Name = Plugin, NodeType = SpaceNodeType.NodeType, Content = new Space() }) .Should().Within(90.Seconds()).Emit(); - await NodeFactory.CreateNode(MeshNode.FromPath(gated) with + await meshService.CreateNode(MeshNode.FromPath(Gated) with { Name = "Paid Lesson", NodeType = "Markdown" }) .Should().Within(90.Seconds()).Emit(); foreach (var node in new[] { - ViewerAssignment(plugin, WellKnownUsers.Public, denied: false), - ViewerAssignment(plugin, WellKnownUsers.Anonymous, denied: false), - ViewerAssignment(gated, WellKnownUsers.Public, denied: true), - ViewerAssignment(gated, WellKnownUsers.Anonymous, denied: true), - ViewerAssignment(plugin, Buyer, denied: false), + ViewerAssignment(Plugin, WellKnownUsers.Public, denied: false), + ViewerAssignment(Plugin, WellKnownUsers.Anonymous, denied: false), + ViewerAssignment(Gated, WellKnownUsers.Public, denied: true), + ViewerAssignment(Gated, WellKnownUsers.Anonymous, denied: true), + ViewerAssignment(Plugin, Buyer, denied: false), }) { - await NodeFactory.CreateNode(node).Should().Within(90.Seconds()).Emit(); + await meshService.CreateNode(node).Should().Within(90.Seconds()).Emit(); } // Barrier: wait until the evaluator's synced access streams have FOLDED the seeded @@ -127,14 +142,12 @@ await NodeFactory.CreateNode(MeshNode.FromPath(gated) with // read can race the query cache: a too-early snapshot denies EVERYONE, which fails the // buyer case and lets every deny case pass vacuously. Polling the evaluator itself is // the honest barrier — it is the same substrate the read gate consults. - await Mesh.GetEffectivePermissions(gated, WellKnownUsers.Public) + await Mesh.GetEffectivePermissions(Gated, WellKnownUsers.Public) .Where(p => !p.HasFlag(Permission.Read)) - .Should().Within(60.Seconds()).Emit(); - await Mesh.GetEffectivePermissions(gated, Buyer) + .Should().Within(120.Seconds()).Emit(); + await Mesh.GetEffectivePermissions(Gated, Buyer) .Where(p => p.HasFlag(Permission.Read)) - .Should().Within(60.Seconds()).Emit(); - - return (plugin, gated); + .Should().Within(120.Seconds()).Emit(); } /// Reads through the same entry point the MCP `get` tool uses. @@ -163,7 +176,8 @@ private async Task Read(AccessContext? identity, string path) [Fact(Timeout = 180000)] public async Task DbRoleClaim_DoesNotGrantNodeRead() { - var (_, gated) = await Seed("Claim"); + await EnsureSeeded(); + var gated = Gated; var result = await Read( new AccessContext { ObjectId = Visitor, Name = Visitor, Roles = ["Admin"] }, gated); Output.WriteLine(result); @@ -176,7 +190,8 @@ public async Task DbRoleClaim_DoesNotGrantNodeRead() [Fact(Timeout = 180000)] public async Task ViewerClaim_DoesNotGrantNodeRead() { - var (_, gated) = await Seed("ViewerClaim"); + await EnsureSeeded(); + var gated = Gated; var result = await Read( new AccessContext { ObjectId = Visitor, Name = Visitor, Roles = ["Viewer"] }, gated); Output.WriteLine(result); @@ -188,7 +203,8 @@ public async Task ViewerClaim_DoesNotGrantNodeRead() [Fact(Timeout = 180000)] public async Task AuthenticatedNotEntitled_IsDenied() { - var (_, gated) = await Seed("Plain"); + await EnsureSeeded(); + var gated = Gated; var result = await Read(new AccessContext { ObjectId = Visitor, Name = Visitor }, gated); Output.WriteLine(result); result.Should().NotContain($"\"path\":\"{gated}\"", @@ -204,7 +220,8 @@ public async Task AuthenticatedNotEntitled_IsDenied() [Fact(Timeout = 180000)] public async Task Buyer_CanReadGatedContent() { - var (_, gated) = await Seed("Buyer"); + await EnsureSeeded(); + var gated = Gated; var result = await Read(new AccessContext { ObjectId = Buyer, Name = Buyer }, gated); Output.WriteLine(result); result.Should().Contain($"\"path\":\"{gated}\"", @@ -216,7 +233,8 @@ public async Task Buyer_CanReadGatedContent() [Fact(Timeout = 180000)] public async Task Cover_VisitorAllowed() { - var (plugin, _) = await Seed("Cover"); + await EnsureSeeded(); + var plugin = Plugin; var result = await Read(new AccessContext { ObjectId = Visitor, Name = Visitor }, plugin); Output.WriteLine(result); result.Should().Contain($"\"path\":\"{plugin}\"", @@ -233,7 +251,8 @@ public async Task Cover_VisitorAllowed() [Fact(Timeout = 180000)] public async Task ApiToken_WithClaimRoles_StillReadsPublicContent() { - var (plugin, _) = await Seed("ApiTok"); + await EnsureSeeded(); + var plugin = Plugin; var result = await Read( new AccessContext { @@ -249,7 +268,8 @@ public async Task ApiToken_WithClaimRoles_StillReadsPublicContent() [Fact(Timeout = 180000)] public async Task ApiToken_WithClaimRoles_IsStillDeniedGatedContent() { - var (_, gated) = await Seed("ApiTokDeny"); + await EnsureSeeded(); + var gated = Gated; var result = await Read( new AccessContext {