Skip to content

A system-owned space grants nobody write access - #805

Merged
rbuergi merged 2 commits into
mainfrom
fix/system-owned-no-account-grants
Aug 5, 2026
Merged

rbuergi merged 2 commits into
mainfrom
fix/system-owned-no-account-grants

Conversation

@rbuergi

@rbuergi rbuergi commented Aug 5, 2026 •

Copy link
Copy Markdown
Contributor

Why

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 (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:

  1. The seven-second window. A Space hand-created before its _GitSync exists 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) at 14:48:07, SST/_GitSync at 14:48:14.
  2. Nothing refused a grant written onto an already system-owned space — by hand, or by an agent over MCP.

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 in MeshExtensions: guarding only create would let an upsert raise an existing Viewer to Admin, which is the identical ownership claim with a version bump.
    • The storage probe is paid for only by the shape that can fail: the pure checks (node type, grant path, confers-write) run first, so an entitlement's Viewer grant — written on every enrollment — never touches persistence.
    • Content is read through the typed accessor, never a raw JsonElement test. A shape test that missed would read "no roles" and silently allow exactly what the guard exists to refuse.
  • SystemOwnedAccessRetractionHandler (INodePostCreationHandler on GitHubSyncConfig) — the moment a partition gains a _GitSync it becomes system-owned, so privileged account grants on it are retracted right there: under System, sequentially (Concat, never Merge — these are writes into a partition that may be seconds old), each one logged at Warning. Self-healing for any mesh that already drifted.
  • Plugin Catalog settings tab removed. Browsing and provisioning packages is the Store's job (/Store → the card → Provision), the only surface that runs the install under System via SystemInstall. 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/Commenter entitlements (a purchase, a redeemed coupon, an admin grant — the only door into a gated plugin), every Denied assignment (that is the plugin gating), system-security itself, 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, a Denied Editor allowed, a non-system-owned space untouched, and PartitionOf on nested scopes. The allow/refuse assertions run in both directions, so a constant return fails one set or the other.

  • MeshWeaver.Graph.Test — 802 passed
  • MeshWeaver.Hosting.Monolith.Test (bootstrap/access/space filter) — 55 passed

🤖 Generated with Claude Code

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>
Copilot AI lite review requested due to automatic review settings August 5, 2026 07:23

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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}/_Access when {partition}/_GitSync is 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.

Comment on lines +136 to +141
/// 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" };
Comment on lines +210 to +214
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 +1662 to +1668
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);
@github-actions

github-actions Bot commented Aug 5, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

2 170 tests   1 982 ✅  4m 43s ⏱️
   12 suites    188 💤
   12 files        0 ❌

Results for commit 76c1060.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 5, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

   12 files     12 suites   4m 3s ⏱️
1 078 tests 1 074 ✅ 4 💤 0 ❌
1 149 runs  1 145 ✅ 4 💤 0 ❌

Results for commit 76c1060.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 5, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

1 204 tests   1 203 ✅  5m 58s ⏱️
   12 suites      1 💤
   12 files        0 ❌

Results for commit 76c1060.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 5, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

1 083 tests   980 ✅  4m 38s ⏱️
   10 suites  103 💤
   10 files      0 ❌

Results for commit 76c1060.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 5, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

987 tests   981 ✅  4m 26s ⏱️
  5 suites    6 💤
  5 files      0 ❌

Results for commit 76c1060.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 5, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

1 248 tests   1 247 ✅  9m 23s ⏱️
   10 suites      1 💤
   10 files        0 ❌

Results for commit 76c1060.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 5, 2026 •

Copy link
Copy Markdown
Contributor

Test Results

   61 files     61 suites   33m 13s ⏱️
7 770 tests 7 467 ✅ 303 💤 0 ❌
7 841 runs  7 538 ✅ 303 💤 0 ❌

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>
@rbuergi
rbuergi merged commit acf46c9 into main Aug 5, 2026
16 checks passed
rbuergi added a commit that referenced this pull request Aug 5, 2026
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
@rbuergi
rbuergi deleted the fix/system-owned-no-account-grants branch August 7, 2026 07:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants