Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion src/MeshWeaver.Hosting.Monolith.TestBase/MonolithMeshTestBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<AccessService>();
using (accessService?.ImpersonateAsSystem()
?? System.Reactive.Disposables.Disposable.Empty)
{
await SetupAccessRightsAsync();
}
Comment on lines +776 to +781
}
TestPhaseTrace(name, "INIT_DONE", sw.ElapsedMilliseconds);
TestMemTrace(name, "INIT_MEM", forceGc: false);

Expand Down
37 changes: 37 additions & 0 deletions src/MeshWeaver.Hosting.Monolith.TestBase/TestUsers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,43 @@ private static MeshNode CreateAccessNode(string ns, AccessAssignment assignment)
MainNode = ns.Length > 0 ? ns : "",
};

/// <summary>
/// REAL root-scope Admin grants for the DevLogin identities (<see cref="Admin"/> /
/// <see cref="TestUser"/>) — static AccessAssignment nodes, the same thing a dev mesh's
/// login has. Suites that hand-roll ConfigureMesh (and so skip
/// <see cref="PublicAdminAccess"/>) chain this in when their SETUP writes/reads run under
/// the DevLogin circuit.
///
/// <para>🚨 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 <c>Roles = ["Admin"]</c>; access
/// now comes from assignment NODES, in tests exactly as in production. Deliberately grants
/// <see cref="Admin"/> (Roland) ONLY — the harness's ambient setup identity.
/// <see cref="TestUser"/> 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.</para>
/// </summary>
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();

/// <summary>
/// Adds sample users and access assignments as pre-seeded MeshNodes.
/// Chain in ConfigureMesh: base.ConfigureMesh(builder).AddGraph().AddSampleUsers()
Expand Down
50 changes: 35 additions & 15 deletions src/MeshWeaver.Mesh.Contract/Security/PermissionEvaluator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,14 @@ public static IObservable<Permission> 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);
Expand Down Expand Up @@ -476,23 +483,36 @@ private static (ImmutableHashSet<string> 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);
}

/// <summary>
/// True when the context's claim roles include a built-in role carrying
/// <see cref="Permission.Api"/> (Viewer, Commenter, Editor, Admin, …). Backs the API-token
/// capability gate — the only effect claim roles have on permission evaluation.
/// </summary>
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<string> GetScopeHierarchy(string nodePath)
{
var scopes = new List<string> { "" };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ILoggerProvider>(
new CapturingLoggerProvider(_saveMeshNodeWarnings)));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}

/// <summary>
Expand Down
Loading
Loading