14 KiB
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_limitstable 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_limitsrow, 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_adminof tenants they create. No new roles. user_tenantsjoin table — tracks which tenants a user belongs to and their role in each. The user'stenant_idcolumn 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_tenantsrow for their currenttenant_idwith their currentrole. - No
plan_limitsrows 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
- Look up tenant's
owner_id - If
owner_idis NULL → no limit (super_admin-owned tenant) - Look up
plan_limitsfor owner. If no row → no limit. - Count devices in tenant (within the same transaction for onboard)
- If count >=
max_devices_per_tenant→ return 422:"Device limit reached (5/5)"
User Creation
Endpoint: POST /api/tenants/{id}/users
- Look up tenant's
owner_id - If
owner_idis NULL → no limit. - Look up
plan_limitsfor owner. If no row → no limit. - Count active users in tenant
- If count >=
max_users_per_tenant→ return 422:"User limit reached (10/10)" - 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.
- Look up
plan_limitsfor current user. If no row → no limit (super_admin). - Count tenants where
owner_id = current_user.id - If count >=
max_tenants→ return 422:"Tenant limit reached (2/2)" - Create tenant with
owner_id = current_user.id - Add
user_tenantsrow:(current_user.id, new_tenant.id, "tenant_admin") - 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:
{
"plan_name": "invite" // optional, defaults to "invite"
}
Response:
{
"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_atto 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:
{ "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:
{
"name": "Jane Doe",
"email": "jane@example.com",
"password": "securepassword123"
}
Flow:
- Validate token (exists, not claimed, not expired). Return generic 400 "Invalid or expired invite" for any failure (no distinction between expired/claimed/not-found).
- Check email uniqueness globally
- Create user with
role = "tenant_admin",tenant_id = NULL,must_upgrade_auth = True(bcrypt, upgrades to SRP on first login) - Create
plan_limitsrow with plan defaults based oninvite.plan_name - Mark invite as claimed (
claimed_by,claimed_at) - Issue JWT with special
onboarding = trueclaim (see Onboarding State below) - 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: trueclaim - 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_idset
Tenant Switching
Users who belong to multiple tenants can switch between them.
Endpoint: POST /api/auth/switch-tenant
Request body:
{
"tenant_id": "uuid"
}
Flow:
- Look up
user_tenantsfor(current_user.id, target_tenant_id). If no row → 403 "You do not have access to this tenant". - Update
users.tenant_id = target_tenant_id - Issue new JWT with the target
tenant_idand the role fromuser_tenants.role - 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:
{
"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" whenhomelab_signup_enabledis 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)