Files
JQC_multi_tenant/control
2026-06-27 11:11:00 -04:00
..
2026-06-26 16:19:48 -04:00
2026-06-27 11:11:00 -04:00
2026-06-26 16:19:48 -04:00
2026-06-26 16:19:48 -04:00
2026-06-26 16:19:48 -04:00
2026-06-26 16:19:48 -04:00
2026-06-26 16:19:48 -04:00
2026-06-27 10:15:00 -04:00
2026-06-27 10:28:57 -04:00
2026-06-26 16:19:48 -04:00
2026-06-26 16:19:48 -04:00

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:

  1. CREATE DATABASE jqc_acme + CREATE USER jqc_acme_u + GRANT (scoped to that DB)
  2. Insert tenants row with Fernet-encrypted password
  3. bootstrap_tenant() → baseline schema + stamp head (phase migrations skipped)
  4. Insert first admin into tenant DB with a set-password token
  5. Register acme.jqc.app (verified) + optional custom domain (unverified)
  6. 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.