Invitation-Only Onboarding

By default the Memex portal is open — anyone who authenticates may self-provision an account through /onboarding (see Memex Cloud Deployment → Onboarding). Invitation-only mode narrows this: an admin invites an email address, the system emails that person from the M365 mailbox the portal sends and receives as, and only an invited email may complete onboarding. Every other email is refused at the gate.

Turn it on with one flag (see Feature Flags):

Features__Onboarding__InvitationOnly=true

Acceptance model — verified-email allowlist. There is no token link. The identity provider (Entra, Google, …) proves the user owns the email; a Pending invitation matching that verified email unlocks onboarding, after which the invitation flips to Accepted. The first-user bootstrap exception still applies — a brand-new deployment with zero User nodes always lets the very first user in, so it can never lock itself out.


End-to-end flow

AdminInvitations tabInvitation nodeAdmin/Invitation/Invitation emailMicrosoft GraphInvitee signs inIdP-verified emailOnboarding gatePending? → Accept

  1. An admin opens Settings → Administration → Invitations, enters an email, clicks Invite.
  2. InvitationService.CreateInvitation writes a Pending Invitation node. It does not send the email. InvitationEmailSender — a hosted service — watches Pending invitations whose EmailSentAt is null, sends through IEmailSender, and stamps EmailSentAt so it never re-sends. Decoupling it from the creation entry point means an invitation created from the settings tab, from MCP (create), or from a REST call is emailed exactly once.
  3. The invitee signs in via any configured IdP. OnboardingMiddleware finds no User node for the email and redirects to /onboarding.
  4. The onboarding gate looks up a Pending invitation for the verified email. If found, the profile form is shown; on submit the user is created and the invitation flips to Accepted. If not found, the page shows "Invitation Required" and CreateUser is refused.

Where invitations live

Invitations are MeshNodes of type Invitation stored in the always-present Admin partition at Admin/Invitation/{slug} (the slug is the lowercased email with non-alphanumerics replaced by _). The content record is Invitation: Email, InvitedBy, InvitedAt, Status (Pending/Accepted/Revoked), AcceptedAt, Note.

The onboarding gate must find an invitation by email, globally, before the user has any identity.

🚨 Invitation queries MUST be path-scoped — path:Admin/Invitation. InvitationNodeType registers a QueryRoutingHints { Partition = "Admin" } rule for path-less nodeType:Invitation queries, but that rule is currently inert: PostgreSqlPartitionedMeshQuery routes purely by the path's first segment and does not consume QueryRoutingHints yet. A path-less nodeType:Invitation query therefore goes through the cross-schema fan-out, which excludes the admin schema — so it silently returns zero rows and every invited user is refused. The rule is kept only so the hint is in place once the router honours it.

InvitationService, InvitationEmailSender and InvitationsSettingsTab all path-scope for this reason. Do the same in any new caller:

// ✅ path-scoped — resolves the admin schema via the first path segment
"path:Admin/Invitation scope:children nodeType:Invitation"

// ❌ path-less — inert routing hint, cross-schema fan-out skips `admin`, 0 rows
"nodeType:Invitation"

Why not the auth-mirror trigger? The V27 auth-mirror trigger only mirrors User/Group/Role/VUser/ApiToken into the auth schema; it would silently drop Invitation rows. Admin-partition storage + the routing rule avoids any schema/migration change. See Postgres Schema Architecture.

All invitation writes (create, accept, revoke) target the Admin partition where the caller has no rights, so InvitationService wraps them in accessService.ImpersonateAsSystem() — the same infrastructure-write pattern as UserOnboardingService.CreateUser. See Access Context Propagation.


The onboarding gate

The security boundary is the CreateUser call in Onboarding.razor, not the UI. The gate adds an invitation synced query alongside the existing first-user / username / email checks and, when InvitationOnly is on and this is not the first user, refuses unless a Pending invitation matches the email. On success it chains InvitationService.MarkAccepted. The page renders the form for invited users and an "Invitation Required" message for everyone else (messaging only — the real gate is at CreateUser).


The admin Invitations tab

InvitationsSettingsTab adds an Invitations tab under the Administration settings group, registered through AddGlobalSettingsMenuItems (wired in MemexConfiguration). The gate is AdminMenuGate.IsPlatformAdmin(host) — reactive, so the tab appears as soon as the platform-admin grant surfaces.

🚨 "Platform admin" means Permission.All at scope Admin, not root-level. A root-level grant is the data-superuser shape and is deliberately not how platform admins are provisioned — see Access Control → The Admin partition. (The XML doc comment on InvitationsSettingsTab still says "root-level Permission.All"; the code does not.)

The tab lets an admin enter an email + optional note and Invite (which creates the node — the hosted InvitationEmailSender sends the mail), lists all invitations with their status, and Revokes a Pending one.


Sending email (Microsoft Graph)

The portal had no email infrastructure; this feature adds a small sender that reuses your M365 tenant. It is configured by the Email section and disabled by default — when disabled, a NoOpEmailSender logs the would-be send and reports success, so local dev and tests never send mail.

Key Type Default Notes
Email:Enabled bool false When false, the NoOp sender is registered.
Email:MailboxAddress string "" The mailbox the portal sends and receives as — a real/shared mailbox (e.g. memex@yourtenant.com).
Email:TenantId string "" Entra tenant id (client-secret flow).
Email:ClientId string "" App-registration client id (client-secret flow).
Email:ClientSecret string "" App-registration client secret (keep in Key Vault).
Email:UseManagedIdentity bool false When true, authenticate via DefaultAzureCredential (managed identity) instead of a client secret.

GraphEmailSender calls Graph /users/{mailbox}/sendMail and returns IObservable<bool>. 🚨 It bridges the async Graph call through a bounded HTTP IIoPool (_http.Run(...)), not Observable.FromAsync — a bare FromAsync runs the prologue on the subscribing thread and deadlocks under a blocking subscriber. Observable.FromAsync is forbidden everywhere in src/ outside IoPool itself; there is no "not hub-reachable, so it's fine" exemption. See Controlled I/O Pooling.

Azure setup (one-time)

/sendMail with client credentials uses the Mail.Send application permission, which is distinct from the delegated sign-in app. Recommended:

  1. Register a dedicated Entra app (or reuse the managed identity in production).
  2. Add the Mail.Send application permission and grant tenant-admin consent — without it Graph returns 403.
  3. Provision a real licensed or shared mailbox the portal sends and receives as, and that the app is allowed to act as (application access policies may scope this).
  4. Production: prefer Email:UseManagedIdentity=true and grant the managed identity the Mail.Send app role; keep no secret in config. Self-host: supply TenantId/ClientId/ ClientSecret (the secret belongs in Key Vault — see Memex Cloud Deployment → Secrets).
{
  "Email": {
    "Enabled": true,
    "MailboxAddress": "memex@yourtenant.com",
    "TenantId": "<tenant-guid>",
    "ClientId": "<app-client-id>",
    "ClientSecret": "<from-key-vault>",
    "UseManagedIdentity": false
  }
}

Registration lives in MemexConfiguration.ConfigureMemexServices: IEmailSender resolves to GraphEmailSender when Email:Enabled=true, else NoOpEmailSender.


Enabling the feature

# Require invitations…
Features__Onboarding__InvitationOnly=true
# …and turn on email so invitations actually go out.
Email__Enabled=true
Email__MailboxAddress=memex@yourtenant.com
Email__TenantId=<tenant-guid>
Email__ClientId=<app-client-id>
Email__ClientSecret=<from-key-vault>

With Email:Enabled=false you can still run invitation-only mode — invitations are created and enforced, but no email goes out (the admin shares the portal link out-of-band).


Testing & verification


Reconnecting…
The server was updated. Reloading the page to pick up the latest version.