Files
JQC_multi_tenant/MULTI_TENANT_PLAN.md
T
2026-06-26 16:19:48 -04:00

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/v1 paths — both ideal for adding Host-based tenant routing with minimal churn.
  • Migrations already use INFORMATION_SCHEMA existence 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), not db.Model with a __control_plane__ flag. Stronger isolation, zero metadata mixing with the tenant schema, and the control package imports nothing from app/. The RoutingSession (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 to NullPool if 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 through RoutingSession. See §3 MT-0 refinement.
  • Net code change to existing models/routes: zero. They keep importing from app import db and calling db.session.

5. Migrations

Two independent Alembic chains:

  1. Tenant schema — the existing chain (current HEAD on disk: phase27_score_alerts; note CLAUDE.md text says phase23 — stale). Runs per-tenant DB. New tenant features continue this chain as phase28_…, phase29_… per existing naming + down_revision → current HEAD rule.
  2. Control schema — new env, chain control{N}_…, runs once against jqc_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. app/tenancy/: resolver.py, routing.py (RoutingSession), engine_cache.py, before_request/teardown hooks. One-line db init change. Unknown-host landing page.

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:

  1. Insert tenants row (id 1), db_* pointing at the current LT DB + creds. Record its current alembic head.
  2. Insert tenant_domains: primary subdomain lts.jqc.app; plus jqc.ltservicesinc.com (kind=custom, verified) so existing web users and existing iPad builds keep working with zero disruption.
  3. 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

  1. Quota-exceed → soft warn (allow + flag upgrade). Never reject. §6.
  2. 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.
  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_control database + 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.