ADR-030 — Organization and User Lifecycle
1. Context
The console onboarding wizard mixed three concerns that belong to different actors and lifecycles:
- Organization setup (once per tenant): name, optional demo seed, SSO/domain policy — admin/owner only.
- User join (per person): invitation accept or SSO JIT → tenant_memberships.
- User activation (per person/device): browser extension install/enroll, first real detection.
Additionally, the invitation flow was incomplete: emails linked to /invite/{token} but the console had no route and the API had no preview/accept endpoints. Invited users on mature tenants were incorrectly sent through the full org onboarding wizard because gating used tenant-level onboarding_completed without role checks.
Noxys ships in three deployment topologies (see ADR-010, ADR-019):
| Topology | Provisioning | Typical auth | Extension delivery |
|---|---|---|---|
| SaaS shared | Self-serve signup → new tenant | Email magic link + optional SSO | Self-serve store links |
| Dedicated tenant | Sales/onboarding creates tenant | SSO enforced, invite-first | MDM or self-serve |
| Self-hosted / air-gap | Customer install + bootstrap admin | SSO or local IdP, no public signup | MDM / offline package |
The lifecycle model must be deployment-agnostic: same states, routes, and API contracts; only defaults and policy knobs differ per topology.
2. Decision
2.1 Three-layer lifecycle (canonical)
Layer 1 — Org setup (tenant, once): tenants.onboarding_completed; actor owner/admin; UI /org-setup (2 steps: welcome+extension, optional demo).
Layer 2 — User join (per user): POST /invitations/accept or SSO JIT; data in tenant_memberships + users row in target tenant.
Layer 3 — User activation (per user/device): UI /welcome (extension install); metrics ext_installed, first_detection (ADR-022 funnel).
Rule: Layer 1 gates only admin/owner roles. Members/viewers never see org setup; they land on /welcome until they install the extension, then /dashboard.
2.2 Invitation flow (repair)
- Admin: POST /api/v1/users/invite → email with {console}/invite/{token}
- Invitee: GET /api/v1/invitations/preview?token= (public)
- Invitee: Login or signup (email must match invitation)
- Invitee: POST /api/v1/invitations/accept → membership + JWT for invited tenant
- Invitee: /welcome → extension → dashboard
2.3 Deployment matrix — same lifecycle, different defaults
| Concern | SaaS shared | Dedicated tenant | Self-hosted |
|---|---|---|---|
| Tenant creation | Signup | Provisioning runbook | Bootstrap installer |
| Org setup UI | /org-setup for first admin | Same | Same |
| Public signup | Enabled | Disabled or domain-gated | Disabled |
| Invitation accept | Same API + console | Same | Same |
| /welcome | Store links | Store + MDM | Offline/MDM package |
| onboarding_completed | Admin finishes org setup | Often pre-set true | Often pre-set true |
| Demo seed | Optional | Optional | Often skipped |
Conclusion: One product lifecycle; deployment mode changes who creates the tenant, whether signup is public, and how the extension is delivered — not separate wizards or duplicate APIs.
2.4 Console routes
| Route | Access | Purpose |
|---|---|---|
| /invite/:token | Public → auth → accept | Invitation landing |
| /org-setup | Admin/owner, !onboarding_completed | Org setup wizard |
| /welcome | Authenticated non-admin OR post-invite | Extension install |
| /dashboard | Authenticated, tenant onboarded | Main app |
/onboarding remains an alias to /org-setup for backward compatibility.
2.5 Removed from org setup
Step Connect an AI provider is removed — it did not persist configuration.
3. Consequences
Positive: invited users skip org setup on mature tenants; single model for all deployment topologies.
Follow-up (Phase 3): block orphan tenant on signup when pending invitation exists; SSO tenant_memberships sync; multi-tenant JWT switching.
4. References
- Migration 179_tenant_onboarding_completed.up.sql
- noxys-api/internal/database/auth.go
Phase 3 (implemented 2026-06-04)
- Signup anti-orphan: VerifyCode joins pending invitation tenant instead of CreateTenantWithOwner.
- SSO UpsertSSOUser syncs tenant_memberships.
- Invite guard: reject if email already has active tenant or is already a member of inviting tenant.
- Tenant switch: GET /api/v1/auth/tenants, POST /api/v1/auth/switch-tenant.