Status: Draft
Date: 2026-04-23
Related: SPEC.md §3 (deployment), §4 (identity/auth), DECISIONS.md (multi-org trust model), migrations 021, 032, org_idp_configs
Today every user is provisioned into exactly one org on first login, identities are the only user-facing table, and the session JWT embeds a single org: Uuid claim. This document introduces (a) a global users table, (b) org memberships, (c) subdomain-based org routing, (d) an explicit /auth/switch-org endpoint, and (e) self-hosted mode toggles.
Cloud root domain is app.overslash.com. Corp orgs live at <slug>.app.overslash.com. Personal orgs are always 1-member, always authenticate via Overslash-level IDPs, and have no subdomain — they live under the root domain.
- Personal org isolation. A personal org is non-configurable: no per-org IDP, no members, no billing seats. It exists solely as a scope for the user's own agents/secrets/services.
- Org IDP sovereignty. Per-org IDPs (already implemented via
org_idp_configs) are only valid within that org's subdomain — they cannot authenticate a user at the Overslash level. - Overslash IDP ↔ org membership. A user signed in via Overslash-level IDP can access any org they are a member of. Membership is the permission, regardless of how they proved identity.
- Two user classes. A
usersrow is either Overslash-backed (overslash_idp_provider + overslash_idp_subjectset, has apersonal_org_id, can log into root) or org-only (both IDP columns NULL,personal_org_idNULL, can only log into the subdomain of orgs they are a member of). - Users are keyed by IDP subject, never by email. The primary lookup at auth time is
(provider, subject)— Overslash-level for root logins (onusers.overslash_idp_*), and(org_id, external_id)for org-subdomain logins (onidentities).users.emailis informational (last value the IDP returned) and is NOT unique. Two different Google accounts that both claim the same email create two differentusersrows. - Email alone never grants membership or merges users. This is the threat-model load-bearing rule. Google (or any Overslash-level IDP) saying "the email is
amartcan@acme.org" does not grant membership to Acme and does not attach to an existing Acme-provisionedusersrow, because Google is not Acme's IDP. Membership into a corp org is granted by one of two admin-controlled paths: (a) the org's own IDP viaorg_idp_configs.allowed_email_domains(the original D12 path), or (b) — when the admin opts in viaorgs.allow_overslash_managed_signin— a pendingorg_invitesrow matching the IDP-verified email (introduced 2026-05, migration 066). There is no cross-IDP account linking. - Corp orgs require an IDP to have members. Before any IDP is enabled on a corp org, only the bootstrap creator can access it (see "Corp Org Creation Bootstrap"). Once an IDP is configured and enabled, membership is gated entirely by that IDP.
- Email claim still required from IDPs. Without an email we have no display/invite matching. Missing email →
idp_missing_emailand the login is rejected. - Cookies never leave the hierarchy. Session cookies are scoped to
.app.overslash.comso the sameoss_sessionis sent to all subdomains, but the JWT'sorgclaim and subdomain resolution must agree — mismatch triggers re-mint.
users
id UUID PK
email TEXT -- informational; NOT unique; last value the IDP returned
display_name TEXT
overslash_idp_provider TEXT -- e.g. 'google', 'github'; NULL if org-only user
overslash_idp_subject TEXT -- provider's stable subject
personal_org_id UUID FK → orgs(id) -- set when Overslash-backed; NULL for org-only
created_at, updated_at
UNIQUE (overslash_idp_provider, overslash_idp_subject) -- enforced where both set
user_org_memberships
user_id UUID FK → users(id)
org_id UUID FK → orgs(id)
role TEXT NOT NULL -- 'admin' | 'member' (room to grow)
created_at
PRIMARY KEY (user_id, org_id)
orgs (additive)
is_personal BOOLEAN NOT NULL DEFAULT false
-- existing slug column becomes the subdomain label for non-personal orgs
identities (additive)
user_id UUID NULL FK → users(id) -- NULL for pure machine identities
UNIQUE (org_id, user_id) WHERE user_id IS NOT NULL AND kind = 'user'
Why users.email is not unique: since we key on (provider, subject) at auth time, two different Google accounts that happen to report the same email must produce distinct users rows. Uniqueness on email would block that — and opening a UNIQUE collision on login would reveal account existence to an attacker.
Why a global users table: decouples identity-of-the-human from identity-of-the-actor-in-an-org. Billing, profile, account deletion all need a stable "human" record. The existing identities table continues to be the unit of permission in an org; we just link each user-kind identity back to a user_id.
Note on identities: the table already has external_id TEXT (IDP subject) and email TEXT, scoped to org_id. These stay — they describe how this org sees the user. The new user_id column describes who the human actually is. One human can have one identities row per org they're a member of, each with its own external_id matching whichever IDP that org used to authenticate them.
User navigates to acme.app.overslash.com, clicks "Continue with Okta". They have never signed in at app.overslash.com.
Okta callback returns { sub: "00u123abc", email: "amartcan@acme.org", name: "Arturo" }.
Tables after login:
users
id = U1
email = 'amartcan@acme.org'
display_name = 'Arturo'
overslash_idp_provider = NULL ← org-only user
overslash_idp_subject = NULL
personal_org_id = NULL ← no personal org
user_org_memberships
(user_id=U1, org_id=ACME, role='member')
identities
id = I1
org_id = ACME
user_id = U1
kind = 'user'
email = 'amartcan@acme.org'
external_id = '00u123abc' ← Okta subject for this org
JWT: { user_id: U1, org_id: ACME }. User can log into acme.app.overslash.com. Hitting app.overslash.com/login shows only Overslash-level IDPs; there is no Okta button there, and Okta can't authenticate a user at the Overslash level anyway, so this user cannot enter root. That is the correct product behavior: org-only users exist within their org.
Later Google sign-in (stays separate, forever). Suppose the same person later visits app.overslash.com and signs in with Google using the same email amartcan@acme.org. Google returns (sub=G1, email=amartcan@acme.org).
- Lookup on
users.(overslash_idp_provider='google', overslash_idp_subject=G1)→ not found. - No email fallback. Create a fresh users row U2 (Overslash-backed), auto-create personal org P1, membership
(U2, P1, 'admin'). - U2 has no membership to Acme. The Google-signed user cannot access Acme's data.
- U1 (Acme / Okta) and U2 (personal / Google) coexist indefinitely and are never merged. This is by design: to use Acme, sign in through Acme's Okta at
acme.app.overslash.com; to use the personal scope, sign in with Google atapp.overslash.com. One human, two accounts, one per trust domain.
Why this is safe: the system never lets an IDP grant access to resources belonging to another IDP's trust domain. Google's claim about the email is only relevant within Google's own trust domain (Overslash-level root). Acme's Okta is the only authority on Acme memberships, and it already grants membership via its own allowed_email_domains when the user signs in through Okta.
The impostor case. If the Google sign-in isn't Alice but Bob with a Google account claiming amartcan@acme.org, Bob gets his own U_bob row with only his own personal org. He cannot access Acme through any path: Acme's membership table doesn't name him, and the root-domain JWT can't satisfy a subdomain auth check for Acme.
What if the IDP returns no email claim? Reject with { error: "idp_missing_email", hint: "Configure your IDP to include 'email' in the OIDC claims." }.
An Overslash-backed user creates a corp org from their root-domain dashboard. This is the only path by which an Overslash-level IdP admits someone into a non-personal org.
- User (e.g., U2 with personal org P1) calls
POST /v1/orgs { name, slug }. - Org is created with
is_personal=false. - An admin
identitiesrow is created in the new org for the caller, linked to U2 viauser_id. - A plain
user_org_memberships(U2, ACME, 'admin')row is inserted — no special flag. The creator is simply an admin. - Dashboard hard-reloads onto
acme.app.overslash.com/, where the creator lands inside their new org.
Two legitimate paths from here:
- Stay on the Overslash-level IdP indefinitely. The org never configures its own IdP. It remains a single-admin org reachable only via the creator's Overslash-level login. This is valid and supported — an org is free to ride the Overslash instance's IdP forever.
- Configure a corp IdP later. The creator adds an
org_idp_config(Okta / generic OIDC) on the Settings page. Additional humans can now sign in via the corp IdP on the subdomain and auto-provision memberships (gated byallowed_email_domains). The creator's original admin membership is unchanged and continues to work — their Overslash-level login remains valid for the corp subdomain because the membership row still grants access.
No is_bootstrap flag. No "breakglass" labeling. Removing it was a deliberate simplification: the creator is just the org's admin, identical to any other admin they may later promote from within. The fact that they authenticate via the Overslash-level IdP is derivable from users.overslash_idp_* and is not a special state on the membership itself.
Migration 033_multi_org_users.sql:
- Create
users,user_org_memberships, addorgs.is_personal, addidentities.user_id. - For each existing
kind='user'identity, upsert ausersrow. Dedup strategy: current code already enforces unique email across orgs viafind_user_identity_by_email, so email is safe as the dedup key for the backfill specifically (we are not using it for new logins). Populateoverslash_idp_provider/overslash_idp_subjectwhere discoverable from existing metadata; otherwise leave NULL (the row becomes org-only post-migration, which is correct for users who only existed inside one org). Backfillidentities.user_idanduser_org_memberships(user_id, org_id, 'admin'). - For each backfilled user with
overslash_idp_*set (i.e., would be Overslash-backed going forward), create a personal org (is_personal=true, slugpersonal-<short-random>) and setusers.personal_org_id. Users with no Overslash IDP binding stay org-only and get no personal org.
DNS: wildcard *.app.overslash.com pointing at the same Cloud Run service as app.overslash.com, sharing a managed wildcard cert.
New middleware in crates/overslash-api/src/middleware/subdomain.rs runs before auth:
- Parse
Hostheader. - If host == root (
app.overslash.com) →RequestOrgContext::Root. - If host matches
<slug>.app.overslash.com→ look uporgsby slug; ifis_personal=trueor not found, return 404. Else →RequestOrgContext::Org { org_id, slug }. - Attach the context to the request extensions.
Self-hosted deployments bypass this middleware when SINGLE_ORG_MODE=<slug> is set; then every request is treated as RequestOrgContext::Org for the configured slug regardless of host.
GET /auth/providers(no?org=param) returns the Overslash-level IDPs only (those configured via env).GET /auth/login/{provider}initiates OAuth without org context. Cookieoss_auth_orgis not set.- Callback handler:
- Exchange code →
{ provider, subject, email, name }. If email missing → reject (idp_missing_email). - Look up
usersby(overslash_idp_provider, overslash_idp_subject). No email fallback — this is the rule that prevents Google from vouching its way into an org-only row. - If not found → create a new users row with the IDP binding set, create a personal org (
is_personal=true), membership(user_id, personal_org_id, 'admin'), linkusers.personal_org_id, create the identity row in the personal org. - If found → refresh
users.emailanddisplay_nameopportunistically. - Mint session JWT with
{ user_id, org_id: personal_org_id }by default. If the user has exactly one non-personal membership (typical for a creator who just bootstrapped one corp org), land there instead. - Set
oss_sessioncookie withDomain=.app.overslash.com. - Redirect to dashboard, which shows the org switcher.
- Exchange code →
- Subdomain middleware resolves
org_idforacme. GET /auth/providersreturns the org's enabled IDPs fromorg_idp_configs, plus the Overslash-managed providers when the org opted in viaallow_overslash_managed_signin(Flow 2b). Without that opt-in there is no "Continue with Overslash" fallback — an Overslash-level IDP cannot grant membership to a corp org, so offering it would be misleading. Both halves come fromservices::org_signin, which every sign-in path reads.- Exception: the org's creator (and any other Overslash-backed user who already has a membership in the org) reaches it via
/auth/switch-orgfrom the root dashboard, not via the org's/loginpage.
- Exception: the org's creator (and any other Overslash-backed user who already has a membership in the org) reaches it via
GET /oauth/authorize(the MCP authorization server) bounces an unauthenticated caller through the same provider list: the org's designated default if it has one, else straight to a lone provider, else the/loginpicker. The redirect is absolute tohttps://<slug>.<app-apex>— theoss_auth_*cookies are scoped toSESSION_COOKIE_DOMAIN(.app.<apex>), so a login started on<slug>.api.<apex>(the host the AS metadata advertises) would have them rejected by the browser.- Provisioning on callback — lookups key on
(org_id, external_id), never on email:- Exchange code →
{ subject, email, name }from IDP. If email missing → reject (idp_missing_email). - Look up
identitiesby(org_id, kind='user', external_id=<IDP subject>).- If found → use
identities.user_idas the targetusersrow. Refreshidentities.emailandusers.display_nameopportunistically. - If not found → first-time sign-in for this IDP subject. Create a fresh
usersrow (org-only:overslash_idp_*NULL,personal_org_idNULL). Create theidentitiesrow in this org withexternal_id=<subject>,email=<IDP email>,user_id=<new users row>. Never match against existingusersrows by email.
- If found → use
- Ensure
user_org_memberships(user_id, org_id, role)exists. Role comes from the org'sallowed_email_domainsauto-provision rules (default'member'). If the IDP email does not match any allowed domain, reject withnot_permitted_by_org_idp— the org admin controls who gets in through their allowed-domains list. Emptyallowed_email_domains= trust the IdP (any email admitted by it provisions); non-empty = strict whitelist. - Mint JWT
{ user_id, org_id }, redirect into the org.
- Exchange code →
Added 2026-05, migration 066. Default true for new corp orgs, false for existing ones.
Migration 092 (2026-07): admission on this path is no longer hard-wired to invites. The org-level
require_invite_admissionflag (defaulttrue) picks the gate — invite-only, or an org-wide domain allowlist. Steps 3–5 below describe therequire_invite_admission = true(default) case; thefalsecase is described immediately after.
- The org has
allow_overslash_managed_signin = true. resolve_auth_credentialsdelegates toservices::org_signin, which resolves availability and credentials together so the login page cannot advertise a provider that sign-in then refuses:- A dedicated
org_idp_configsrow claims its provider key. If the row is enabled, its own credentials win (the admin's explicit setup is authoritative), falling back to the org's OAuth App Credentials (OAUTH_{PROVIDER}_CLIENT_ID/SECRETorg secrets) when the row is configured to defer to them — a missing pair there is an error, not a fallthrough. - If the row is disabled, that provider is off for the org. Managed sign-in does not quietly take it over; the login page stops listing it too.
- For a key no row claims, the managed path applies: the org's OAuth App Credentials first (an org-level credential is an intentional override of the shared env app), then the server's env-var creds (
GOOGLE_AUTH_CLIENT_ID,GITHUB_AUTH_CLIENT_ID, …) — the Overslash-managed OAuth app authenticates the user.
- A dedicated
- The callback path swaps the
allowed_email_domainsgate for an invite check:- Exchange code →
{ subject, email, name }. If email missing → reject (idp_missing_email). - If the
identities(org_id, external_id)row already exists, refresh + return (existing member). - Otherwise look up
org_invites WHERE org_id = $org AND email = lower($email) AND accepted_at IS NULL. No match → reject withnot_invited. - Provision the
usersrow using the standard(provider, subject)keying — reuses an existing Overslash-backed user if(provider, subject)already exists, otherwise creates an org-only user. - Create the
identitiesrow +user_org_memberships(role=$invite.role)+ mark the inviteaccepted_at = now(). Membership role comes from the invite, not fromallowed_email_domains.
- Exchange code →
- The gate applies to every sign-in into this org while the flag is on — including authentications via a dedicated
org_idp_configs. Admins who want the legacy per-provider domain-whitelist semantics keep the flag off.
require_invite_admission = false — org-wide domain admission (migration 092). When the admin turns off the invite requirement, step 3 changes: instead of an org_invites lookup, the callback checks the IdP email's domain (split on @, case-insensitive) against orgs.managed_signin_allowed_domains:
- Empty list → reject
domain_admission_not_configured. An open-admission-with-no-domains state is a misconfiguration, never "admit the whole internet." - Domain not on a non-empty list → reject
domain_not_allowed. - Domain matches → provision as in steps 4–5, but with
role = 'member'(no invite → no role override) and no invite consumed.
This is deliberately org-wide rather than per-provider: the managed path admits through several env-var providers (Google, GitHub) that share one trust boundary (the operator's env creds), so a single org-level list is the natural home. It is distinct from org_idp_configs.allowed_email_domains, which still gates Flow 2a's per-org-IdP path. Domain admission trusts the verified-email domain only; it does not verify Google's hd/Workspace claim — see TECH_DEBT.md.
Threat model: D12's invite concern was "email-spoofing into a per-org IdP-backed membership." That doesn't apply on either managed sub-mode — there's no Okta on the corp side to vouch for an attacker's email. With invites, the admin's org_invites list is the only path in; with domain admission, the admin's managed_signin_allowed_domains list is, and the managed IdP authenticates the holder of the email. Either way the IdP's role is reduced to "authenticate the holder of this email," and the org admin chooses which emails/domains matter.
POST /auth/switch-org { org_id }:- Requires valid session (any org).
- Verifies
user_idhas a membership toorg_id(or thatorg_idis the user's personal org). - Mints a new JWT
{ user_id, org_id }, setsoss_sessionon.app.overslash.com. - Response body:
{ redirect_to: "<subdomain-or-root-url>" }. Dashboard hard-reloads to the returned URL.
Current: reads JWT, extracts org_id from claims. New: reads JWT → extracts user_id and JWT org_id; if RequestOrgContext::Org { org_id: subdomain_org } is present, require jwt.org_id == subdomain_org (else 401 with reason=org_mismatch, dashboard forwards to /auth/switch-org). If RequestOrgContext::Root, the JWT's org_id wins (used by account-level routes that still need scope).
- Login page (
dashboard/src/routes/login/+page.svelte):- On root domain: render only Overslash-level providers; no org field.
- On org subdomain: render the org's enabled IDPs, plus the Overslash-managed providers when the org enabled managed sign-in. No Overslash fallback button otherwise.
- If the subdomain has no enabled IDPs yet, show an explanatory page: "This org has no sign-in configured. Contact the org admin." The admin (= creator) reaches the org via root → org switcher.
+layout.ts:MeIdentitygainsuser_id,memberships: [{ org_id, slug, name, role, is_personal }],personal_org_id.OrgSwitcher.svelte(new) — sidebar-top component. Dropdown listing memberships grouped as Personal / Orgs; calls/auth/switch-orgthen hard-reloads to returned URL. No per-row badges — every row is just an org name./account(new) — top-level page outside any org scope; shows profile, linked Overslash IDP, org memberships, "Create org" CTA (gated byALLOW_ORG_CREATION). Lets the user drop a bootstrap membership./org(existing) — hide IDP + OAuth credential cards when current orgis_personal=true. For corp orgs, surface a warning banner when no IDP is enabled: "Configure an IDP to let your team sign in."
Two new env flags parsed once at startup:
ALLOW_ORG_CREATION(defaulttrue). Whenfalse:POST /v1/orgsreturns403 org_creation_disabled; dashboard hides "Create org" CTAs.SINGLE_ORG_MODE=<slug>(default unset). When set: subdomain middleware disabled; every request scoped to the named org; root login lands directly in that org (no personal org auto-creation); org switcher hidden.
Documented in SPEC.md §3 and a future docs/self-hosting.md.
- Per-user profile settings (name, avatar) surface in
/account— scoped for a later PR onceuserstable exists. - SAML — already out of scope per SPEC; unchanged.
- Slug squatting on corp orgs — deferred; add domain verification or admin approval later if abuse materializes.
- Auditing bootstrap-admin removal — should likely emit an audit event; straightforward follow-up.