diff --git a/src/MeshWeaver.Hosting.Monolith.TestBase/MonolithMeshTestBase.cs b/src/MeshWeaver.Hosting.Monolith.TestBase/MonolithMeshTestBase.cs index df1ba305f5..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 @@ -756,7 +767,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..88571077d4 100644 --- a/src/MeshWeaver.Hosting.Monolith.TestBase/TestUsers.cs +++ b/src/MeshWeaver.Hosting.Monolith.TestBase/TestUsers.cs @@ -106,6 +106,43 @@ 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 + /// (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() => + // 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)", + 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..80fb1c50fa --- /dev/null +++ b/test/MeshWeaver.Hosting.PostgreSql.Test/PaywallRealGateShapeTests.cs @@ -0,0 +1,282 @@ +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 }, + }, + }; + } + + // 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, 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 static Task? _seeded; + + private Task EnsureSeeded() => _seeded ??= SeedOnce(); + + 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 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), + }) + { + await meshService.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(120.Seconds()).Emit(); + await Mesh.GetEffectivePermissions(Gated, Buyer) + .Where(p => p.HasFlag(Permission.Read)) + .Should().Within(120.Seconds()).Emit(); + } + + /// 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() + { + await EnsureSeeded(); + var gated = Gated; + 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() + { + await EnsureSeeded(); + var gated = Gated; + 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() + { + await EnsureSeeded(); + var gated = Gated; + 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() + { + await EnsureSeeded(); + var gated = Gated; + 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() + { + await EnsureSeeded(); + var plugin = Plugin; + 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() + { + await EnsureSeeded(); + var plugin = Plugin; + 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() + { + await EnsureSeeded(); + var gated = Gated; + 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() => 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"); } ///