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
- An admin opens Settings → Administration → Invitations, enters an email, clicks Invite.
InvitationService.CreateInvitationwrites aPendingInvitationnode. It does not send the email.InvitationEmailSender— a hosted service — watches Pending invitations whoseEmailSentAtis null, sends throughIEmailSender, and stampsEmailSentAtso 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.- The invitee signs in via any configured IdP.
OnboardingMiddlewarefinds no User node for the email and redirects to/onboarding. - 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
CreateUseris 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.InvitationNodeTyperegisters aQueryRoutingHints { Partition = "Admin" }rule for path-lessnodeType:Invitationqueries, but that rule is currently inert:PostgreSqlPartitionedMeshQueryroutes purely by the path's first segment and does not consumeQueryRoutingHintsyet. A path-lessnodeType:Invitationquery therefore goes through the cross-schema fan-out, which excludes theadminschema — 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,InvitationEmailSenderandInvitationsSettingsTaball 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/ApiTokeninto theauthschema; it would silently dropInvitationrows. 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:
- Register a dedicated Entra app (or reuse the managed identity in production).
- Add the
Mail.Sendapplication permission and grant tenant-admin consent — without it Graph returns 403. - 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).
- Production: prefer
Email:UseManagedIdentity=trueand grant the managed identity theMail.Sendapp role; keep no secret in config. Self-host: supplyTenantId/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
- Unit —
InvitationServiceTests(real mesh, no mocks):CreateInvitationwrites a queryable Admin-partition node;FindPendingInvitationreturns it (null when absent);Revoke/MarkAcceptedflip status; the NoOp sender returns success without sending. - Manual — as a platform admin open Settings → Administration → Invitations, invite an email, then sign in as a non-invited email (→ "Invitation Required") and as the invited email (→ profile form → completes → invitation shows Accepted).
Related
- Feature Flags — the
Featuressection reference (theInvitationOnlyflag and onboarding modes). - Postgres Schema Architecture — partitions, schemas, and the auth-mirror trigger.
- Access Context Propagation — why invitation writes impersonate as System.
- Synced Mesh-Node Queries —
workspace.GetQuery, used by the gate and the tab.