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 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 {partition}/_GitSync is rewritten from its repo on every sync, so the only identity that may write it is system-security. AccessAssignmentGuard.IsForbiddenOnSystemOwned refuses any Admin/Editor/unknown-role grant there (Viewer/Commenter entitlements and every Denied assignment stay legal), and SystemOwnedAccessRetractionHandler deletes such grants the moment the _GitSync is wired — so the shape is unsatisfiable, not merely inconvenient.

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.


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.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
Reconnecting…
The server was updated. Reloading the page to pick up the latest version.