12 KiB
Multi-Tenant Plan — JQC
Audience: AI assistants and developers extending JQC into a multi-tenant SaaS. Companion to:
CLAUDE.md(single-tenant architecture reference). Status: Planning. No code written yet. Existing single-tenant deploy stays live and untouched until cutover.
1. Decisions (locked)
| Decision | Choice |
|---|---|
| Isolation model | Shared codebase + database-per-tenant (one deploy, dynamic Host routing). NOT fork-per-tenant. |
| "Managed by tenant" | Tenant-admin self-serves branding / users / settings / in-plan feature toggles. No per-tenant code edits. |
| Plan-tier axes | (1) user quota, (2) facility quota, (3) inspections/month quota, (4) issues/month quota, (5) feature gates (mobile API, scheduled reports, …), (6) branding/white-label, (7) custom-domain vs subdomain-only. |
| Existing LT deploy | Migrated in as tenant-zero — register existing live DB in place, no data move (§8). |
| Quota-exceed behavior | Soft warn — allow submit, flag for upgrade. Never reject. |
| Tenant DB credentials | Per-tenant MySQL user + password. Provisioning creates the user/grants; creds encrypted at rest in control DB. |
Terminology: control plane = manages tenants (registry, plans, provisioning). data plane = serves tenant traffic (the existing Flask app, now tenant-aware).
2. Why this model
- DB-per-tenant → true data isolation; satisfies "separate database". Each tenant = own MySQL database, e.g.
jqc_<slug>. - Shared code → one codebase to patch. As a solo maintainer, N forks would mean N bug-fix deploys. Avoided.
- App already uses the application-factory pattern (
create_app) and relative/api/v1paths — both ideal for adding Host-based tenant routing with minimal churn. - Migrations already use
INFORMATION_SCHEMAexistence checks (CLAUDE.md Rule 14) → safe to re-run across every tenant DB unchanged.
3. Control-plane database (new, separate schema)
Lives in its own MySQL database (e.g. jqc_control), its own Alembic env (chain prefix control{N}_…), never routed by tenant middleware.
plans
id, code (slug: free/starter/pro/enterprise), name, active,
max_users INT NULL, -- NULL = unlimited
max_facilities INT NULL,
max_inspections_month INT NULL,
max_issues_month INT NULL,
allow_custom_domain BOOL,
allow_mobile_api BOOL,
allow_scheduled_reports BOOL,
allow_branding BOOL, -- white-label
price_cents INT NULL, -- future billing
billing_period VARCHAR(16) NULL
plan_features -- optional EAV escape hatch for future boolean flags
id, plan_id (FK), feature_key, enabled BOOL
tenants
id, slug (subdomain label, UNIQUE), name, plan_id (FK),
status ENUM(provisioning, active, suspended, deleted),
db_host, db_port, db_name, db_user, db_password_enc, -- creds encrypted at rest
alembic_head VARCHAR(64), -- last tenant-schema rev applied
created_at, suspended_at, notes
tenant_domains
id, tenant_id (FK), domain (UNIQUE), kind ENUM(subdomain, custom),
is_primary BOOL, verified BOOL, verification_token,
tls_status ENUM(pending, active, failed), created_at
superadmins -- cross-tenant accounts, control-plane only
id, username, email, password_hash, active, created_at
provisioning_jobs
id, tenant_id (FK), action ENUM(create_db, migrate, seed, suspend, delete),
status ENUM(queued, running, ok, failed), log TEXT, created_at, finished_at
tenant_audit -- superadmin actions (separate from per-tenant log_action)
id, superadmin_id, action, tenant_id, details, ip_address, created_at
Submission quotas (inspections/issues per month) are enforced by live-counting current-period rows in the tenant DB at submit time (cheap with an index on the date column). No separate counter table required; always accurate. A cached usage_counters table is an optional later optimization if submit-path counts ever show up in profiling.
4. Tenant resolution + DB routing (the core mechanism)
Resolution (before_request): host = request.host.split(':')[0] → look up tenant_domains.domain == host where verified AND tenant.status='active' → load tenant + plan into g. Unknown/unverified/suspended host → branded error/landing page (no tenant DB touched).
Routing without rewriting every db.session call — use a routing Session so the entire existing codebase keeps using db.session unchanged (preserves Change-Philosophy Rules 5/7):
# app/tenancy/routing.py
from sqlalchemy.orm import Session
from flask import g
class RoutingSession(Session):
def get_bind(self, mapper=None, clause=None, **kw):
# Tenant models (app.db.Model) bind to the per-request tenant engine.
# The control plane is a SEPARATE Base/engine/session (control/base.py),
# so it never passes through this session — no special-casing needed here.
return getattr(g, 'tenant_engine', None) or _default_engine
MT-0 refinement (implemented): the control plane uses its own
ControlBase+ engine + session (control/base.py), notdb.Modelwith a__control_plane__flag. Stronger isolation, zero metadata mixing with the tenant schema, and the control package imports nothing fromapp/. TheRoutingSession(MT-1) therefore only handles tenant models.
# app/__init__.py (one-line change to the existing db init)
db = SQLAlchemy(session_options={'class_': RoutingSession})
# before_request
g.tenant_engine = _engine_cache.get_or_create(tenant) # create_engine(tenant.db_uri)
_engine_cache:dict[tenant_id -> Engine], lazily built. Use small pools +pool_recycle; switch toNullPoolif tenant count grows large (connection-count = Σ tenant pools). Documented scaling lever.- The control plane is a separate Base/engine/session (
control/base.py); it never routes throughRoutingSession. See §3 MT-0 refinement. - Net code change to existing models/routes: zero. They keep importing
from app import dband callingdb.session.
5. Migrations
Two independent Alembic chains:
- Tenant schema — the existing chain (current HEAD on disk:
phase27_score_alerts; noteCLAUDE.mdtext says phase23 — stale). Runs per-tenant DB. New tenant features continue this chain asphase28_…,phase29_…per existing naming +down_revision→ current HEAD rule. - Control schema — new env, chain
control{N}_…, runs once againstjqc_control.
CLI added: flask tenant db upgrade --tenant <id|all> loops tenants, sets sqlalchemy.url to each tenant URI, runs command.upgrade(cfg, 'head'), writes resulting head back to tenants.alembic_head. All existing migrations are re-run-safe (Rule 14), so this is idempotent across the fleet.
Deploy ordering rule carries over (CLAUDE.md Rule 23): run tenant migrations before shipping app code that references new columns.
6. Plan-tier matrix (starting point — tune in §9)
| Axis | Free | Starter | Pro | Enterprise |
|---|---|---|---|---|
| Max users | 3 | 15 | 50 | unlimited |
| Max facilities | 2 | 10 | 50 | unlimited |
| Inspections / month | 50 | 500 | 5 000 | unlimited |
| Issues / month | 50 | 500 | 5 000 | unlimited |
| Mobile API (iPad app) | ✗ | ✓ | ✓ | ✓ |
| Scheduled reports | ✗ | ✗ | ✓ | ✓ |
| Branding / white-label | ✗ | ✗ | ✓ | ✓ |
| Custom domain | ✗ | ✗ | ✓ | ✓ |
Subdomain (*.jqc.app) |
✓ | ✓ | ✓ | ✓ |
Enforcement: @feature_required('mobile_api') reads g.tenant.plan and blocks disabled features. Quota checks (@quota_check('inspections')) are soft — over-limit submits still succeed but the response/UI flags an upgrade prompt and the event is recorded; submits are never rejected. Feature + quota checks must live in BOTH web routes and /api/v1 endpoints — the iPad submits via API, so a web-only check is bypassable (mirrors existing dual-enforcement Rules 39/56/57).
7. Phased roadmap
Each phase additive; existing tenant-zero traffic keeps working throughout.
MT-0 — Control-plane scaffold. ✅ DONE. Self-contained control/ package at repo root: own ControlBase + engine + session, own Alembic chain (control0001_init), 7 models, Fernet-encrypted tenant creds, idempotent plan seeder, operator CLI. Zero imports into app/ — existing app untouched.
MT-1 — Tenant resolution + routing. ✅ DONE. app/tenancy/ package: routing.py (RoutingSession subclassing the Flask-SQLAlchemy session), resolver.py (Host→tenant via control plane, lazy import), engine_cache.py (per-tenant engines), context.py (TenantContext), middleware.py (init_tenancy before_request hook + branded unknown-host 404). Edits: db init in app/__init__.py (+init_tenancy(app) call) and MULTI_TENANT_ENABLED + pool flags in config.py. Gated behind MULTI_TENANT_ENABLED (default False) — fully inert until flipped.
MT-2 — Per-tenant migration runner. flask tenant db upgrade --tenant <id|all>; record alembic_head per tenant.
MT-3 — Provisioning service. Create DB → create per-tenant MySQL user + password + grant scoped to that DB only → upgrade to head → seed first tenant-admin → invite email. Creds encrypted into the tenants row. Idempotent, provisioning_jobs-logged.
MT-4 — Superadmin control panel. Tenant CRUD, plan assign, suspend/resume, domain mgmt, migration status, impersonation (scoped login into a tenant for support). At admin.jqc.app, separate blueprint, superadmin-gated.
MT-5 — Plans + quota/feature gating. @feature_required, @quota_check; new cross-tenant superadmin (control plane). Existing tenant admin = top role within a tenant (unchanged ENUM).
MT-6 — Custom domain + TLS. Wildcard cert for *.jqc.app; Caddy on-demand TLS in front for arbitrary custom domains. Domain-verification flow (TXT/CNAME) before activation.
MT-7 — Tenant self-service. Tenant-admin: branding (logo/name/colors), domain request, plan view, in-plan feature toggles. User mgmt auto tenant-scoped by routing.
MT-8 — Billing (future). Stripe per-plan; lifecycle (trial/suspend/dunning). Flag-gated.
MT-9 — iOS multi-tenant. Replace hardcoded ServerOption enum (Utils/Constants.swift) with onboarding: enter subdomain or work-email → discovery endpoint resolves base URL → store per-tenant in Keychain. All /api/v1 calls unchanged (server resolves tenant by Host). Tenant-zero keeps jqc.ltservicesinc.com as a custom domain so existing builds keep working through rollout.
8. Tenant-zero (LT Services) migration
Register the existing live LT database in place — no dump/reload:
- Insert
tenantsrow (id 1),db_*pointing at the current LT DB + creds. Record its current alembic head. - Insert
tenant_domains: primary subdomainlts.jqc.app; plusjqc.ltservicesinc.com(kind=custom, verified) so existing web users and existing iPad builds keep working with zero disruption. - Point wildcard DNS / Caddy at the (now tenant-aware) app. Resolver returns tenant 1 for both hosts.
Result: existing users notice nothing; LT is now just "tenant 1" inside the new model.
9. Decisions resolved — MT-0 unblocked
- Quota-exceed → soft warn (allow + flag upgrade). Never reject. §6.
- Tenant DB credentials → per-tenant MySQL user + password; provisioning creates user/grants scoped to the single tenant DB; creds encrypted at rest. §3, MT-3.
- Tenant-zero → register existing live LT DB in place, no data move. §8.
All planning decisions locked. Ready to begin MT-0 (control-plane scaffold) on request.
MT-0 first deliverables (preview)
jqc_controldatabase + control Alembic env (control0001_init).- Control models on a dedicated
ControlBase:Plan,PlanFeature,Tenant,TenantDomain,Superadmin,ProvisioningJob,TenantAudit. - Plan seeder (Free / Starter / Pro / Enterprise per §6 matrix).
- No data-plane change in MT-0 — existing app keeps running against the LT DB exactly as today.