Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions src/MeshWeaver.AI/Data/Skill/create-group.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
---
nodeType: Skill
name: /create-group
description: Create an access-control Group and put people in it — the group node, granting the group access to a space (one AccessAssignment whose subject is the group), adding members who already exist, and inviting members who don't yet (an email invite that lands group membership the moment they sign in, via the durable AddToGroup event subscription). Covers placement (one partition), the exact node shapes, and verification.
icon: People
category: Skills
order: 12
---

You are creating an **access-control Group** and populating it. A Group is a platform-shipped
`AccessObject` node; membership + access are **data** (like [/access](/access)): you grant the *group*
access once, then every member inherits it. Membership resolves through the permission matview's
group-expansion — so **the group, its memberships, and its space-grant must all live under the SAME
partition** (the matview resolves group access within a schema). Put the group *under the space* it serves.

# The model — grant the group once, add members forever

```
{Space}/{Group} ← the Group node (nodeType: Group)
{Space}/{Group}/{member}_Membership ← one GroupMembership per member (a CHILD of the group)
{Space}/_Access/{Group}_Access ← ONE AccessAssignment: accessObject = the group PATH
```

- **The group's `accessObject` in the grant is the group's PATH** (`{Space}/{Group}`), and each
membership's `groups[].group` is that **same path**. They must match exactly — that string is the join key.
- **`member` is the userId** — the User node's id (its partition-root id, e.g. `rbuergi`), the same value
`_Access` grants use as `accessObject`. NOT the email, NOT a display name.
- Everything under `{Space}` so it lands in the space's Postgres schema. A group in another partition
will not resolve access to this space.

# Recipe 1 — create the group (under the space it serves)

```bash
mcp create --node '{
"id": "Team", "namespace": "YouTube", "name": "YouTube Team",
"nodeType": "Group",
"content": { "$type": "AccessObject", "description": "Collaborators on the YouTube space" }
}'
```

# Recipe 2 — grant the group access to the space (do this ONCE)

An `AccessAssignment` at `{Space}/_Access` whose **subject is the group path**. Members inherit this.

```bash
mcp create --node '{
"id": "Team_Access", "namespace": "YouTube/_Access", "name": "YouTube Team — Editor",
"nodeType": "AccessAssignment", "mainNode": "YouTube",
"content": {
"$type": "AccessAssignment",
"accessObject": "YouTube/Team", "displayName": "YouTube Team",
"roles": [ { "$type": "RoleAssignment", "role": "Editor" } ]
}
}'
```

`mainNode` MUST equal the scope (`YouTube`) — an empty `mainNode` is silently ignored (see [/access](/access)).
Role is `Admin` / `Editor` / `Viewer` / `Commenter` or a custom `Role` id.

# Recipe 3 — add a member who ALREADY has an account

A `GroupMembership` node as a **child of the group**, id `{userId}_Membership`, `groups[].group` = the group path:

```bash
mcp create --node '{
"id": "rbuergi_Membership", "namespace": "YouTube/Team", "name": "rbuergi — Team",
"nodeType": "GroupMembership",
"content": {
"$type": "GroupMembership",
"member": "rbuergi", "displayName": "rbuergi",
"groups": [ { "$type": "MembershipEntry", "group": "YouTube/Team" } ]
}
}'
```

The creator/owner adds themselves this way. For any other person who has logged in before, use their
exact userId (their partition-root id — find it with `search nodeType:User content.email:{email}`).

# Recipe 4 — invite someone who does NOT have an account yet (durable onboarding)

Two nodes, both in the **Admin** partition. The `Invitation` triggers the email; the `EventSubscription`
(an `AddToGroup` continuation) adds them to the group the moment their `User` node is created — surviving
restarts (the `EventSubscriptionRunner` reconciles on boot). Both are idempotent by deterministic id.

```bash
# (a) the invitation — InvitationEmailSender emails any Pending invitation (see caveat below)
mcp create --node '{
"id": "beat_panimage_ch", "namespace": "Admin/Invitation", "name": "Invitation beat@panimage.ch",
"nodeType": "Invitation",
"content": { "$type": "Invitation", "email": "beat@panimage.ch", "invitedBy": "rbuergi",
"note": "Invited to group YouTube/Team" }
}'

# (b) the durable subscriber — add to the group on sign-up. Id: addgroup_{slug(email)}_{slug(groupPath)}
mcp create --node '{
"id": "addgroup_beat_panimage_ch_youtube_team", "namespace": "Admin/EventSubscription",
"name": "NodeChange → AddToGroup", "nodeType": "EventSubscription",
"content": {
"$type": "EventSubscription",
"id": "addgroup_beat_panimage_ch_youtube_team",
"triggerType": "NodeChange", "triggerNodeType": "User", "triggerKind": "Created",
"matchField": "email", "matchValue": "beat@panimage.ch",
"continuationType": "AddToGroup", "targetPath": "YouTube/Team",
"createdBy": "rbuergi"
}
}'
```

- **`slug(x)`** = lowercase, every non-alphanumeric → `_` (so `beat@panimage.ch` → `beat_panimage_ch`,
`YouTube/Team` → `youtube_team`). The invitation node id is `slug(email)`; a re-invite upserts the same
nodes instead of duplicating.
- Enums serialise by **name**: `"triggerType": "NodeChange"`, `"triggerKind": "Created"`,
`"continuationType": "AddToGroup"`.
- **The invitation is what unlocks onboarding** in invitation-only mode AND what sends the email —
create it even if you only care about the durable grant.

**Code equivalent (one call does both branches):** `hub.InviteToGroup(groupPath, email, invitedBy)` —
existing user → membership now; unknown → invitation + `AddToGroup` subscription. Returns
`GroupInviteOutcome.Added` / `.Invited`. It is the group twin of `SpaceInviteService.Invite`.

# 🚨 Email caveat — verify it actually sends

The invitation email only goes out when the portal has **`Email:Enabled = true`** (Microsoft Graph
`Mail.Send`). It defaults to **false** → `NoOpEmailSender` just logs. If email is off, the invitation
still authorises onboarding and the durable grant still fires — but **no email is sent**; tell the user
rather than reporting a phantom send. Check the deployment's `Email__Enabled`.

# Verify — never declare done without this

1. `mcp get @{Space}/{Group}` → the group exists.
2. `mcp get @{Space}/_Access/{Group}_Access` → the grant exists with `mainNode == {Space}` and the group
path as `accessObject`.
3. Members: `search namespace:{Space}/{Group} nodeType:GroupMembership` lists the memberships.
4. Invitees: `get @Admin/Invitation/{slug}` (Pending) AND
`get @Admin/EventSubscription/addgroup_{slug(email)}_{slug(group)}` (Pending → Fired after they onboard).
5. Effect: an existing member reading a node under `{Space}` no longer sees `Access denied` (propagation ~1 s).

# Pitfalls — each makes membership silently grant nothing

| Symptom | Cause |
|---|---|
| Member added, still `Access denied` | group's `AccessAssignment` `mainNode` empty, or `accessObject` ≠ the group PATH |
| Member added, still no access | membership's `groups[].group` ≠ the grant's `accessObject` (must be the same path string) |
| Group in another partition doesn't work | group/memberships/grant not co-located in the space's schema — put them all under `{Space}` |
| Invitee never lands in the group | `EventSubscription` not in `Admin/EventSubscription`, or `matchField`/`matchValue` ≠ the User's email |
| No invite email | `Email:Enabled=false` (NoOp) — the grant still works, but nothing was emailed |

# Related

- [/access](/access) — the AccessAssignment shape, roles, and the `mainNode` rule this builds on
- [/create-space](/create-space) — create the space the group serves
- [Granting Access via AccessAssignments](/Doc/Architecture/GrantingAccess) · [Access Control Architecture](/Doc/Architecture/AccessControl)
22 changes: 22 additions & 0 deletions src/MeshWeaver.Graph/EventSubscriptionOps.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,28 @@ public static IObservable<MeshNode> Grant(IMeshService meshService, string userI
return meshService.CreateOrUpdateNode(node);
}

/// <summary>Adds <paramref name="userId"/> to the group at <paramref name="groupPath"/> by creating the
/// <c>{groupPath}/{user}_Membership</c> <see cref="GroupMembership"/> node (create-or-update = idempotent).
/// The membership node is a child of the group so it lands in the group's partition schema, where the
/// permission matview's group-expansion CTE reads it (<c>content.member</c> + <c>content.groups[].group</c>)
/// and joins the group's <c>AccessAssignment</c> — so the user inherits whatever the group is granted.</summary>
public static IObservable<MeshNode> AddToGroup(IMeshService meshService, string userId, string groupPath)
{
var idSafe = userId.Replace('/', '_').Replace('@', '_');
var node = new MeshNode($"{idSafe}_Membership", groupPath)
{
NodeType = GroupMembershipNodeType.NodeType,
Name = $"{userId} — {groupPath.Split('/').LastOrDefault()}",
Content = new GroupMembership
{
Member = userId,
DisplayName = userId,
Groups = [new MembershipEntry { Group = groupPath }],
},
};
return meshService.CreateOrUpdateNode(node);
}

/// <summary>Adds <paramref name="path"/> to <paramref name="userId"/>'s pinned dashboard list (idempotent).</summary>
public static IObservable<MeshNode> Pin(IMessageHub hub, string userId, string path)
=> hub.GetWorkspace().GetMeshNodeStream(userId).Update(node =>
Expand Down
3 changes: 3 additions & 0 deletions src/MeshWeaver.Graph/EventSubscriptionRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,9 @@ private IObservable<MeshNode> BuildContinuation(EventSubscription subscription,
.SelectMany(g => subscription.Pin
? AsSystem(() => EventSubscriptionOps.Pin(hub, userId, subscription.TargetPath!)).Select(_ => g)
: Observable.Return(g)),
EventContinuationType.AddToGroup
when subscription is { TargetPath.Length: > 0 } && !string.IsNullOrEmpty(userId) =>
AsSystem(() => EventSubscriptionOps.AddToGroup(meshService, userId, subscription.TargetPath!)),
_ => Observable.Throw<MeshNode>(new InvalidOperationException(
$"Unsupported or incomplete event subscription {subscription.Id}")),
};
Expand Down
102 changes: 102 additions & 0 deletions src/MeshWeaver.Graph/GroupInviteExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
using System.Reactive.Linq;
using MeshWeaver.Data;
using MeshWeaver.Graph.Configuration;
using MeshWeaver.Mesh;
using MeshWeaver.Mesh.Services;
using MeshWeaver.Messaging;
using Microsoft.Extensions.DependencyInjection;

namespace MeshWeaver.Graph;

/// <summary>The result of an <see cref="GroupInviteExtensions.InviteToGroup"/> call.</summary>
public enum GroupInviteOutcome
{
/// <summary>The email already had an account — the user was added to the group immediately.</summary>
Added,
/// <summary>The email is not on the system yet — an invitation was created and a durable
/// <see cref="EventSubscription"/> will add them to the group the moment their account is created.</summary>
Invited,
}

/// <summary>
/// Invites a person (by email) to a <b>group</b> — the group twin of <see cref="SpaceInviteService"/>,
/// composed from existing pieces. Stateless, so a static extension rather than a DI service: the two deps
/// (<see cref="IMeshService"/>, <see cref="AccessService"/>) are resolved off
/// <see cref="IMessageHub.ServiceProvider"/> per the "static handlers for one-shot pipelines" rule.
/// <list type="bullet">
/// <item><b>Already on the system</b> → add them to the group now (a <c>GroupMembership</c> node) via
/// <see cref="EventSubscriptionOps.AddToGroup"/> — runs under the CALLER's identity.</item>
/// <item><b>Not yet on the system</b> → create an <c>Invitation</c> (the existing
/// <c>InvitationEmailSender</c> emails any Pending invitation; in invitation-only mode it also unlocks
/// onboarding) AND an <see cref="EventSubscription"/> whose <see cref="EventContinuationType.AddToGroup"/>
/// continuation adds them to the group the moment a <c>User</c> with that email is created — surviving
/// restarts via <c>EventSubscriptionRunner</c>. The Admin-partition writes run as system.</item>
/// </list>
/// Grant the group its access ONCE (an <c>AccessAssignment</c> at <c>{scope}/_Access</c> whose
/// <c>accessObject</c> is the group path); every member then inherits it — membership resolves through the
/// permission matview's group-expansion CTE. Keep the group, its memberships, and its grant under the same
/// partition (the matview resolves group access within a schema).
/// </summary>
public static class GroupInviteExtensions
{
/// <summary>Invites <paramref name="email"/> to the group at <paramref name="groupPath"/>. Adds the
/// user now if they already exist, otherwise invites + schedules the durable add-on-sign-up.</summary>
public static IObservable<GroupInviteOutcome> InviteToGroup(
this IMessageHub hub, string groupPath, string email, string? invitedBy)
{
var meshService = hub.ServiceProvider.GetRequiredService<IMeshService>();
var accessService = hub.ServiceProvider.GetRequiredService<AccessService>();
var normalizedEmail = email.Trim();

// Look up an existing account by email (one-shot initial snapshot).
return meshService.Query<MeshNode>(MeshQueryRequest.FromQuery($"nodeType:User content.email:{normalizedEmail}"))
.Where(c => c.ChangeType == QueryChangeType.Initial)
.Select(c => c.Items)
.Take(1)
.SelectMany(users =>
{
var existing = users.FirstOrDefault(u => EmailMatches(hub, u, normalizedEmail));
return existing is not null
? EventSubscriptionOps.AddToGroup(meshService, existing.Id, groupPath)
.Select(_ => GroupInviteOutcome.Added)
: ScheduleAndInvite(meshService, accessService, groupPath, normalizedEmail, invitedBy);
});
Comment on lines +47 to +63
}

private static IObservable<GroupInviteOutcome> ScheduleAndInvite(
IMeshService meshService, AccessService accessService, string groupPath, string email, string? invitedBy)
{
var subscription = new EventSubscription
{
// Deterministic id per invitee+group → a re-invite upserts the SAME subscription (idempotent).
Id = $"addgroup_{SpaceInviteService.Slug(email)}_{SpaceInviteService.Slug(groupPath)}",
TriggerType = EventTriggerType.NodeChange,
TriggerNodeType = "User",
TriggerKind = MeshChangeKind.Created,
MatchField = "email",
MatchValue = email,
ContinuationType = EventContinuationType.AddToGroup,
TargetPath = groupPath,
CreatedBy = invitedBy,
};
var invitation = new MeshNode(SpaceInviteService.Slug(email), InvitationNodeType.Namespace)
{
NodeType = InvitationNodeType.NodeType,
Name = $"Invitation {email}",
Content = new Invitation { Email = email, InvitedBy = invitedBy, Note = $"Invited to group {groupPath}" },
};

// Both writes land in the Admin partition → system identity, constructed inside the scope. Both are
// upserts keyed by a deterministic id/slug, so a re-invite overwrites rather than duplicating.
return Observable.Using(accessService.ImpersonateAsSystem, _ => Observable.Defer(() =>
EventSubscriptionOps.CreateSubscription(meshService, subscription)
.SelectMany(_ => meshService.CreateOrUpdateNode(invitation))))
.Select(_ => GroupInviteOutcome.Invited);
}

private static bool EmailMatches(IMessageHub hub, MeshNode userNode, string email)
{
var actual = EventSubscriptionOps.ReadContentField(userNode, "email", hub.JsonSerializerOptions);
return string.Equals(actual, email, StringComparison.OrdinalIgnoreCase);
}
}
9 changes: 7 additions & 2 deletions src/MeshWeaver.Mesh.Contract/EventSubscription.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,12 @@ public enum EventContinuationType
GrantSpaceAccess = 0,
/// <summary>Post the watched node's summary back into the thread at
/// <see cref="EventSubscription.TargetPath"/> as a new round (the durable delegation backstop, stage 5).</summary>
PostThreadMessage = 1
PostThreadMessage = 1,
/// <summary>Add the triggering user to the group at <see cref="EventSubscription.TargetPath"/> (a
/// <c>GroupMembership</c> node). The group-invite twin of <see cref="GrantSpaceAccess"/>: an email
/// invite to a <b>group</b> that lands membership — and, transitively, whatever the group is granted —
/// the moment the invitee's account exists.</summary>
AddToGroup = 2
}

/// <summary>
Expand Down Expand Up @@ -117,7 +122,7 @@ public record EventSubscription
public EventContinuationType ContinuationType { get; init; } = EventContinuationType.GrantSpaceAccess;

/// <summary>The node the continuation targets — for <see cref="EventContinuationType.GrantSpaceAccess"/>
/// the Space path.</summary>
/// the Space path, for <see cref="EventContinuationType.AddToGroup"/> the Group path.</summary>
public string? TargetPath { get; init; }

/// <summary>The subject the continuation acts on when the trigger carries no node (e.g. a
Expand Down
2 changes: 1 addition & 1 deletion test/MeshWeaver.AI.Test/SkillNodeTypeTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public void BuiltInSkillProvider_ShipsStandardSkills_AsPickSkillNodes()

var skills = nodes.Where(n => n.NodeType == SkillNodeType.NodeType).ToList();
skills.Should().OnlyContain(n => n.Namespace == SkillNodeType.RootNamespace);
skills.Select(n => n.Id).OrderBy(x => x).Should().Equal("access", "agent", "code", "create-space", "harness", "layout-area", "maui", "model", "navigate", "provider-keys", "slide");
skills.Select(n => n.Id).OrderBy(x => x).Should().Equal("access", "agent", "code", "create-group", "create-markdown", "create-space", "harness", "layout-area", "maui", "model", "navigate", "provider-keys", "slide");

var def = (SkillDefinition)skills.Single(n => n.Id == "model").Content!;
def.Action!.Kind.Should().Be(SkillActionKind.Pick);
Expand Down
Loading
Loading