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.
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):
- Users —
nodeType:User namespace:"". Users live at the ROOT namespace (path = userId); the path-less query is pinned to the centralauthlookup mirror byUserNodeType's routing rule, so one query covers every user in the mesh. 🚨 The legacynamespace:Usershape targets the pre-V27userschema, which no longer exists — it silently returns zero rows. Never hand-roll subject queries; referenceAccessSubjectQueries. - Groups —
nodeType:Group namespace:{partition} scope:subtree: every group defined in the scope's partition.
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) andmainNodematter.
ThePermissionEvaluator'sSatelliteAccessRuleusesmainNodeto decide which partition or subtree the assignment binds to. IfmainNodeis 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 anAccessAssignmentexists at{scope}/_Access/X_Access.
🚨 The namespace must end in
/_Access.
ThePermissionEvaluator's synced query only routes namespaces that match this pattern, and nodes with an_Accesssegment land in the partition'saccesstable. 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 patchdoes applymainNode— it is inMeshOperations.PatchableFieldsalongsidename,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.Patchnow 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 updatewith 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
_Accessnamespace, and never leavemainNodeempty. A root-levelAdminassignment is the data-superuser shape — standingPermission.Allon 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 anyAccessAssignmentwhosemainNodedisagrees with the scope its path encodes — soAdmin/_Access/{id}withmainNode: ""is rejected with "has an EMPTY MainNode … this grants ROOT (every partition), not 'Admin'". A deliberately consistent root grant (_Access/{id}andmainNode: "") still passes, because the test harness uses that shape; the access-control UI never offers it (AccessAssignmentGuard.CanGrantAtreturns 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}/_GitSyncis rewritten from its repo on every sync, so the only identity that may write it issystem-security. Two enforcement points, one predicate (AccessAssignmentGuard.IsSystemOwned):IsForbiddenOnSystemOwnedrefuses anyAdmin/Editor/unknown-role grant there, andSystemOwnedAccessRetractionHandlerretracts every such grant — including the partition's last administrator — when the_GitSyncis wired.Viewer/Commenterentitlements, everyDeniedassignment andsystem-securityitself 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 humanAdminfor 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 holdsPermission.Allwith 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
_GitSyncbeing created — and neither ever looks at a partition again. Measured 2026-10-08:HomeandProvideron memex.meshweaver.cloud andDeploymentson memex.systemorph.com each still carried a humanAdmingrant beside a one-way sync, and a direct edit of aDeploymentsrecord 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:PermissionEvaluatorreads 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 byGetMeshNodeStream(..).Updateis 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 (_Accessis already capped to entitlements and is where the platform admin's #5904 repair writes;_GitSynckeeps 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 asupdateandcommit, 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 bySystemOwnedPartitionRefusesNonSystemWritesTest(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/*.jsonin a synced data tree is dead data that logs afail:line on every sync. That is exactly what shipped insamples/Graph/Datauntil #1245 — 75 refusals in 0.36 s per sync, and the import permanentlyImportedWithErrors.ShippedAccessGrantsTest(test/MeshWeaver.Graph.Test) now fails the build if one is re-added. Platform admin belongs inAuth:GlobalAdmins(GlobalAdminSeedwrites theAdmin/_Accessgrant 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/Commenterentitlement 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:
mcp search nodeType:AccessAssignment scope:descendants --basePath {scope}— confirm the node landed in the right partition.mcp get @{scope}/_Access/{id}— confirmmainNodeis set correctly.- Refresh the user's home page in the portal — the
Activityarea should render itsMeshSearchpanels without anAccess deniedred banner. - Optional SQL sanity check:
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.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';
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 |