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 — except on an instance whose own Entra directory demonstrably gates sign-in, which is exempt (see Exempt where the instance's own directory gates sign-in).

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. The one other way in is the own-directory exemption: where Microsoft is the only sign-in route, the tenant names one directory and Microsoft Graph confirms the instance's Entra application requires user assignment, the directory's assignment list is the allowlist and no invitation is needed.


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 — unless the own-directory exemption holds for this sign-in (below), in which case the form is shown and the user is created without an invitation.

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 or the own-directory exemption holds. On success it chains InvitationService.MarkAccepted when an invitation was used. The page renders the form for admitted visitors and an "Invitation Required" message for everyone else (messaging only — the real gate is at CreateUser, which re-reads the same decision).

Exempt where the instance's own directory gates sign-in

Invitation-only is exempt on an instance whose own Entra directory decides who may use it. Three conditions, all required, all re-checked at every sign-in:

  1. Microsoft is the only sign-in route. Any second route (Google, LinkedIn, Apple, GitHub, the developer login) admits identities no directory issued, so it voids the exemption.
  2. Authentication:Microsoft:TenantId names one directory — not unset, common, organizations or consumers. The handler then signs in with the authorization-code flow against that tenant's authority, so every identity reaching the portal was authenticated by that directory.
  3. Microsoft Graph confirms the instance's Entra application requires user assignment (appRoleAssignmentRequired = true on its service principal). A named tenant alone proves only which directory AUTHENTICATES a visitor; without assignment required, every member of the tenant can sign in, and the exemption would open an invitation-only portal to all of them.

How condition 3 is read. The portal asks Graph with the instance's OWN credential (Authentication:Microsoft:ClientId / ClientSecret, an app-only token from the tenant's token endpoint): GET /v1.0/servicePrincipals(appId='{clientId}')?$select=appRoleAssignmentRequired. The call runs on the HTTP I/O pool. A successful reading is reused for at most two minutes, so turning assignment off closes the exemption within that window; a failed reading is never cached.

It fails closed. No client secret, a refused token, a 403, a missing service principal or an unparseable answer each mean not exempt: invitation-only applies, and the portal logs a Warning starting [InvitationOnly] The own-directory exemption does NOT apply that names the cause.

🚨 The Graph read needs a permission the sign-in app does not have by default: the Application.Read.All application permission on the instance's Entra app registration, with admin consent granted in that tenant. Until the tenant's administrator grants it, Graph answers 403 and the exemption stays off — invitations remain the way in. Granting it is an act of the tenant's owner, never of the platform.

Sessions are bound to the sign-in configuration. The auth cookie lives fourteen days and slides, so a session opened through a route that has since been turned off — or against a tenant that has since changed — would otherwise outlive the change. At sign-in the portal stamps a fingerprint of the sign-in configuration (which routes are on, their client ids, the Microsoft tenant; never a secret) into the cookie, and on every request it rejects and deletes a cookie whose fingerprint no longer matches, or that carries none. The visitor signs in again under the current routes. Rotating a client secret does not change the fingerprint. A cookie issued before the stamp existed carries no fingerprint and is rejected, so each user signs in once more after the change ships.

The exemption never opens a closed registration (InvitationOnly and AllowSelfOnboarding both off) and never promotes anybody: the first-user rule is unchanged.

The decision lives in the portal's onboarding gate (MeshWeaver.Plugins: OnboardingGate.Decide, fed by EntraAppAssignmentProbe; the cookie binding is SignInConfigurationStamp). The deployment-record pre-flight checks the configuration shape (conditions 1 and 2); only the running portal can read condition 3. Decided on Systemorph/Memex#633.

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