7.3 KiB
Control Plane (MT-0 → MT-3)
Tenant registry, plans, domains, provisioning state, and superadmin accounts
for multi-tenant JQC. Self-contained and decoupled from app/ — imports
nothing from the data-plane application.
See ../MULTI_TENANT_PLAN.md for the full phased roadmap.
Layout
control/
├── __init__.py # package docs
├── base.py # ControlBase + engine/session (CONTROL_DATABASE_URL)
├── crypto.py # Fernet encrypt/decrypt for tenant DB passwords (CONTROL_FERNET_KEY)
├── time_utils.py # now_eastern() mirror (no app import)
├── models.py # Plan, PlanFeature, Tenant, TenantDomain,
│ # Superadmin, ProvisioningJob, TenantAudit
├── seed.py # idempotent baseline-plan seeder (Free/Starter/Pro/Enterprise)
├── cli.py # seed / create-superadmin / list-plans
├── tenant_migrate.py # MT-2 — upgrade_tenant / bootstrap_tenant / chain_head + CLI
├── provision.py # MT-3 — create_tenant / register_tenant_zero / delete_tenant + CLI
└── migrations/ # standalone Alembic chain (control{N}_…)
└── versions/
└── control0001_init.py ← HEAD (7 tables)
Environment variables
All must live in /etc/jqc/control.env (see §3 below). Never hard-code.
| Variable | Purpose |
|---|---|
CONTROL_DATABASE_URL |
e.g. mysql+pymysql://jqc_control:pw@127.0.0.1/jqc_control |
CONTROL_FERNET_KEY |
Fernet key for encrypting tenant DB passwords. Generate once, never rotate without re-encrypting all tenant rows. |
PROVISION_DB_URL |
MySQL account with CREATE DATABASE / CREATE USER / GRANT rights. |
TENANT_BASE_DOMAIN |
Apex for subdomains, e.g. jqc.app. Builds <slug>.jqc.app. |
Generate a Fernet key (run once, store permanently):
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
Canonical env file (required — prevents Fernet key mismatch)
The CONTROL_FERNET_KEY used at provisioning time must be identical to the key
the running app uses to decrypt tenant creds. A mismatch causes
cryptography.fernet.InvalidToken at request time. One file prevents this:
# /etc/jqc/control.env (chmod 640, chown root:jqc)
CONTROL_DATABASE_URL=mysql+pymysql://jqc_control:<pw>@127.0.0.1/jqc_control
CONTROL_FERNET_KEY=<key — no quotes, no trailing space>
PROVISION_DB_URL=mysql+pymysql://jqc_provisioner:<pw>@127.0.0.1/
TENANT_BASE_DOMAIN=jqc.app
MULTI_TENANT_ENABLED=true
# systemd unit [Service]
EnvironmentFile=/etc/jqc/control.env
# any CLI session that provisions tenants
set -a; . /etc/jqc/control.env; set +a
MySQL accounts required
-- Control DB user
CREATE DATABASE jqc_control CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'jqc_control'@'localhost' IDENTIFIED BY '<pw>';
GRANT ALL PRIVILEGES ON jqc_control.* TO 'jqc_control'@'localhost'; FLUSH PRIVILEGES;
-- Provisioner (creates per-tenant DBs + users)
CREATE USER 'jqc_provisioner'@'localhost' IDENTIFIED BY '<pw>';
GRANT ALL PRIVILEGES ON *.* TO 'jqc_provisioner'@'localhost' WITH GRANT OPTION;
GRANT CREATE USER ON *.* TO 'jqc_provisioner'@'localhost'; FLUSH PRIVILEGES;
Bootstrap — MT-0 (run once per environment)
set -a; . /etc/jqc/control.env; set +a
# 1. Apply control schema (7 tables)
alembic -c control/migrations/alembic.ini upgrade head
# 2. Seed Free / Starter / Pro / Enterprise plans
python -m control.cli seed
# 3. First superadmin
python -m control.cli create-superadmin --username admin --email you@example.com
# Verify
alembic -c control/migrations/alembic.ini current # → control0001_init (head)
python -m control.cli list-plans # → 4 plans
Tenant-zero — register existing LT in place (MT-3)
No DB creation, no data movement, no schema changes to the live LT database.
python -m control.provision register-tenant-zero \
--slug lts --name "LT Services" --plan enterprise \
--db-host 127.0.0.1 \
--db-name <LT_DATABASE_NAME> \
--db-user <LT_DB_USER> \
--db-password '<LT_DB_PASSWORD>' \
--custom-domain jqc.ltservicesinc.com \
--base-domain jqc.app
Output includes alembic_head (read from the existing alembic_version table)
and both domain mappings (lts.jqc.app + jqc.ltservicesinc.com as verified).
Provisioning a new tenant (MT-3)
python -m control.provision create-tenant \
--slug acme \
--name "Acme Corp" \
--plan pro \
--admin-email ops@acme.com \
--base-domain jqc.app
Steps executed automatically:
CREATE DATABASE jqc_acme+CREATE USER jqc_acme_u+GRANT(scoped to that DB)- Insert
tenantsrow with Fernet-encrypted password bootstrap_tenant()→ baseline schema +stamp head(phase migrations skipped)- Insert first admin into tenant DB with a set-password token
- Register
acme.jqc.app(verified) + optional custom domain (unverified) - Set tenant
status = active
Output includes the admin setup link — send to the admin's email:
https://acme.jqc.app/customers/set-password/<token>
On failure, a best-effort rollback drops the DB/user and removes the tenant row so a retry starts clean.
Per-tenant migrations (MT-2)
# New fresh DB (empty → full schema + stamp head; phase migrations skipped)
python -m control.tenant_migrate bootstrap --tenant acme
# Incremental upgrade (phase33+ — must be INFORMATION_SCHEMA-guarded)
python -m control.tenant_migrate upgrade --tenant all
python -m control.tenant_migrate upgrade --tenant acme # by slug
python -m control.tenant_migrate upgrade --tenant 3 # by id
# Status check
python -m control.tenant_migrate current --tenant all
python -m control.tenant_migrate heads
Rule: every new migration (phase33+) MUST use INFORMATION_SCHEMA existence
checks (_table_exists, _column_exists, _index_exists) — the same pattern
used by the squashed baseline. Unguarded migrations break idempotency and cannot
be re-run safely across the fleet.
Delete / deregister a tenant
# Provisioned tenant — delete DB + control records
python -m control.provision delete-tenant --slug acme --drop-db --yes
# Adopted DB (tenant-zero) — remove control records only; NEVER --drop-db
python -m control.provision delete-tenant --slug lts --yes
--drop-db is guarded: it only proceeds when the stored db_name matches the
provisioner convention (jqc_<slug>). This prevents accidental drops of
externally-named databases like LT's production DB.
Rollback
# Drop all 7 control tables (destructive)
alembic -c control/migrations/alembic.ini downgrade base
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 | ✗ | ✓ | ✓ | ✓ |
| Scheduled reports | ✗ | ✗ | ✓ | ✓ |
| Branding | ✗ | ✗ | ✓ | ✓ |
| Custom domain | ✗ | ✗ | ✓ | ✓ |
| Subdomain | ✓ | ✓ | ✓ | ✓ |
Quota-exceed: soft warn — allow submit, flag for upgrade, never reject.
Plans are seeded idempotently; re-running python -m control.cli seed updates
in-place without creating duplicates.