Files
the-other-dude/docs/superpowers/specs/2026-03-14-saas-tiers-design.md
2026-03-14 12:33:10 -05:00

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_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:

{
  "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_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:

{ "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:

  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:

{
  "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:

{
  "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)