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 oftenants): addpartner_id; keepspublic_key, wrapped_private_key, recovery_*, optionalbranding_jsonoverride, optionalseat_limit.users: addpartner_id;rolein the four roles above;client_scope(null = all clients in the partner; aclient_idforclient_viewer).- Child tables (
machines, runs, secrets, drift, progress, builds, build_profiles, tenant_idp, machine_creds, api_tokens, audit): atenant_idhere now means a client id. We keep the physical column nametenant_idrather 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 denormalizepartner_idfor fast provider rollups later.
Migration (no data loss)¶
- Create a "Totlcom" partner row.
- Set
partner_id= Totlcom on every existing client (renamed tenant) and user. - Map existing roles:
provider→provider,admin→partner_admin,engineer→technician,readonly→client_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.
partnerstable;clientsrename +partner_id;tenant_id→client_idacross child tables; migration that creates the Totlcom partner and reparents existing rows. - R2 — Auth & scoping.
resolveUserreturns 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.