docs: add SaaS tiers and invite system design spec
This commit is contained in:
346
docs/superpowers/specs/2026-03-14-saas-tiers-design.md
Normal file
346
docs/superpowers/specs/2026-03-14-saas-tiers-design.md
Normal file
@@ -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)
|
||||
Reference in New Issue
Block a user