# 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 `.jqc.app`. | Generate a Fernet key (run once, store permanently): ```bash 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: ```bash # /etc/jqc/control.env (chmod 640, chown root:jqc) CONTROL_DATABASE_URL=mysql+pymysql://jqc_control:@127.0.0.1/jqc_control CONTROL_FERNET_KEY= PROVISION_DB_URL=mysql+pymysql://jqc_provisioner:@127.0.0.1/ TENANT_BASE_DOMAIN=jqc.app MULTI_TENANT_ENABLED=true ``` ```ini # systemd unit [Service] EnvironmentFile=/etc/jqc/control.env ``` ```bash # any CLI session that provisions tenants set -a; . /etc/jqc/control.env; set +a ``` ## MySQL accounts required ```sql -- Control DB user CREATE DATABASE jqc_control CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE USER 'jqc_control'@'localhost' IDENTIFIED BY ''; 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 ''; 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) ```bash 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. ```bash python -m control.provision register-tenant-zero \ --slug lts --name "LT Services" --plan enterprise \ --db-host 127.0.0.1 \ --db-name \ --db-user \ --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) ```bash 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/` 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) ```bash # 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 ```bash # 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_`). This prevents accidental drops of externally-named databases like LT's production DB. ## Rollback ```bash # 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.