Skip to content

Multi-tenant rebuild plan — Partner / Client / Device

Why

The first cut modeled the world in two levels: Provider (Totlcom) plus a flat list of "customers." In reality the business has three levels, and the flat model made the provider console show every end business as a top-level customer — the wrong information architecture for a resellable SaaS.

This plan introduces the missing middle tier and splits the two portals along the correct line. The engine, zero-knowledge escrow, request signing, per-customer builds, and guided profiles all carry over unchanged — this is a tenancy refactor, not a teardown.

Target hierarchy

Provider (Totlcom — the platform operator)
 └─ Partner (an MSP account that buys / white-labels TotlProvision)   ← paying tenant
     └─ Client (the MSP's end business)                               ← isolation boundary
         └─ Device (a provisioned machine)
  • Partner owns branding, plan, seat allowance, billing, and users.
  • Client is the isolation boundary: devices, runs, escrowed secrets, build profiles, builds, SSO mappings, and the RSA escrow keypair all live at the client level. A partner admin manages all of their clients; escrow decryption still requires the client passphrase (zero-knowledge is preserved).
  • Device reports in under a client.

Naming

Use Partner / Client / Device throughout UI and code. "Tenant" is retired as a user-facing term; internally the isolation key becomes client_id (was tenant_id).

URLs (unchanged)

Surface Host Who signs in Auth
Partner portal (daily driver, white-labeled) totlprovision.totlcom.com partner admins & technicians Cloudflare Access (internal) / OIDC (public)
Provider console admin.totlprovision.totlcom.com Totlcom staff Cloudflare Access, staff-only
API (shared Worker) /api/* on both hosts same-origin

Roles

Role Scope Can
provider all partners everything, cross-partner; create/suspend partners
partner_admin one partner manage the partner's clients, users, branding, billing
technician one partner manage devices/profiles/builds/secrets across the partner's clients
client_viewer one client read-only fleet status for a single client

Every request resolves to { partnerId, clientId?, role } and is gated by canManagePartner() / canManageClient() server-side — the portal split is defense-in-depth, not the control itself.

Data model

New and changed tables:

  • partners (new): id, name, slug, branding_json, plan, seat_limit, status, stripe_customer_id, stripe_subscription_id, created_at.
  • clients (rename of tenants): add partner_id; keeps public_key, wrapped_private_key, recovery_*, optional branding_json override, optional seat_limit.
  • users: add partner_id; role in the four roles above; client_scope (null = all clients in the partner; a client_id for client_viewer).
  • Child tables (machines, runs, secrets, drift, progress, builds, build_profiles, tenant_idp, machine_creds, api_tokens, audit): a tenant_id here now means a client id. We keep the physical column name tenant_id rather than rename it repo-wide — the rename is cosmetic and a repo-wide change to a key referenced everywhere is pure risk for no functional gain. "Tenant" is retired only as a user-facing term. Optionally denormalize partner_id for fast provider rollups later.

Migration (no data loss)

  1. Create a "Totlcom" partner row.
  2. Set partner_id = Totlcom on every existing client (renamed tenant) and user.
  3. Map existing roles: providerprovider, adminpartner_admin, engineertechnician, readonlyclient_viewer (applied in R2, together with the code that understands the new roles, so there's no broken intermediate state).

Migration 0011_partners.sql performs steps 1–2 (create Totlcom partner, backfill partner_id) and is non-breaking: nothing reads partner_id until R2/R3.

Phased delivery

  • R1 — Model & migration. partners table; clients rename + partner_id; tenant_idclient_id across child tables; migration that creates the Totlcom partner and reparents existing rows.
  • R2 — Auth & scoping. resolveUser returns partner + client + role; new role set; canManagePartner / canManageClient; rescoped guards on every endpoint. Tests.
  • R3 — API rescope. Move all /v1/* to client-under-partner scoping; add /v1/partners (provider) and /v1/clients (partner) endpoints.
  • R4 — Partner portal. Login → the partner's Clients list → drill into a client → the existing workspace (devices, profiles, builds, secrets, SSO). Branding = the partner's.
  • R5 — Provider console. Partners list with usage/seat/billing rollups → drill into a partner → its clients; retire the flat all-clients view.
  • R6 — Onboarding & white-label. Partner creation/self-serve signup, per-partner branding, custom domain automation.
  • R7 — Billing (Stripe). Seats metered per partner; plans and invoices.

Sequencing: R1→R2→R3 is the correctness core (data model + permissions), then R4→R5 aligns the UX, then R6→R7 is the go-to-market layer. Totlcom creates partners until R6; billing lands last.

What carries over unchanged

The provisioning engine and all its phases, zero-knowledge RSA escrow + Shamir break-glass, HMAC request signing + replay protection, per-customer build runner + R2 artifacts, guided build profiles, OIDC sign-in, and the tamper-evident audit chain. Each simply rescopes from tenant to client under a partner.