# 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)