Invite existing users into orgs

Task 5071 · Done · Coder · created · updated

## Epic ask (verbatim) > "Allow user to be a member of several orgs, including Operator. At any given moment user acts on behalf of the currently selected org, no permissions mix or leak. User can switch between orgs by clicking on his icon and selecting a switch icon next to the org. Same model as in Stripe and other multitenant systems" ## Requirements (PRD, verbatim) - **R1** "The Operator org is a valid membership. A user may hold Operator plus any number of tenant orgs." - **R5 — Joining another org by invitation.** An org admin can invite an email that already has an account. The existing user gets a pending invitation (in the account menu and by email). The org joins their list only after they Accept. Decline is also offered. Inviting a brand-new email keeps today's new-account invitation flow. Only operator staff can invite into the Operator org; tenant admins cannot. - **D-B** "Domain auto-join keeps creating NEW accounts as today. An EXISTING account signing in with a matching domain is not silently added; the org admin invites them (R5)." ## Acceptance (PRD, verbatim) - AC1: "One identity can hold memberships in Operator + ≥1 tenant org, and in ≥2 tenant orgs." — "create user U in Default, invite U into org T, accept, then `GET` the membership list → both orgs. Repeat with Operator + T." - AC9 (API half): "Inviting an existing email creates a pending invitation, not a new user. Before Accept, U cannot act as the org and it is not in the switcher. Accept adds it; Decline removes the invitation. A tenant admin cannot invite into Operator (403)." ## Approach (architecture §2.5, §3.6, §5.3, §5.5) - New org-scoped `Stored` table `org_invitations`: `id`, `org_id` (inviting org), `identity_id` (invitee), `grant` jsonb (the exact grant payload the create-user route takes — groups/role bindings), `state` (`pending`|`accepted`|`declined`, check constraint), `decided_at`, entity shape; `UNIQUE(org_id, identity_id) WHERE state='pending'`. Additive migration; manifest + censuses updated. The invitee reads own pending invitations under `identity_scope` filtered by `identity_id` — never by org. - Existing create-user route (`routes/users.py`, `@org_admin`): email matches an identity with no row in this org → create a pending invitation + send "you've been invited to <org>" email (new variant in `user_invite.py`), respond `{"invitation": "pending"}`; identity already has a row here → 409; unknown email → today's flow unchanged. - Operator org: only the operator plane's own create-user path (already operator-gated) can invite into Operator; a tenant admin's route is bound to its own org. - `POST /api/auth/invitations/{id}/accept` → creates the `admin_users` row in that org for this identity (`is_superadmin=false`), `access.join_everyone`, applies `grant` verbatim, marks `accepted`, returns `200 {"org_id"}`. `…/decline` → `declined`, `204`. Both 404 unless the invitation's `identity_id` is the caller's. - Fill `GET /api/auth/me` `pending_invitations: [ { id, org_name, invited_by_name, created_at } ]`. - Domain auto-join (`access.py:851+`) creates rows only for NEW identities. Security surface: authz + data migration — under the security-surface gate. Out of scope: No web UI (web child). No switch endpoint. No self-service leave. No change to removal (`DELETE /api/users/{id}` unchanged). Epic out-of-scope (PRD §5, verbatim): "No change to email-domain auto-join beyond what is noted in §6 D-B." "No change to billing/plans/seat pricing semantics beyond each membership being a member of that org as today." "Do not weaken the read/write seams." "No self-service 'leave org'." Done means: - AC1 — API test as specified: U in Default, invited into T, accepts → `me.memberships` lists both orgs; repeat with Operator (invited by operator staff) + T. - AC9 — API tests for each state: invite existing email → invitation row, no new identity, `{"invitation":"pending"}`, email sent with the org name; before accept, U's `me.memberships` lacks T and a switch to T would 404; accept → row created with the grant applied verbatim and `is_superadmin` false; decline → `declined`, no row; accept/decline of another identity's invitation → 404; tenant admin inviting into Operator → 403; inviting an email already a member → 409. - Regression test: inviting a brand-new email still runs today's new-account flow unchanged. - Domain auto-join: an existing identity signing in with a matching domain is NOT added; a new email still auto-joins — test. - AC17 — `./dc check full` green, including the tenancy bypass/scope censuses and the authz policy suite. Required checks: gate

Outcome

Built org invitations: existing email → pending invite + email; accept/decline routes; operator plane 403; /me pending_invitations; rev 0362. Full gate green; operator approved; merged #4510. Deferred: revoke/expiry, per-recipient limit, email outcome.

Pull request

Artifacts

Activity

  • Draft
  • Ready
  • Runner started
  • Awaiting response
  • Awaiting response
  • Runner resumed
  • Runner ended (done)