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 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
{partition}/_GitSyncis rewritten from its repo on every sync, so the only identity that may write it issystem-security.AccessAssignmentGuard.IsForbiddenOnSystemOwnedrefuses anyAdmin/Editor/unknown-role grant there (Viewer/Commenterentitlements and everyDeniedassignment stay legal), andSystemOwnedAccessRetractionHandlerdeletes such grants the moment the_GitSyncis wired — so the shape is unsatisfiable, not merely inconvenient.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.
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.GitSync/SystemOwnedAccessRetractionHandler.cs |
Same predicate, applied as a sweep when a _GitSync is wired — retracts privileged grants that predate the sync |
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 |