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
8 changes: 5 additions & 3 deletions memex/Memex.Portal.Shared/MemexConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -898,9 +898,11 @@ public TBuilder ConfigureMemexMesh(IConfiguration configuration, bool isDevelopm
.AddGitHubSyncSettingsTab()
// GitHub Issues & PRs tab — browse/act on the repo's issues + pull requests.
.AddGitHubIssuesTab()
// Plugin Catalog tab (platform admins only) — browse the registry's modules
// and Install / Update them onto this instance. Replaces the old Plugins Space.
.AddPluginCatalogSettingsTab()
// NO Plugin Catalog tab. Browsing and provisioning packages is the STORE's
// job (/Store → the package card → Provision), and it is the only surface
// that runs the install under the System identity via SystemInstall. A
// second admin-only page onto the same registry duplicated that flow while
// bypassing the funnel it exists to enforce.
// Coupons tab (platform admins only) — the Store's typed coupon codes at
// Admin/Coupons: live list, redemption tallies, create/open.
.AddCouponAdminSettingsTab()
Expand Down
10 changes: 10 additions & 0 deletions src/MeshWeaver.GitSync/GitHubSyncConfiguration.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using MeshWeaver.Graph;
using MeshWeaver.Layout;
using MeshWeaver.Mesh;
using MeshWeaver.Mesh.Services;
using MeshWeaver.Mesh.Threading;
using MeshWeaver.Messaging;
using Microsoft.Extensions.Configuration;
Expand Down Expand Up @@ -59,6 +60,15 @@ public static IServiceCollection AddGitHubSyncServices(this IServiceCollection s
services.AddSingleton<IPartitionSyncSourceProvider>(sp => new GitHubPartitionSyncSourceProvider(
sp.GetRequiredService<GitHubSyncService>(),
sp.GetRequiredService<IMessageHub>()));
// Wiring a _GitSync makes the partition SYSTEM-OWNED — retract any privileged account
// grant that predates it. See SystemOwnedAccessRetractionHandler for the seven-second
// window this closes (create the Space, get Admin, wire the sync).
services.AddSingleton<INodePostCreationHandler>(sp => new SystemOwnedAccessRetractionHandler(
sp.GetRequiredService<IMessageHub>(),
sp.GetRequiredService<IMeshService>(),
sp.GetRequiredService<IStorageAdapter>(),
sp.GetService<AccessService>(),
sp.GetService<ILoggerFactory>()?.CreateLogger<SystemOwnedAccessRetractionHandler>()));
// On-disk per-user git working trees (clone/edit/commit/push) — the working-tree
// counterpart to content sync, shared by the AI harness + the in-portal editor.
// Root binds from GitWorkspace:Root (env GitWorkspace__Root=/workspace in the portal,
Expand Down
124 changes: 124 additions & 0 deletions src/MeshWeaver.GitSync/SystemOwnedAccessRetractionHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
using System.Reactive;
using System.Reactive.Linq;
using MeshWeaver.Mesh;
using MeshWeaver.Mesh.Security;
using MeshWeaver.Mesh.Services;
using MeshWeaver.Messaging;
using Microsoft.Extensions.Logging;

namespace MeshWeaver.GitSync;

/// <summary>
/// 🚨 THE MOMENT A PARTITION GAINS A <c>_GitSync</c> IT BECOMES SYSTEM-OWNED — so every privileged
/// account grant on it is retracted, right there.
///
/// <para><b>The window this closes.</b> <c>AccessAssignmentGuard.IsForbiddenOnSystemOwned</c> makes
/// it impossible to WRITE such a grant onto a space that is already system-owned, and
/// <c>EnsurePartitionBootstrap</c> stopped re-minting one on every deploy. Neither helps when the
/// order is reversed: a Space hand-created by a person is an ORDINARY space for as long as it takes
/// to wire the sync, its creator gets Admin the ordinary way, and that grant then sits on a space
/// the repo owns — legal when written, wrong one second later.</para>
///
/// <para>Measured on memex 2026-08-04: <c>SST/_Access/rbuergi_Access</c> (Admin) at 14:48:07,
/// <c>SST/_GitSync</c> at 14:48:14. Seven seconds. Seventeen Admin grants across three meshes
/// entered through that window, and nothing anywhere reported them — the space looked correct,
/// the content synced, the pages rendered.</para>
///
/// <para><b>Why retract rather than refuse the sync.</b> Refusing to wire <c>_GitSync</c> while a
/// grant exists turns a deploy into a permissions error at the worst moment and teaches people to
/// hand-clean access lists. Retracting states the invariant instead: the repo owns this space now,
/// so nobody else does. It is also SELF-HEALING — a mesh that already drifted converges the next
/// time its sync is (re)wired.</para>
///
/// <para><b>What survives, deliberately:</b> <c>Viewer</c>/<c>Commenter</c> entitlements (a
/// purchase, a redeemed coupon, an admin-issued grant — the only door into a gated plugin), every
/// <c>Denied</c> assignment (that IS the gating), and <c>system-security</c> itself (the identity
/// the importer writes under — retracting it would make the space unwritable by anything).</para>
///
/// <para>Best-effort by design (<c>FailsCreateOnError</c> stays false): a partition that cannot be
/// swept must not block its own sync from being configured. Every retraction is logged at Warning
/// naming the subject and the space, because access silently disappearing is its own bug class.</para>
/// </summary>
public sealed class SystemOwnedAccessRetractionHandler(
IMessageHub hub,
IMeshService meshService,
IStorageAdapter persistence,
AccessService? accessService,
ILogger<SystemOwnedAccessRetractionHandler>? logger) : INodePostCreationHandler
{
/// <summary>Fires for the sync-config node — the thing whose existence MAKES a space system-owned.</summary>
public string NodeType => GitHubSyncService.ConfigNodeType;

public IObservable<Unit> Handle(MeshNode createdNode, string? createdBy)
{
// The config lives at {partition}/_GitSync, so the owning partition is its namespace's
// first segment. A root-level config owns nothing and is not this handler's business.
var partition = AccessAssignmentGuard.PartitionOf(createdNode.Namespace ?? "");
if (string.IsNullOrEmpty(partition))
return Observable.Return(Unit.Default);

var accessFolder = $"{partition}/_Access";

return persistence.ListChildPaths(accessFolder)
.Take(1)
.Catch<(IEnumerable<string> NodePaths, IEnumerable<string> DirectoryPaths), Exception>(ex =>
{
logger?.LogDebug(ex,
"[SystemOwned] could not list '{Folder}'; nothing to retract", accessFolder);
return Observable.Return<(IEnumerable<string>, IEnumerable<string>)>(([], []));
})
.SelectMany(children =>
{
var paths = children.NodePaths?.ToArray() ?? [];
return paths.Length == 0
? Observable.Return<IList<MeshNode>>([])
: persistence.ReadMany(paths, hub.JsonSerializerOptions).ToList();
})
.SelectMany(RetractAll);
}

private IObservable<Unit> RetractAll(IList<MeshNode> grants)
{
// The SAME predicate the write boundary uses — one rule, two enforcement points, so a
// grant that could not be created here can never survive here either.
var doomed = grants
.Where(n => AccessAssignmentGuard.IsForbiddenOnSystemOwned(
n, n.ContentAs<AccessAssignment>(hub.JsonSerializerOptions), systemOwned: true, out _))
.Select(n => n.Path)
.ToArray();

if (doomed.Length == 0)
return Observable.Return(Unit.Default);

logger?.LogWarning(
"[SystemOwned] retracting {Count} privileged grant(s) — the partition is now system-owned "
+ "and only {System} may write it: {Paths}",
doomed.Length, WellKnownUsers.System, string.Join(", ", doomed));

// Sequential (Concat, never Merge): these are node deletes into a partition that may have
// been created seconds ago, and a fan-out of cold per-node hub activations is what crashes
// writes into a fresh partition. Under System — the retraction is the platform's decision,
// not the caller's, and the caller may hold no rights on the space at all.
return AsSystem(() => doomed
.Select(path => meshService.DeleteNode(path)
.Take(1)
.Select(_ => Unit.Default)
.Catch<Unit, Exception>(ex =>
{
logger?.LogError(ex,
"[SystemOwned] FAILED to retract '{Path}' — it still grants write access to a "
+ "system-owned space", path);
return Observable.Return(Unit.Default);
}))
.Concat()
.DefaultIfEmpty(Unit.Default)
.TakeLast(1));
}

/// <summary>Establishes the System identity on the write's own subscribe thread — mirrors
/// <c>MeshExtensions.AsSystem</c>, so the deletes are attributed to the platform.</summary>
private IObservable<T> AsSystem<T>(Func<IObservable<T>> write)
=> accessService is null
? Observable.Defer(write)
: Observable.Using(() => accessService.ImpersonateAsSystem(), _ => write());
}
69 changes: 67 additions & 2 deletions src/MeshWeaver.Mesh.Contract/MeshExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -454,7 +454,15 @@ private static IMessageDelivery HandleCreateNodeRequest(
// See EnsurePartitionBootstrap.
// 2. Validators → 3. NodeType existence → 4-7. Enrich + save + change feed + version
return EnsurePartitionBootstrap(hub, node, capturedRequest, logger)
.SelectMany(_ => RunCreationValidatorsObs(hub, node, capturedRequest))
// 1d. A SYSTEM-OWNED space grants nobody write access. Sequenced AFTER the
// bootstrap (which may have just created the partition) and ahead of the
// validators, and folded into the same rejection tuple so the failure is
// posted by the one code path that already knows how.
.SelectMany(_ => SystemOwnedGrantRejection(hub, node))
.SelectMany(grantRejection => grantRejection is not null
? Observable.Return<(string? ErrorMessage, NodeCreationRejectionReason Reason)?>(
(grantRejection, NodeCreationRejectionReason.ValidationFailed))
: RunCreationValidatorsObs(hub, node, capturedRequest))
.SelectMany(validationError =>
{
if (validationError != null)
Expand Down Expand Up @@ -1625,6 +1633,48 @@ private static IMessageDelivery HandleValidateDeleteRequest(
return request.Processed();
}

/// <summary>
/// Emits the rejection reason when the node is a privileged grant on a SYSTEM-OWNED (GitSynced)
/// partition, else <c>null</c>. See <see cref="AccessAssignmentGuard.IsForbiddenOnSystemOwned"/>
/// for why that shape is refused.
///
/// <para><b>The storage read is paid for only by the shape that can fail.</b> The pure checks
/// run first — node type, grant path, and whether the assignment confers write at all — so the
/// hot path (an entitlement's <c>Viewer</c> grant, written on every enrollment) never touches
/// persistence. Only an Admin/Editor grant costs the one <c>{partition}/_GitSync</c> read, which
/// is the same probe <c>EnsurePartitionBootstrap</c> already performs on this path.</para>
///
/// <para>Content is materialised through the TYPED accessor, never a raw <c>JsonElement</c>
/// test: a grant arrives typed on the hub that owns it, as <c>JsonObject</c> from the node
/// builders, and as <c>JsonElement</c> over the wire — and a shape test that misses would read
/// "no roles", i.e. silently allow exactly what this guard exists to refuse.</para>
/// </summary>
private static IObservable<string?> SystemOwnedGrantRejection(IMessageHub hub, MeshNode node)
{
if (!string.Equals(node.NodeType, AccessAssignmentGuard.AccessAssignmentNodeType,
StringComparison.OrdinalIgnoreCase))
return Observable.Return<string?>(null);

var scope = AccessAssignmentGuard.ScopeFromPath(node.Path);
if (string.IsNullOrEmpty(scope))
return Observable.Return<string?>(null);

var assignment = node.ContentAs<AccessAssignment>(hub.JsonSerializerOptions);
if (!AccessAssignmentGuard.ConfersWriteAccess(assignment))
return Observable.Return<string?>(null);

var persistence = hub.ServiceProvider.GetService<IStorageAdapter>();
if (persistence is null)
return Observable.Return<string?>(null);
Comment on lines +1662 to +1668

var partition = AccessAssignmentGuard.PartitionOf(scope);
return ReadNodeAuthoritative(hub, persistence, $"{partition}/_GitSync")
.Select(sync => AccessAssignmentGuard.IsForbiddenOnSystemOwned(
node, assignment, systemOwned: sync is not null, out var reason)
? reason
: null);
}

/// <summary>
/// Sync-friendly observable variant of the creation-validator runner. Iterates
/// validators sequentially via <c>Concat</c> (preserves short-circuit semantics —
Expand Down Expand Up @@ -2014,7 +2064,22 @@ private static IMessageDelivery HandleCreateOrUpdateNodeRequest(

var inboundCtx = request.AccessContext;

existingObs.Subscribe(
// Same rule as the create path: a SYSTEM-OWNED space grants nobody write access. Guarding
// only the create would leave the hole open through the UPDATE branch below — an existing
// Viewer grant could simply be upserted up to Admin, which is the identical ownership claim
// with a version bump instead of a create. Both write paths, or neither.
var gatedExisting = SystemOwnedGrantRejection(hub, node)
.SelectMany(grantRejection =>
{
if (grantRejection is null)
return existingObs;
logger.LogError("[UpsertNode] REFUSED privileged grant on system-owned partition {Path}: {Reason}",
node.Path, grantRejection);
PostFail(grantRejection, NodeUpsertRejectionReason.ValidationFailed);
return Observable.Empty<MeshNode?>();
});

gatedExisting.Subscribe(
existing =>
{
if (existing is null)
Expand Down
93 changes: 93 additions & 0 deletions src/MeshWeaver.Mesh.Contract/Services/AccessAssignmentGuard.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using System.Collections.Frozen;
using MeshWeaver.Mesh;
using MeshWeaver.Mesh.Security;

namespace MeshWeaver.Mesh.Services;

Expand Down Expand Up @@ -130,4 +132,95 @@ public static void EnsureScopeValid(MeshNode node)
if (IsScopeInvalid(node, out var reason))
throw new InvalidOperationException(reason);
}

/// <summary>
/// The roles an ENTITLEMENT legitimately confers on a system-owned space. Everything else —
/// including any custom role — counts as write access, because this list is an ALLOWLIST: a
/// role nobody here recognises must not be waved through on a space the repo owns.
/// </summary>
private static readonly FrozenSet<string> ReadOnlyRoles =
new[] { "Viewer", "Commenter" }.ToFrozenSet(StringComparer.OrdinalIgnoreCase);

/// <summary>
/// The assignment confers WRITE access — any non-denied role outside <see cref="ReadOnlyRoles"/>.
/// A <c>Denied</c> role never counts: a deny only ever removes access, and the plugin gating
/// writes exactly those on every non-public child.
/// </summary>
public static bool ConfersWriteAccess(AccessAssignment? assignment)
{
if (assignment?.Roles is null)
return false;

foreach (var role in assignment.Roles)
{
if (role.Denied || string.IsNullOrWhiteSpace(role.Role))
continue;
if (!ReadOnlyRoles.Contains(role.Role))
return true;
}

return false;
}

/// <summary>
/// 🚨 A SYSTEM-OWNED space grants nobody write access. A partition with a <c>_GitSync</c> is
/// rewritten from its repo on every sync, so the ONLY identity that may write it is the one the
/// importer runs as (<see cref="WellKnownUsers.System"/>). Any other Admin/Editor grant is an
/// ownership claim over content the repo owns — and the change it enables is silently reverted
/// by the next sync, which is the worst of both worlds.
///
/// <para><b>The hole this closes.</b> Core already refuses to MINT a creator grant when a
/// deploy writes into a GitSynced partition (<c>EnsurePartitionBootstrap</c>). But a Space
/// hand-created BEFORE its <c>_GitSync</c> exists is not system-owned yet, so its creator gets
/// Admin the ordinary way and keeps it once the sync is wired. Measured on memex 2026-08-04:
/// <c>SST/_Access/rbuergi_Access</c> (Admin) was written at 14:48:07 and <c>SST/_GitSync</c> at
/// 14:48:14 — seven seconds later. Seventeen such grants across three meshes came from that
/// window and from the pre-fix re-mint.</para>
///
/// <para><b>What stays legal</b>, because it is how the funnel works: <c>Viewer</c>/
/// <c>Commenter</c> (an entitlement — a purchase, a redeemed coupon, an admin-issued grant),
/// every <c>Denied</c> assignment (the gating), and anything the System identity writes for
/// itself.</para>
/// </summary>
/// <param name="node">The grant node, already normalised.</param>
/// <param name="assignment">Its content, materialised by the caller through the typed accessor
/// — never re-read here as raw JSON, which is how a typed content silently reads as empty.</param>
/// <param name="systemOwned">Whether the partition has a <c>_GitSync</c>. The caller reads it;
/// keeping it a parameter is what makes this predicate pure and unit-testable with no hub.</param>
public static bool IsForbiddenOnSystemOwned(
MeshNode? node, AccessAssignment? assignment, bool systemOwned, out string reason)
{
reason = "";
if (!systemOwned || node is null)
return false;
if (!string.Equals(node.NodeType, AccessAssignmentNodeType, StringComparison.OrdinalIgnoreCase))
return false;

var scope = ScopeFromPath(node.Path);
if (string.IsNullOrEmpty(scope))
return false; // not a grant path, or the root shape IsScopeInvalid owns

var subject = assignment?.AccessObject ?? "";
if (string.Equals(subject, WellKnownUsers.System, StringComparison.OrdinalIgnoreCase))
return false; // the importer's own identity — this is the one Admin

if (!ConfersWriteAccess(assignment))
return false; // an entitlement, not an ownership claim

var partition = PartitionOf(scope);
reason = $"AccessAssignment '{node.Path}' would give '{subject}' write access to "
+ $"'{partition}', which is SYSTEM-OWNED (it has {partition}/_GitSync and is rewritten "
+ "from its repo on every sync). Only '" + WellKnownUsers.System + "' may write it; a "
+ "live edit by anyone else is reverted by the next sync. Grant Viewer as an "
+ "entitlement instead, or change the repo and sync it.";
Comment on lines +211 to +215
return true;
}

/// <summary>The partition a scope belongs to — its first segment. <c>Store/Plugin</c> → <c>Store</c>,
/// because <c>_GitSync</c> is wired on the partition root, not on nested scopes.</summary>
public static string PartitionOf(string scope)
{
var slash = scope.IndexOf('/');
return slash < 0 ? scope : scope[..slash];
}
}
Loading
Loading