diff --git a/docs/superpowers/specs/2026-03-14-saas-tiers-design.md b/docs/superpowers/specs/2026-03-14-saas-tiers-design.md new file mode 100644 index 0000000..87e1585 --- /dev/null +++ b/docs/superpowers/specs/2026-03-14-saas-tiers-design.md @@ -0,0 +1,346 @@ +# SaaS Tiers, Invite System & Plan Limits — Design Spec + +## Overview + +Add a tier/quota system to TOD that limits tenants, devices, and users per account. Includes a one-time invite system for onboarding new users with configurable plan limits, and a disabled-by-default public signup page for future homelab tier. + +**Branch:** `saas-tiers` (diverges from open-source `main`) + +**Target audience:** Super_admin (you) managing a SaaS offering, and invited users who manage their own tenants. + +## Design Decisions + +- **No new "Account" model** — the existing user model is extended. The "account owner" is just the user who accepted an invite and creates tenants. +- **Per-user plan limits** — a `plan_limits` table keyed by user ID stores max_tenants, max_devices_per_tenant, max_users_per_tenant. +- **No limits row = no limits** — super_admin users never have a `plan_limits` row, so they're unlimited. +- **Invite-based onboarding** — super_admin generates one-time invite links (32 bytes / 256 bits entropy). No self-registration yet (homelab signup page exists but is disabled by default). +- **Existing RBAC preserved** — invited users become `tenant_admin` of tenants they create. No new roles. +- **`user_tenants` join table** — tracks which tenants a user belongs to and their role in each. The user's `tenant_id` column becomes the "currently active" tenant. Tenant switching updates this field and re-issues the JWT. +- **Enforcement at creation time** — limits are checked when creating devices, users, or tenants. Not on every request. +- **Invited users start as bcrypt** — with `must_upgrade_auth=True`. SRP upgrade happens on first login, consistent with existing user creation flow. + +## Data Model + +### New Tables + +#### `plan_limits` + +Per-user quota configuration. If no row exists for a user, no limits are enforced (super_admin behavior). + +| Column | Type | Default | Description | +|--------|------|---------|-------------| +| `id` | UUID PK | gen_random_uuid() | Primary key | +| `user_id` | UUID FK (users.id), unique | — | The account owner | +| `max_tenants` | integer | 2 | Max tenants this user can own | +| `max_devices_per_tenant` | integer | 10 | Max devices per tenant | +| `max_users_per_tenant` | integer | 10 | Max users per tenant (0 = owner only) | +| `plan_name` | varchar(50) | "invite" | Plan identifier: "invite", "homelab", "custom" | +| `created_at` | timestamptz | now() | | +| `updated_at` | timestamptz | now() | | + +RLS policy: super_admin can read/write all rows. Users can read their own row. + +#### `user_tenants` + +Join table tracking which tenants a user belongs to and their role in each. + +| Column | Type | Default | Description | +|--------|------|---------|-------------| +| `id` | UUID PK | gen_random_uuid() | Primary key | +| `user_id` | UUID FK (users.id) | — | The user | +| `tenant_id` | UUID FK (tenants.id) | — | The tenant | +| `role` | varchar(50) | "tenant_admin" | User's role in this tenant | +| `created_at` | timestamptz | now() | | + +Unique constraint on `(user_id, tenant_id)`. This table allows a single user (single email, single password) to be a member of multiple tenants without duplicating user rows. + +The existing `users.tenant_id` column is retained as the "currently active" tenant. The `switch-tenant` endpoint updates this field and re-issues the JWT. + +#### `invites` + +One-time invite tokens generated by super_admin. + +| Column | Type | Default | Description | +|--------|------|---------|-------------| +| `id` | UUID PK | gen_random_uuid() | Primary key | +| `token` | varchar(64), unique, indexed | — | URL-safe random token (32 bytes / 256 bits entropy) | +| `plan_name` | varchar(50) | "invite" | Plan to assign when claimed | +| `created_by` | UUID FK (users.id) | — | Super_admin who created it | +| `claimed_by` | UUID FK (users.id), nullable | — | User who claimed it | +| `claimed_at` | timestamptz, nullable | — | When it was claimed | +| `expires_at` | timestamptz | — | 7 days from creation | +| `created_at` | timestamptz | now() | | + +No RLS — only accessible via super_admin endpoints and the public claim endpoint (which validates the token directly). + +### Modified Tables + +#### `tenants` + +Add column: + +| Column | Type | Default | Description | +|--------|------|---------|-------------| +| `owner_id` | UUID FK (users.id), nullable | — | User who created/owns this tenant. Null for bootstrap/super_admin-created tenants (always unlimited). | + +#### `system_settings` + +New key-value entry: + +| Key | Default Value | Description | +|-----|---------------|-------------| +| `homelab_signup_enabled` | `"false"` | Controls public signup page visibility | + +### Default Plan Values + +| Plan | max_tenants | max_devices_per_tenant | max_users_per_tenant | +|------|------------|----------------------|---------------------| +| invite | 2 | 10 | 10 | +| homelab | 1 | 5 | 0 (owner only) | +| custom | (set by super_admin) | (set by super_admin) | (set by super_admin) | + +### Migration Notes + +- Existing tenants get `owner_id = NULL` (treated as unlimited / super_admin-owned). +- Existing users get a corresponding `user_tenants` row for their current `tenant_id` with their current `role`. +- No `plan_limits` rows are created for existing users (unlimited by default). + +## Enforcement Logic + +Limits are checked at creation time only — not on every request. + +### Device Creation + +**Endpoints:** `POST /api/tenants/{id}/devices`, VPN onboard endpoint + +1. Look up tenant's `owner_id` +2. If `owner_id` is NULL → no limit (super_admin-owned tenant) +3. Look up `plan_limits` for owner. If no row → no limit. +4. Count devices in tenant (within the same transaction for onboard) +5. If count >= `max_devices_per_tenant` → return 422: `"Device limit reached (5/5)"` + +### User Creation + +**Endpoint:** `POST /api/tenants/{id}/users` + +1. Look up tenant's `owner_id` +2. If `owner_id` is NULL → no limit. +3. Look up `plan_limits` for owner. If no row → no limit. +4. Count active users in tenant +5. If count >= `max_users_per_tenant` → return 422: `"User limit reached (10/10)"` +6. Homelab plan (`max_users_per_tenant = 0`) means only the owner exists — no additional users. + +### Tenant Creation + +**Endpoint:** `POST /api/tenants` + +Currently super_admin only. Change: allow users with a `plan_limits` row to create tenants within their limit. + +1. Look up `plan_limits` for current user. If no row → no limit (super_admin). +2. Count tenants where `owner_id = current_user.id` +3. If count >= `max_tenants` → return 422: `"Tenant limit reached (2/2)"` +4. Create tenant with `owner_id = current_user.id` +5. Add `user_tenants` row: `(current_user.id, new_tenant.id, "tenant_admin")` +6. Update `users.tenant_id = new_tenant.id` (switch to the new tenant) + +## Invite System + +### Creating Invites (Super_admin) + +**Endpoint:** `POST /api/invites` + +Rate limit: 20/minute + +Request body: +```json +{ + "plan_name": "invite" // optional, defaults to "invite" +} +``` + +Response: +```json +{ + "id": "uuid", + "token": "abc123...", + "url": "https://app.theotherdude.net/invite/abc123...", + "plan_name": "invite", + "expires_at": "2026-03-21T16:00:00Z", + "created_at": "2026-03-14T16:00:00Z" +} +``` + +- Generates 32-byte URL-safe random token (256 bits entropy — brute force infeasible) +- Sets `expires_at` to 7 days from now + +### Managing Invites (Super_admin) + +- `GET /api/invites` — list all invites with status (pending/claimed/expired) +- `DELETE /api/invites/{id}` — revoke an unclaimed invite + +### Validating an Invite (Public) + +**Endpoint:** `GET /api/invites/{token}/validate` + +Rate limit: 5/minute per IP + +No auth required. Returns: +```json +{ "valid": true } +``` + +Or `{ "valid": false }` — no reason disclosed to prevent information leakage about token states. + +### Claiming an Invite (Public) + +**Endpoint:** `POST /api/invites/{token}/claim` + +Rate limit: 5/minute per IP + +No auth required. Request body: +```json +{ + "name": "Jane Doe", + "email": "jane@example.com", + "password": "securepassword123" +} +``` + +Flow: +1. Validate token (exists, not claimed, not expired). Return generic 400 "Invalid or expired invite" for any failure (no distinction between expired/claimed/not-found). +2. Check email uniqueness globally +3. Create user with `role = "tenant_admin"`, `tenant_id = NULL`, `must_upgrade_auth = True` (bcrypt, upgrades to SRP on first login) +4. Create `plan_limits` row with plan defaults based on `invite.plan_name` +5. Mark invite as claimed (`claimed_by`, `claimed_at`) +6. Issue JWT with special `onboarding = true` claim (see Onboarding State below) +7. Frontend redirects to tenant creation page + +### Onboarding State + +After claiming an invite, the user has `tenant_id = NULL` and `role = "tenant_admin"`. The existing RLS middleware blocks non-super_admin users with no tenant. To handle this: + +- The JWT issued during claim includes an `onboarding: true` claim +- The tenant context middleware is modified: if `onboarding = true`, allow access to a whitelist of endpoints only: + - `POST /api/tenants` (create first tenant) + - `GET /api/plan/usage` (see their limits) + - `POST /api/auth/logout` +- All other endpoints return 403: "Please create a tenant first" +- After creating their first tenant, the user gets a normal JWT with `tenant_id` set + +## Tenant Switching + +Users who belong to multiple tenants can switch between them. + +**Endpoint:** `POST /api/auth/switch-tenant` + +Request body: +```json +{ + "tenant_id": "uuid" +} +``` + +Flow: +1. Look up `user_tenants` for `(current_user.id, target_tenant_id)`. If no row → 403 "You do not have access to this tenant". +2. Update `users.tenant_id = target_tenant_id` +3. Issue new JWT with the target `tenant_id` and the role from `user_tenants.role` +4. Return new access token + refresh token + +**Listing available tenants:** + +`GET /api/auth/tenants` — returns all tenants the current user belongs to (from `user_tenants`), including the currently active one. + +## API Summary + +### New Endpoints + +| Method | Path | Auth | Rate Limit | Description | +|--------|------|------|------------|-------------| +| `POST` | `/api/invites` | super_admin | 20/min | Create invite | +| `GET` | `/api/invites` | super_admin | — | List all invites | +| `DELETE` | `/api/invites/{id}` | super_admin | 5/min | Revoke invite | +| `GET` | `/api/invites/{token}/validate` | public | 5/min/IP | Check if invite is valid | +| `POST` | `/api/invites/{token}/claim` | public | 5/min/IP | Register via invite | +| `POST` | `/api/auth/switch-tenant` | authenticated | 20/min | Switch active tenant | +| `GET` | `/api/auth/tenants` | authenticated | — | List user's tenants | +| `GET` | `/api/settings/signup-status` | public | — | Check if homelab signup is enabled | +| `GET` | `/api/plan/usage` | authenticated | — | Get current plan limits and usage | +| `PUT` | `/api/admin/users/{user_id}/plan` | super_admin | 20/min | Update a user's plan limits | + +### Modified Endpoints + +| Method | Path | Change | +|--------|------|--------| +| `POST` | `/api/tenants` | Allow users with plan_limits to create; set `owner_id`; add `user_tenants` row | +| `POST` | `/api/tenants/{id}/devices` | Add device limit enforcement | +| `POST` | `/api/tenants/{id}/vpn/peers/onboard` | Add device limit enforcement (before device creation in transaction) | +| `POST` | `/api/tenants/{id}/users` | Add user limit enforcement | + +### Usage Response Schema + +`GET /api/plan/usage` returns: + +```json +{ + "plan_name": "invite", + "tenants": { "current": 1, "max": 2 }, + "active_tenant": { + "tenant_id": "uuid", + "devices": { "current": 3, "max": 10 }, + "users": { "current": 2, "max": 10 } + } +} +``` + +Returns device/user counts for the currently active tenant. + +## Frontend Changes + +### New Pages + +- **`/invite/{token}`** — public invite claim page. Standalone (not behind auth). Shows registration form or "Invalid or expired invite" error. +- **`/signup`** — public homelab signup page. Disabled by default. Shows "Not accepting signups" when `homelab_signup_enabled` is false. +- **`/settings/invites`** — super_admin invite management. Create, list, copy link, revoke. + +### Modified Components + +- **Top nav / sidebar** — tenant switcher dropdown for users who belong to multiple tenants. Shows current tenant name, lists available tenants from `GET /api/auth/tenants`, "Create Tenant" option if under limit. +- **Tenant list** — "Create Tenant" button visible to users with a plan_limits row (not just super_admin). Disabled with tooltip if at limit. +- **Tenant detail (super_admin view)** — shows plan limits and current usage. Editable by super_admin. +- **Device list** — subtle usage indicator: "3/10 devices" near the header. Only shown when limits exist. +- **User list** — subtle usage indicator: "2/10 users" near the header. Only shown when limits exist. +- **System settings (super_admin)** — "Enable homelab signups" toggle. + +## Audit Logging + +The following operations produce audit log entries: + +- Invite created (by super_admin) +- Invite claimed (by new user) +- Invite revoked (by super_admin) +- Tenant created by non-super_admin user +- Tenant switched +- Plan limits updated by super_admin + +## Error Handling + +| Scenario | HTTP Status | Message | +|----------|-------------|---------| +| Device limit reached | 422 | "Device limit reached ({count}/{max})" | +| User limit reached | 422 | "User limit reached ({count}/{max})" | +| Tenant limit reached | 422 | "Tenant limit reached ({count}/{max})" | +| Invalid/expired/claimed invite | 400 | "Invalid or expired invite" | +| Email already registered | 409 | "Email already in use" | +| Signup disabled | 403 | "Not accepting signups at this time" | +| Switch to unjoined tenant | 403 | "You do not have access to this tenant" | +| Onboarding user hits non-whitelisted endpoint | 403 | "Please create a tenant first" | + +## Out of Scope + +- Billing / Paddle integration +- Homelab self-registration activation (page exists but disabled) +- VPN per-tenant network isolation (separate spec) +- Email notifications for invites (super_admin copies the link) +- Usage metering / analytics dashboard +- Plan upgrade/downgrade flows +- Tenant deletion by non-super_admin users (remains super_admin only)