Granting Access via AccessAssignments

Permissions in MeshWeaver are data — they live as AccessAssignment MeshNodes inside _Access satellite namespaces. You grant access either through the Access Control UI (Settings → Access Control on any node you administer) or by creating an AccessAssignment node via MCP, a hub message, or the migration runner. Either way, the PermissionEvaluator picks the node up automatically via its synced query — the UI is just a convenient writer of the same data.

This page walks through the UI, the field anatomy, and copy-paste recipes for the most common scenarios.

rbuergi/ Partition root rbuergi/_Access/ Satellite namespace /_Access segment AccessAssignment accessObject: rbuergi mainNode: rbuergi roles: [Admin] MeshNode at {id} PermissionEvaluator synced query on nodeType:AccessAssignment ~1s pickup Postgres triggers rebuild effective perms SatelliteAccessRule checks mainNode to scope permission user_effective_permissions ready for read requests

AccessAssignment nodes live in _Access satellite namespaces; PermissionEvaluator picks them up via a synced query and Postgres triggers rebuild effective permissions automatically.


The Access Control UI

Open a node you administer → Settings → Access Control. The page shows the assignments inherited from the parent scope (read-only), the editable assignments at the current scope, an inline Add row, and a collapsed Advanced section for the partition policy.

The Subject (User or Group) picker is bound to the canonical queries in AccessSubjectQueries (src/MeshWeaver.Mesh.Contract/Security/AccessSubjectQueries.cs):

The picker loads the subject set once (capped at 500 nodes) and filters in-memory, diacritic- and case-insensitively (SearchText.Fold): typing "Burgi" finds "Bürgi". On installations with more subjects than the cap, typed text additionally runs the normal server-side search and the union is shown, so users beyond the cap remain findable (that path matches by substring, without diacritic folding).

Not-yet-provisioned users — grant by email. A User node is created at first login/onboarding, so a person who has never signed in has no node to pick. The Add row therefore also takes an email: leave the subject picker empty and type the address instead. If an account already exists it is granted the selected role immediately; otherwise the person is invited and a durable deferred grant — an EventSubscription (see EventSubscriptions) — lands the same role at this exact {scope}/_Access the moment they sign up. The scheduled grant is byte-identical to the immediate one, so the invitee ends up with exactly the assignment an already-provisioned subject would. (You can still grant by principal via MCP — Recipe 3 below with the exact login userId — for a fully headless setup.)

Bulk-inviting a whole list into a group. A Group node's Edit area has an Invite by Email button: paste a list of emails (newline / comma / semicolon separated; Name <email> entries work) and pick a role. Every entry becomes a group member with that role granted on the group — existing accounts immediately, everyone else via invitation email + the same deferred EventSubscription mechanics (AddToGroup carrying the role), landing membership + grant the moment they register. Junk tokens are skipped and reported, and re-running a list is idempotent.


Anatomy of an AccessAssignment

Every assignment is a MeshNode whose placement and content together determine what it grants.

Field Meaning
path Where the assignment lives — must be {scope}/_Access/{id} (Admin/_Access/{id} for platform admins — see Recipe 2).
mainNode The path the assignment scopes to. Must equal {scope} from the path above.
accessObject The user or group this grants permissions to. Matched against userId.
roles[].role The role names the user gets at this scope — typically Admin or Editor.

🚨 Both path (via the /_Access/ segment) and mainNode matter.
The PermissionEvaluator's SatelliteAccessRule uses mainNode to decide which partition or subtree the assignment binds to. If mainNode is empty for a non-global assignment, the assignment is silently ignored and the user gets zero permissions.
Symptom: Access denied: user 'X' lacks Read permission on '{scope}/Y' even though an AccessAssignment exists at {scope}/_Access/X_Access.

🚨 The namespace must end in /_Access.
The PermissionEvaluator's synced query only routes namespaces that match this pattern, and nodes with an _Access segment land in the partition's access table. Place the node anywhere else and it never reaches the security pipeline.


Recipe 1 — Grant a user Admin on their partition

This is the most common setup: giving a user Admin access over every node under their own partition (e.g. rbuergi/...).

MCP (bash):

mcp create --node '{
  "id": "rbuergi_Access",
  "namespace": "rbuergi/_Access",
  "name": "rbuergi Access",
  "nodeType": "AccessAssignment",
  "mainNode": "rbuergi",
  "content": {
    "$type": "AccessAssignment",
    "accessObject": "rbuergi",
    "displayName": "rbuergi",
    "roles": [ { "$type": "RoleAssignment", "role": "Admin" } ]
  }
}'

C# / migration:

new MeshNode("rbuergi_Access", "rbuergi/_Access")
{
    Name = "rbuergi Access",
    NodeType = AccessAssignmentNodeType.NodeType,
    MainNode = "rbuergi",
    State = MeshNodeState.Active,
    Content = new AccessAssignment
    {
        AccessObject = "rbuergi",
        DisplayName  = "rbuergi",
        Roles = ImmutableList.Create(new RoleAssignment { Role = "Admin" })
    }
}

After the node is created, the PermissionEvaluator's synced query picks it up within about one second. The access_changed Postgres trigger then rebuilds partition_access and user_effective_permissions automatically — no restart needed.

Fixing an existing assignment with empty mainNode

If an assignment already exists but mainNode is empty (the node shows up in mcp search nodeType:AccessAssignment partitions:all yet permissions still fail), patch it with a full update:

mcp update --nodes '[{
  "id": "rbuergi_Access",
  "namespace": "rbuergi/_Access",
  "path": "rbuergi/_Access/rbuergi_Access",
  "mainNode": "rbuergi",
  "name": "rbuergi Access",
  "nodeType": "AccessAssignment",
  "state": "Active",
  "content": {
    "$type": "AccessAssignment",
    "accessObject": "rbuergi",
    "displayName": "rbuergi",
    "roles": [ { "$type": "RoleAssignment", "role": "Admin" } ]
  }
}]'

Note: mcp patch does apply mainNode — it is in MeshOperations.PatchableFields alongside name, description, icon, category, order, content, preRenderedHtml, and the write boundary re-validates the merged node. (It did not, until 2026-08-05: seven {"mainNode":"…"} patches against root-scoped grants each returned "Patched" and changed nothing, leaving a mesh-wide escalation in place. Patch now refuses any key outside that list rather than reporting a success it did not perform — so if a patch is silently dropped again, you get an error, not a lie.) mcp update with a full node body still works and is what the block above shows.


Recipe 2 — Grant a user Global (Platform) Admin

🚨 NEVER make anyone a global admin unless it is a named platform operator and you mean it — see Access Control. Global/platform admin has one canonical shape: an AccessAssignment granting the Admin role on the Admin partition — namespace Admin/_Access, mainNode: "Admin". ⚠️ An EMPTY mainNode is NOT "scoped to Admin" — it is a ROOT grant, i.e. a data superuser over every partition. Verify with select user_id from admin.user_effective_permissions where node_path_prefix = '' — that result must be empty. This is exactly what GlobalAdminSeed (config-driven admins via Auth:GlobalAdmins) and UserOnboardingService.GrantPlatformAdmin (first user) write, and what hub.IsGlobalAdmin() reads.

mcp create --node '{
  "id": "rbuergi_Access",
  "namespace": "Admin/_Access",
  "name": "rbuergi — Admin",
  "nodeType": "AccessAssignment",
  "mainNode": "Admin",
  "content": {
    "$type": "AccessAssignment",
    "accessObject": "rbuergi",
    "displayName": "rbuergi",
    "roles": [ { "$type": "RoleAssignment", "role": "Admin" } ]
  }
}'

PermissionEvaluator's global-admin short-circuit turns Permission.All at scope Admin into the platform-admin gates (hub.IsGlobalAdmin(), admin tabs, invites, config).

🚨 Never grant at the root _Access namespace, and never leave mainNode empty. A root-level Admin assignment is the data-superuser shape — standing Permission.All on every partition's data — and is deliberately not how platform admins are provisioned. Platform admins manage the platform; emergency cross-partition data access goes through explicit elevation (break-glass), never a standing grant. See AccessControl → "The Admin partition".

This is enforced at the write boundary. AccessAssignmentGuard.IsScopeInvalid (src/MeshWeaver.Mesh.Contract/Services/AccessAssignmentGuard.cs) refuses any AccessAssignment whose mainNode disagrees with the scope its path encodes — so Admin/_Access/{id} with mainNode: "" is rejected with "has an EMPTY MainNode … this grants ROOT (every partition), not 'Admin'". A deliberately consistent root grant (_Access/{id} and mainNode: "") still passes, because the test harness uses that shape; the access-control UI never offers it (AccessAssignmentGuard.CanGrantAt returns false at root).

🚨 A SYSTEM-OWNED partition grants nobody write — and a grant COMMITTED TO A REPO can never be one. A partition with a one-way {partition}/_GitSync is rewritten from its repo on every sync, so the only identity that may write it is system-security. Two enforcement points, one predicate (AccessAssignmentGuard.IsSystemOwned): IsForbiddenOnSystemOwned refuses any Admin/Editor/unknown-role grant there, and SystemOwnedAccessRetractionHandler retracts every such grant — including the partition's last administrator — when the _GitSync is wired. Viewer/Commenter entitlements, every Denied assignment and system-security itself stay. A bijective (twoWay: true) sync is a different contract and is not system-owned: there the mesh nodes are the working copy and the people editing them keep write access.

🚨 It did not hold until #5140, and the way it failed is worth knowing — because three separate symptoms were one mechanism, and the middle one was the platform working as documented. The retraction sweep used to SPARE a partition's last administrator, deliberately: SpaceAdminInvariantValidator ("a space must always have at least one admin") would have refused that delete, so the sweep detected it up front rather than logging a guaranteed failure. But the same guard refuses granting a second Admin on a system-owned partition, so the sweep's stated escape — "retracted next time, once another admin exists" — could never arrive. A repo-owned space therefore kept one human Admin for ever, conferring write over content whose edits the next sync reverts, and a reporter reasonably read a working sweep as one that had never run. Both halves are fixed: the invariant exempts a system-owned partition (its administrator is the System identity, which holds Permission.All with no assignment node), and the sweep no longer spares anything. Fixing only one would have left the other silently in charge.

🚨 And then it still did not hold where it mattered: on partitions synced BEFORE that fix. Both of those enforcement points act on an EVENT — a grant being written, a _GitSync being created — and neither ever looks at a partition again. Measured 2026-10-08: Home and Provider on memex.meshweaver.cloud and Deployments on memex.systemorph.com each still carried a human Admin grant beside a one-way sync, and a direct edit of a Deployments record by that Admin had been accepted the day before. So the rule is now also a property of the PARTITION, asked by the permission fold itself: PermissionEvaluator reads each partition's sync config as one more cached, unseeded leg (SecurityQueries.PartitionSyncConfig), and on a system-owned partition it caps Create, Update and Delete out of every role on any CONTENT path — every path with no _-prefixed segment, the partition root included. A cap in the fold, not a validator, because three seams enforce writes and they must give one answer: an update by GetMeshNodeStream(..).Update is a patch whose only gate is the [RequiresPermission] delivery gate on the owner, a create or delete passes the RLS node validator, a recursive delete passes the pre-flight — and the menus read the same fold, so no Edit is offered that the write would refuse. System short-circuits above the fold, so the importer is untouched; Read and the non-CRUD capabilities are untouched; a bijective sync is untouched. The _-prefixed satellites stay on their own rules (_Access is already capped to entitlements and is where the platform admin's #5904 repair writes; _GitSync keeps its own access rule, so a platform admin may still delete it — which is how a partition stops being system-owned; comments, threads and activities are what an entitlement exists for). A refused content write now says why (PartitionWriteGuardValidator.DescribeDeniedWrite) instead of reporting a store/fold disagreement. The way to change repo-owned content is a pull request to the repo. The settings tab's re-import at a chosen commit (ReimportFromGitHub) used to run under the caller, so on a system-owned Space it worked only for a leftover grant holder; it now goes through the same "the click authorizes, System executes" trigger as update and commit, with the commit authority (Update on the Space, or a platform admin), because choosing the commit a Space is mirrored to is more than converging it to the branch head. Pinned by SystemOwnedPartitionRefusesNonSystemWritesTest (update, delete and the fold, with a bijective partition and a System write as controls).

The practical consequence for anyone editing a node repo: a privileged _Access/*.json in a synced data tree is dead data that logs a fail: line on every sync. That is exactly what shipped in samples/Graph/Data until #1245 — 75 refusals in 0.36 s per sync, and the import permanently ImportedWithErrors. ShippedAccessGrantsTest (test/MeshWeaver.Graph.Test) now fails the build if one is re-added. Platform admin belongs in Auth:GlobalAdmins (GlobalAdminSeed writes the Admin/_Access grant at startup); per-space write is granted on the live mesh.

🚨 A plugin's content is reached ONLY through a subscription and its tier. A published package is listed in the App Store; its cover and guide are the storefront, and NO ONE holds access to its content by default. A Viewer/Commenter entitlement is minted for ONE person when THAT person acquires the package under a plan whose tier covers it — the free tier included. Never grant a plugin to users in bulk: on 2026-09-29 a Store sweep granted a newly published free plugin to 72 users in three minutes, none of whom had acquired it (MeshWeaver.Plugins#2572 removed the sweep). Grants the system issues never notify (MeshWeaver#5901). Maintainer directive, 2026-09-30.


Recipe 3 — Grant another user access to your partition

Once you have Admin rights on a partition, you can extend access to other users. Here, rbuergi gives alice Editor rights on the rbuergi partition:

mcp create --node '{
  "id": "alice_Access",
  "namespace": "rbuergi/_Access",
  "name": "alice Access (Editor)",
  "nodeType": "AccessAssignment",
  "mainNode": "rbuergi",
  "content": {
    "$type": "AccessAssignment",
    "accessObject": "alice",
    "displayName": "Alice",
    "roles": [ { "$type": "RoleAssignment", "role": "Editor" } ]
  }
}'

The shape is identical to Recipe 1 — only accessObject and displayName differ.


Verification

After creating or updating an assignment, run through this checklist:

  1. mcp search nodeType:AccessAssignment scope:descendants --basePath {scope} — confirm the node landed in the right partition.
  2. mcp get @{scope}/_Access/{id} — confirm mainNode is set correctly.
  3. Refresh the user's home page in the portal — the Activity area should render its MeshSearch panels without an Access denied red banner.
  4. Optional SQL sanity check:
    select * from "rbuergi".access where namespace = 'rbuergi/_Access';
    -- user_effective_permissions is PER PARTITION SCHEMA (there is no global one):
    select * from "rbuergi".user_effective_permissions where user_id = 'rbuergi';
    -- partition_access, by contrast, IS shared and lives in public:
    select * from public.partition_access where user_id = 'rbuergi';
    
    The first query should show the row; the second should show rebuilt permission rows for the partition, and the third the partition's read entry.

Common pitfalls

Symptom Likely cause
Access denied despite an AccessAssignment existing mainNode is empty for a non-root assignment
The write is refused with "has an EMPTY MainNode … this grants ROOT" AccessAssignmentGuard doing its job — set mainNode to the scope the path encodes
Error: cannot patch … 'x' is not patchable Only name, description, icon, category, order, content, preRenderedHtml, mainNode are patchable; use mcp update with a full node for anything else
Search finds the assignment but it has no effect Namespace doesn't end in /_Access — node landed in the wrong table
The write is refused with "REFUSED privileged grant on system-owned partition" The partition has a _GitSync — it is owned by its repo. Grant Viewer/Commenter as an entitlement, or change the repo and sync it
The grant exists but the user has no permissions at all The role name is not one the mesh defines (Admin, Editor, Viewer, Commenter, PlatformAdmin) — an unknown role resolves to Permission.None while still counting as WRITE at the guard
Public→Admin works but per-user denials fail in a test Tests must use a per-user accessObject, not a Public assignment whose union bypasses negative-permission assertions

Source references

File Purpose
src/MeshWeaver.Graph/Configuration/AccessAssignmentNodeType.cs NodeType definition and post-create handler that rebuilds permissions
src/MeshWeaver.Graph/Security/RlsNodeValidator.cs Read-side enforcer that surfaces Access denied
src/MeshWeaver.Mesh.Contract/Security/PermissionEvaluator.cs Synced query that aggregates AccessAssignments per user
src/MeshWeaver.Mesh.Contract/Services/AccessAssignmentGuard.cs Write-boundary guard: mainNode must equal the scope the path encodes; no write-conferring grant on a system-owned (GitSynced) partition
src/MeshWeaver.Mesh.Contract/Security/PermissionEvaluator.cs (ObserveRepositoryOwnedContent) Same predicate, asked by the fold: no role confers Create/Update/Delete on the CONTENT of a system-owned partition, whatever grants exist
src/MeshWeaver.GitSync/SystemOwnedAccessRetractionHandler.cs Same predicate, applied as a sweep when a _GitSync is wired — retracts privileged grants that predate the sync
src/MeshWeaver.Graph/Configuration/SpaceAdminInvariantValidator.cs "At least one admin per partition" — exempts system-managed mirrors AND system-owned (one-way GitSynced) partitions, so the sweep above can converge
test/MeshWeaver.Graph.Test/ShippedAccessGrantsTest.cs Structural guard: no _Access file committed to this repo may confer write, or name an undefined role
MeshWeaver.Plugins/src/MeshWeaver.Hosting.PostgreSql/PostgreSqlSchemaInitializer.cs access_changed trigger that rebuilds partition_access and user_effective_permissions