Repository navigation
A system-owned space grants nobody write access - #805
Conversation
A partition with a _GitSync is rewritten from its repo on every sync, so the
only identity that may write it is the one the importer runs as. 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.
Core already refused to re-mint a creator grant when a deploy writes into a
GitSynced partition. Two holes remained, and both were load-bearing:
* A Space hand-created BEFORE its _GitSync exists is an ordinary space for as
long as it takes to wire the sync, so its creator gets Admin the ordinary
way and simply keeps it. On memex, SST/_Access/rbuergi_Access (Admin) was
written at 14:48:07 and SST/_GitSync at 14:48:14 — seven seconds. Seventeen
Admin grants across three meshes came through that window, and nothing
reported them: the space looked correct and the pages rendered.
* Nothing stopped a grant being written onto a space that was ALREADY
system-owned, by hand or by an agent over MCP.
So: refuse the write, and retract what predates it.
* AccessAssignmentGuard.IsForbiddenOnSystemOwned — pure, hub-free, unit-
testable. Wired at BOTH write boundaries: guarding only create would let an
upsert raise an existing Viewer to Admin, which is the same ownership claim
with a version bump. The storage probe is paid for only by the shape that
can fail — the pure checks run first, so an entitlement's Viewer grant
(written on every enrollment) never touches persistence.
* SystemOwnedAccessRetractionHandler — when a partition gains a _GitSync it
becomes system-owned, so privileged account grants on it are retracted right
there, under System, sequentially, each one logged. Self-healing: a mesh that
already drifted converges the next time its sync is wired.
Read-only roles are an ALLOWLIST, not a denylist: Viewer/Commenter pass, and an
unrecognised custom role is refused rather than waved through. Entitlements
(Viewer/Commenter), every Denied assignment (that IS the plugin gating), and
system-security itself keep working untouched — the funnel is the point.
Also removes the Plugin Catalog settings tab. Browsing and provisioning packages
is the Store's job (/Store -> the card -> Provision), which is the only surface
that runs the install under System via SystemInstall; the admin page duplicated
that flow while bypassing the funnel it exists to enforce. The registry API
itself (AddPluginCatalog) is untouched — memex still serves plugins to every
other instance.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR closes two remaining holes where privileged AccessAssignment grants (e.g., Admin/Editor/custom roles) could persist or be written onto GitSynced (“system-owned”) partitions, ensuring only system-security can hold write authority on repo-owned spaces and retracting any legacy privileged grants when _GitSync is first configured.
Changes:
- Add a pure, unit-tested predicate (
AccessAssignmentGuard.IsForbiddenOnSystemOwned) and enforce it at both create and upsert write boundaries. - Add a GitSync post-creation handler that retracts any pre-existing privileged grants under
{partition}/_Accesswhen{partition}/_GitSyncis created. - Remove the Plugin Catalog settings tab from the portal configuration to keep provisioning flowing through the Store’s system-owned install funnel.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| test/MeshWeaver.Graph.Test/AccessAssignmentGuardTest.cs | Adds unit tests covering allow/deny behavior for privileged vs entitlement roles on system-owned partitions. |
| src/MeshWeaver.Mesh.Contract/Services/AccessAssignmentGuard.cs | Implements system-owned grant detection, including “confers write access” logic and refusal reason generation. |
| src/MeshWeaver.Mesh.Contract/MeshExtensions.cs | Enforces the guard at node create and create-or-update boundaries by probing {partition}/_GitSync. |
| src/MeshWeaver.GitSync/SystemOwnedAccessRetractionHandler.cs | Introduces a post-creation handler that retracts privileged grants when _GitSync is wired. |
| src/MeshWeaver.GitSync/GitHubSyncConfiguration.cs | Registers the new post-creation handler in DI for GitSync. |
| memex/Memex.Portal.Shared/MemexConfiguration.cs | Removes the Plugin Catalog settings tab entry. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| /// 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 HashSet<string> ReadOnlyRoles = | ||
| new(StringComparer.OrdinalIgnoreCase) { "Viewer", "Commenter" }; |
| 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."; |
| 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); |
Test Results (shard 4)2 170 tests 1 982 ✅ 4m 43s ⏱️ Results for commit 76c1060. ♻️ This comment has been updated with latest results. |
Test Results (shard 3) 12 files 12 suites 4m 3s ⏱️ Results for commit 76c1060. ♻️ This comment has been updated with latest results. |
Test Results (shard 5)1 204 tests 1 203 ✅ 5m 58s ⏱️ Results for commit 76c1060. ♻️ This comment has been updated with latest results. |
Test Results (shard 2)1 083 tests 980 ✅ 4m 38s ⏱️ Results for commit 76c1060. ♻️ This comment has been updated with latest results. |
Test Results (shard 0)987 tests 981 ✅ 4m 26s ⏱️ Results for commit 76c1060. ♻️ This comment has been updated with latest results. |
Test Results (shard 1)1 248 tests 1 247 ✅ 9m 23s ⏱️ Results for commit 76c1060. ♻️ This comment has been updated with latest results. |
Test Results 61 files 61 suites 33m 13s ⏱️ Results for commit 76c1060. ♻️ This comment has been updated with latest results. |
NoStaticCollectionsTest caught the role allowlist: a static readonly HashSet is still mutable, so any caller could have added a role to it at runtime and widened what counts as read-only on a system-owned space. FrozenSet is the right type for a lookup-only set anyway. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
All three findings were one theme: the rationale referenced the "Plugin Catalog settings tab", but AddPluginCatalogSettingsTab() currently has no call sites in src/ (#805 removed the wiring — #811 part B). The consumer of the Plugins partition read is CatalogLayoutAreas.ObserveInstalled, so the code comment, the test's xmldoc/assertion message and the What's New entry now describe the installed-state query generically, and the What's New description matches the actual access model (a read-only public policy) instead of "every signed-in user". Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CsVb5f4N6KuJnvLaNrvjDB
Why
A partition with a
_GitSyncis rewritten from its repo on every sync, so the only identity that may write it is the one the importer runs as (system-security). 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.Core already refused to re-mint a creator grant when a deploy writes into a GitSynced partition (
EnsurePartitionBootstrap). Two holes remained:_GitSyncexists is an ordinary space until the sync is wired, so its creator gets Admin the ordinary way and keeps it. Measured on memex 2026-08-04:SST/_Access/rbuergi_Access(Admin) at14:48:07,SST/_GitSyncat14:48:14.An audit of the three live meshes found 42 privileged grants on synced spaces (17 Admin + 17 Editor on memex, 7 Admin on systemorph, 1 on client-i). They have been retracted live; this PR is what stops them coming back.
What changed
AccessAssignmentGuard.IsForbiddenOnSystemOwned— pure, hub-free, unit-tested. Wired at both write boundaries inMeshExtensions: guarding onlycreatewould let anupsertraise an existing Viewer to Admin, which is the identical ownership claim with a version bump.Viewergrant — written on every enrollment — never touches persistence.JsonElementtest. A shape test that missed would read "no roles" and silently allow exactly what the guard exists to refuse.SystemOwnedAccessRetractionHandler(INodePostCreationHandleronGitHubSyncConfig) — the moment a partition gains a_GitSyncit becomes system-owned, so privileged account grants on it are retracted right there: under System, sequentially (Concat, neverMerge— these are writes into a partition that may be seconds old), each one logged at Warning. Self-healing for any mesh that already drifted./Store→ the card → Provision), the only surface that runs the install under System viaSystemInstall. The admin page duplicated that flow while bypassing the funnel.AddPluginCatalog()— the registry API serving plugins to every other instance — is untouched.What deliberately still works
Read-only roles are an allowlist, not a denylist, so an unrecognised custom role is refused rather than waved through. Untouched:
Viewer/Commenterentitlements (a purchase, a redeemed coupon, an admin grant — the only door into a gated plugin), everyDeniedassignment (that is the plugin gating),system-securityitself, and user-home/tenant spaces with no_GitSync.Tests
AccessAssignmentGuardTest+15 cases: each privileged role refused (incl. an unknown custom one), each entitlement role allowed, the System identity allowed, aDeniedEditor allowed, a non-system-owned space untouched, andPartitionOfon nested scopes. The allow/refuse assertions run in both directions, so a constant return fails one set or the other.MeshWeaver.Graph.Test— 802 passedMeshWeaver.Hosting.Monolith.Test(bootstrap/access/space filter) — 55 passed🤖 Generated with Claude Code