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 Admin app (/Admin) → 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).

A request that died while its identity was being resolved

OnboardingMiddleware runs in front of every page and API route and decides — from the signed-in user's User node and roles — whether to pass the request on, redirect it to /onboarding, or answer 503 because identity could not be resolved. That wait is deliberately deaf to HttpContext.RequestAborted: abandoning it half-way would leave the request with no outcome, and the lookup carries its own bound. The consequence is that the request the middleware resumes may already be dead.

It therefore checks RequestAborted after the decision arrives and, when the connection is gone, hands nothing to the endpoint. Before that check (#4859) a burst of five requests on the public instance was passed to a JSON endpoint after Kestrel's shutdown had aborted their connections and disposed its transport; every response write rented from the dead pool and died on ObjectDisposedException: 'MemoryPool' at Http1OutputProducer.GetFakeMemory, attributed to this middleware because it was the frame that resumed them. (In Kestrel, GetFakeMemory is the path a write takes once its connection has already ended, and the pool it rents from belongs to the transport — so that stack is a write arriving after shutdown tore the HTTP transport down, whichever way the connection itself ended.)

🚨 Not answering a dead request must not erase what the crash was evidence of. An abort while the host is stopping is how Kestrel ends a shutdown drain that ran out of budget, so that case is logged at Warning (the connection … was ABORTED while the host is stopping); a burst of those lines at one instant says the host's shutdown budget ran out before HTTP drained, which is a question about the teardown ahead of Kestrel, not about this middleware. A client that simply left a running portal is logged at Debug. Pinned by OnboardingMiddlewareAbortedRequestTest.


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