16 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: MT-0 through MT-7 complete and deployed. MT-8 flag-gated (future). MT-9 (iOS) pending.
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.
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:
# 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):
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. 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})
_engine_cache:dict[tenant_id -> Engine], lazily built, small pools +pool_recycle.- Net code change to existing models/routes: zero.
MT-5 addition: Plan limits and feature flags are loaded into TenantContext by the resolver in the same control-DB session — zero extra queries per request. g.tenant.allow_mobile_api, g.tenant.max_users, etc. are available everywhere.
5. Migrations
Two independent Alembic chains:
- Tenant schema — existing chain (HEAD:
phase33_tenant_settings). Runs per-tenant DB. New tenant features continue asphase34_…per existing naming. - Control schema — chain
control{N}_…, runs once againstjqc_control. HEAD:control0001_init.
CLI (always source env first):
set -a; . /etc/jqc/control.env; set +a
python -m control.tenant_migrate upgrade --tenant all
Deploy ordering rule: run tenant migrations before shipping app code that references new columns.
6. Plan-tier matrix
| 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') hard-blocks (403) disabled features. @quota_soft_check('inspections') sets g.quota_warning but never rejects — over-limit submits succeed, UI shows upgrade prompt. Both decorators are inert when MULTI_TENANT_ENABLED=false.
Feature + quota checks live in both web routes and /api/v1 endpoints (iPad submits via API — web-only check is bypassable).
7. Phased roadmap
MT-0 — Control-plane scaffold. ✅ DONE.
Self-contained control/ package: own ControlBase + engine + session, own Alembic chain (control0001_init), 7 models, Fernet-encrypted tenant creds, idempotent plan seeder, operator CLI. Zero imports into app/.
MT-1 — Tenant resolution + routing. ✅ DONE.
app/tenancy/ package: RoutingSession, resolve_tenant(), engine_cache, TenantContext, init_tenancy() before_request hook + branded 404. Gated behind MULTI_TENANT_ENABLED=false — fully inert until flipped.
MT-2 — Per-tenant migration runner. ✅ DONE.
migrations_tenant/env.py + control/tenant_migrate.py (upgrade_tenant, bootstrap_tenant, chain_head, CLI). Guarded squashed baseline 0003_add_user_active restores chain root. Fresh DBs use bootstrap_tenant (baseline → stamp head, skips unguarded phase migrations). Incremental upgrades use upgrade_tenant (phase33+).
⚠ Blocker found by MT-2 — RESOLVED. Chain had no base; 14/30 phase migrations unguarded. Fixed by guarded squashed baseline. Fresh DB provisioning must use
bootstrap_tenant, never rawflask db upgrade.
MT-3 — Provisioning service. ✅ DONE.
control/provision.py: create_tenant() (DB + user + schema + admin seed + domain), register_tenant_zero() (adopt LT DB in place), delete_tenant(). Fernet-encrypted creds, provisioning_jobs logged. CLI: python -m control.provision.
MT-4 — Superadmin control panel. ✅ DONE.
Standalone Flask app at admin.jqc.app (control/panel/). WSGI entry: control/panel/wsgi_panel.py → Gunicorn on port 8001 (jqc-panel.service). Separate Nginx server block — must appear before the *.jqc.app wildcard block or Nginx routes admin.jqc.app to port 8000 (main app).
Routes: tenant list/detail, plan change, suspend/resume, domain CRUD (add/verify/delete), migration status + upgrade trigger, provision new tenant, impersonation.
Impersonation flow:
- Panel generates HMAC-SHA256 signed token (
PANEL_IMPERSONATE_KEY, TTL 60 s). - Redirects to
https://<tenant-primary-domain>/auth/impersonate?token=<t>. - Main app validates token, sets
session['impersonating_tenant_id']. - Tenancy middleware reads this key and short-circuits Host resolution.
- "End impersonation" banner clears key, redirects back to
admin.jqc.app.
Required env vars (add to /etc/jqc/control.env):
PANEL_SECRET_KEY=<hex32>
PANEL_IMPERSONATE_KEY=<hex32>
MT-5 — Plans + quota/feature gating. ✅ DONE.
app/tenancy/quota.py — live counters (inspections/issues this month, total users/facilities) against tenant DB.
app/tenancy/gates.py — @feature_required(key) (hard 403) and @quota_soft_check(axis) (sets g.quota_warning, never rejects).
app/tenancy/context.py — TenantContext extended with 9 plan fields (all default to unlimited/True → single-tenant unchanged).
app/tenancy/resolver.py — loads plan in the same control session, populates TenantContext plan fields.
app/templates/_quota_warning.html — reusable upgrade-prompt banner partial.
Gated routes:
| Route | Gate |
|---|---|
inspections.start() |
@quota_soft_check('inspections') |
issues.create() |
@quota_soft_check('issues') |
auth.create_user() |
@quota_soft_check('users') |
facilities.create_facility() |
@quota_soft_check('facilities') |
scheduled_reports.index() + create() |
@feature_required('scheduled_reports') |
api.create_inspection() |
@feature_required('mobile_api') + @quota_soft_check('inspections') |
api.create_issue() |
@feature_required('mobile_api') + @quota_soft_check('issues') |
Decorator stack order: @login_required → @role_required → @feature_required → @quota_soft_check.
MT-6 — Custom domain + TLS. ⚙ INFRASTRUCTURE ONLY — no Python deliverables.
Wildcard cert *.jqc.app via DNS-01 challenge (certbot + DNS plugin). Custom-domain TLS via Caddy on-demand TLS. Domain-verification flow (TXT/CNAME) already in MT-7 self-service UI; superadmin marks verified=True in the control panel after DNS check. When Caddy is deployed, update Nginx to pass custom domains to Caddy rather than directly to port 8000.
MT-7 — Tenant self-service. ✅ DONE.
app/models/tenant_settings.py — TenantSettings model, one row per tenant DB, get_or_default() returns transient defaults when no row exists (zero migration burden for existing tenants).
app/routes/tenant_settings.py — blueprint at /settings/, @admin_required.
app/templates/tenant_settings/ — branding.html, plan.html, domains.html.
Migration: phase33_tenant_settings (INFORMATION_SCHEMA guarded, safe to re-run).
Branding injection: inject_tenant_branding() context processor in app/__init__.py pushes tenant_branding into every template. base.html patches: navbar brand reads logo/name from tenant_branding; CSS vars --bs-primary, --jqc-accent injected via inline <style> from tenant_branding.primary_color / accent_color. Jinja2 filter hex_to_rgb registered for Bootstrap RGB var.
Self-service features:
- Branding: company name, logo upload (magic-byte validated), primary/accent colours, support email. Gated by
allow_branding— shows warning but doesn't block form (soft, consistent with quota philosophy). - Plan & Usage: read-only plan info + live quota progress bars (reads from control DB + tenant DB live counts). "Request upgrade" mailto link.
- Domains: lists all
TenantDomainrows. Admin can request a custom domain (creates unverified row, shows TXT/CNAME DNS instructions). Delete unverified custom domains. Superadmin does final verification via control panel.
MT-8 — Billing. 🔲 FUTURE — flag-gated.
Stripe per-plan subscription. Lifecycle: trial → active → past_due → suspended → cancelled. Dunning emails. All behind a BILLING_ENABLED feature flag. No code written.
MT-9 — iOS multi-tenant. 🔲 PENDING.
Server side: GET /api/v1/discover?subdomain=acme and GET /api/v1/tenant public endpoints — exempt from tenant middleware via MULTI_TENANT_EXEMPT_PATHS. iOS side: pending (web-first priority).
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 keep working. - Point wildcard DNS / Nginx at the (now tenant-aware) app. Resolver returns tenant 1 for both hosts.
Result: existing users notice nothing; LT is now "tenant 1".
9. Decisions resolved
- Quota-exceed → soft warn (allow + flag upgrade). Never reject.
- Tenant DB credentials → per-tenant MySQL user + password; creds encrypted at rest.
- Tenant-zero → register existing live LT DB in place, no data move.
- Impersonation → HMAC-signed token (60 s TTL), panel→tenant redirect, session key short-circuits Host resolution.
- Branding gate → soft (shows warning, form still usable) — consistent with quota philosophy.
10. Nginx — block ordering (critical)
# /etc/nginx/sites-available/jqc
# 1. PANEL — exact match, must be FIRST
server {
listen 80;
server_name admin.jqc.app;
location / { proxy_pass http://127.0.0.1:8001; ... }
}
# 2. APEX redirect — jqc.app has no registered tenant
server {
listen 80;
server_name jqc.app;
return 301 http://lts.jqc.app$request_uri;
}
# 3. WILDCARD — all tenant subdomains
server {
listen 80;
server_name *.jqc.app;
location / { proxy_pass http://127.0.0.1:8000; ... }
}
If admin.jqc.app is in the same block as *.jqc.app, Nginx routes it to port 8000 (main app), which returns "Workspace not found" because admin.jqc.app is not a registered tenant domain.
11. CLI quick-reference
# Source env first — CONTROL_DATABASE_URL not in interactive shell by default
set -a; . /etc/jqc/control.env; set +a
# Control schema + plans + first superadmin (run once)
alembic -c control/migrations/alembic.ini upgrade head
python -m control.cli seed
python -m control.cli create-superadmin --username admin --email you@example.com
# Adopt existing LT database as tenant-zero (run once, no data move)
python -m control.provision register-tenant-zero \
--slug lts --name "LT Services" --plan enterprise \
--db-host 127.0.0.1 --db-name <LT_DB> --db-user <LT_USER> --db-password '<pw>' \
--custom-domain jqc.ltservicesinc.com --base-domain jqc.app
# Provision a new tenant
python -m control.provision create-tenant \
--slug acme --name "Acme Corp" --plan pro --admin-email ops@acme.com
# Delete / deregister a tenant
python -m control.provision delete-tenant --slug ztest --drop-db --yes
python -m control.provision delete-tenant --slug lts --yes # no --drop-db for adopted DB
# Migration status + upgrades
python -m control.tenant_migrate heads
python -m control.tenant_migrate current --tenant all
python -m control.tenant_migrate upgrade --tenant all # incremental (phase33+)
python -m control.tenant_migrate bootstrap --tenant acme # fresh DB only
# Superadmin panel
sudo systemctl status jqc-panel
sudo systemctl restart jqc-panel