diff --git a/MULTI_TENANT_PLAN.md b/MULTI_TENANT_PLAN.md new file mode 100644 index 0000000..1aafb6e --- /dev/null +++ b/MULTI_TENANT_PLAN.md @@ -0,0 +1,203 @@ +# 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_`. +- **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): + +```python +# 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. + +```python +# app/__init__.py (one-line change to the existing db init) +db = SQLAlchemy(session_options={'class_': RoutingSession}) +``` + +```python +# 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 ` 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 `; 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. diff --git a/control/README.md b/control/README.md new file mode 100644 index 0000000..c02b537 --- /dev/null +++ b/control/README.md @@ -0,0 +1,84 @@ +# Control Plane (MT-0) + +Tenant registry, plans, domains, provisioning state, and superadmin accounts +for multi-tenant JQC. Self-contained and decoupled from `app/` — the existing +single-tenant application is unaffected by this package. + +See `../MULTI_TENANT_PLAN.md` for the full architecture and roadmap. + +## Layout + +``` +control/ +├── __init__.py # package docs +├── base.py # ControlBase + engine/session (from CONTROL_DATABASE_URL) +├── crypto.py # Fernet encrypt/decrypt for tenant DB passwords +├── time_utils.py # now_eastern() mirror (no app import) +├── models.py # Plan, PlanFeature, Tenant, TenantDomain, +│ # Superadmin, ProvisioningJob, TenantAudit +├── seed.py # idempotent baseline-plan seeder +├── cli.py # seed / create-superadmin / list-plans +└── migrations/ # standalone Alembic chain (control{N}_…) + └── versions/control0001_init.py ← HEAD +``` + +## Environment variables (control plane only) + +| Variable | Purpose | +|---|---| +| `CONTROL_DATABASE_URL` | e.g. `mysql+pymysql://jqc_control:pw@localhost/jqc_control` | +| `CONTROL_FERNET_KEY` | Fernet key for encrypting tenant DB passwords | + +Generate a Fernet key: + +```bash +python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" +``` + +## Deploy — MT-0 bootstrap (run once) + +Migrations are independent of the tenant chain and the data plane. The existing +app does **not** need to be touched or restarted for MT-0. + +```bash +# 1. Create the control database + its MySQL user (run as a MySQL admin) +mysql -e "CREATE DATABASE jqc_control CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" +mysql -e "CREATE USER 'jqc_control'@'localhost' IDENTIFIED BY '';" +mysql -e "GRANT ALL PRIVILEGES ON jqc_control.* TO 'jqc_control'@'localhost'; FLUSH PRIVILEGES;" + +# 2. Export the env vars (add to .env or the systemd unit for the control panel later) +export CONTROL_DATABASE_URL='mysql+pymysql://jqc_control:@localhost/jqc_control' +export CONTROL_FERNET_KEY='' + +# 3. Apply the control schema +alembic -c control/migrations/alembic.ini upgrade head + +# 4. Seed baseline plans (Free / Starter / Pro / Enterprise) +python -m control.cli seed + +# 5. Create the first superadmin +python -m control.cli create-superadmin --username admin --email you@example.com +``` + +Verify: + +```bash +alembic -c control/migrations/alembic.ini current # → control0001_init (head) +python -m control.cli list-plans +``` + +## Rollback + +```bash +alembic -c control/migrations/alembic.ini downgrade base # drops all control tables +``` + +## Notes + +- The migration uses INFORMATION_SCHEMA existence checks (Rule 14) — safe to re-run. +- Plan seeding is idempotent (upsert by `code`) — re-running updates in place. +- Tenant DB passwords are stored Fernet-encrypted in `tenants.db_password_enc`; + `Tenant.db_uri` decrypts on demand. Provisioning that *creates* per-tenant + MySQL users/grants lands in MT-3. +- Control-panel write auditing (`tenant_audit`) is wired in MT-4; the bootstrap + CLI logs to stdout only. diff --git a/control/__init__.py b/control/__init__.py new file mode 100644 index 0000000..7e34408 --- /dev/null +++ b/control/__init__.py @@ -0,0 +1,23 @@ +""" +control/ +======== +JQC control plane (MT-0). + +Manages the tenant registry, plans, domains, provisioning state, and +superadmin accounts. Deliberately DECOUPLED from the data-plane Flask app +in `app/`: + + * Its own declarative base — `control.base.ControlBase` + * Its own engine + session — built from the `CONTROL_DATABASE_URL` env var + * Its own Alembic migration chain — `control/migrations/` (prefix control{N}) + +Nothing under `app/` imports this package during MT-0, so the existing +single-tenant application runs exactly as before. + +Bootstrap (operator): + export CONTROL_DATABASE_URL='mysql+pymysql://jqc_control:pw@localhost/jqc_control' + export CONTROL_FERNET_KEY='' + 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 +""" diff --git a/control/base.py b/control/base.py new file mode 100644 index 0000000..4ec094c --- /dev/null +++ b/control/base.py @@ -0,0 +1,75 @@ +""" +control/base.py +--------------- +Standalone SQLAlchemy foundation for the control plane. + +`ControlBase` keeps control-plane table metadata fully separate from the +tenant-schema models declared on `app.db.Model`. The engine is built lazily +from the `CONTROL_DATABASE_URL` environment variable. +""" + +import os +from contextlib import contextmanager + +from sqlalchemy import create_engine +from sqlalchemy.orm import declarative_base, sessionmaker + +# Every control model subclasses this base — its own metadata collection. +ControlBase = declarative_base() + + +def get_control_database_url() -> str: + """Return CONTROL_DATABASE_URL or raise a clear error if unset.""" + url = os.environ.get('CONTROL_DATABASE_URL') + if not url: + raise RuntimeError( + "CONTROL_DATABASE_URL is not set. Example: " + "mysql+pymysql://jqc_control:password@localhost/jqc_control" + ) + return url + + +_engine = None +_Session = None + + +def get_engine(): + """Lazily build (and cache) the control-plane engine.""" + global _engine + if _engine is None: + _engine = create_engine( + get_control_database_url(), + pool_pre_ping=True, + pool_recycle=1800, + future=True, + ) + return _engine + + +def get_sessionmaker(): + """Lazily build (and cache) the control-plane sessionmaker.""" + global _Session + if _Session is None: + _Session = sessionmaker( + bind=get_engine(), future=True, expire_on_commit=False + ) + return _Session + + +@contextmanager +def control_session(): + """Context-managed session — commits on success, rolls back on error. + + Usage: + with control_session() as s: + s.add(obj) + """ + session = get_sessionmaker()() + try: + yield session + session.commit() + except Exception: + session.rollback() + raise + finally: + session.close() diff --git a/control/cli.py b/control/cli.py new file mode 100644 index 0000000..bbd06b1 --- /dev/null +++ b/control/cli.py @@ -0,0 +1,79 @@ +""" +control/cli.py +-------------- +Thin operator CLI for the control plane. Schema changes go through Alembic +(`alembic -c control/migrations/alembic.ini upgrade head`); this CLI covers +the bootstrap actions on top of an already-migrated control DB. + + python -m control.cli seed + python -m control.cli create-superadmin --username admin --email you@example.com + python -m control.cli list-plans +""" + +import argparse +import getpass +import sys + +from control.base import control_session +from control.models import Plan, Superadmin +from control.seed import seed_plans + + +def _cmd_seed(_args): + created, updated = seed_plans() + print(f'Plans seeded — created={created} updated={updated}') + + +def _cmd_list_plans(_args): + with control_session() as s: + plans = s.query(Plan).order_by(Plan.id).all() + if not plans: + print('No plans found. Run: python -m control.cli seed') + return + for p in plans: + print(f' [{p.code}] {p.name} ' + f'users={p.max_users} facilities={p.max_facilities} ' + f'insp/mo={p.max_inspections_month} issues/mo={p.max_issues_month} ' + f'custom_domain={p.allow_custom_domain} mobile={p.allow_mobile_api} ' + f'sched_reports={p.allow_scheduled_reports} branding={p.allow_branding}') + + +def _cmd_create_superadmin(args): + with control_session() as s: + if s.query(Superadmin).filter_by(username=args.username).first(): + print(f"Superadmin '{args.username}' already exists.") + sys.exit(1) + if s.query(Superadmin).filter_by(email=args.email).first(): + print(f"Email '{args.email}' already in use.") + sys.exit(1) + password = args.password or getpass.getpass('Password: ') + if not password: + print('Password cannot be empty.') + sys.exit(1) + sa = Superadmin(username=args.username, email=args.email, active=True) + sa.set_password(password) + s.add(sa) + print(f"Superadmin '{args.username}' created.") + + +def main(argv=None): + parser = argparse.ArgumentParser(prog='control.cli', + description='JQC control-plane CLI') + sub = parser.add_subparsers(dest='command', required=True) + + sub.add_parser('seed', help='Seed/upsert baseline plans').set_defaults(func=_cmd_seed) + sub.add_parser('list-plans', help='List plans').set_defaults(func=_cmd_list_plans) + + p_sa = sub.add_parser('create-superadmin', help='Create a superadmin account') + p_sa.add_argument('--username', required=True) + p_sa.add_argument('--email', required=True) + p_sa.add_argument('--password', default=None, + help='Omit to be prompted securely.') + p_sa.set_defaults(func=_cmd_create_superadmin) + + args = parser.parse_args(argv) + args.func(args) + + +if __name__ == '__main__': + main() diff --git a/control/crypto.py b/control/crypto.py new file mode 100644 index 0000000..a9d88b4 --- /dev/null +++ b/control/crypto.py @@ -0,0 +1,41 @@ +""" +control/crypto.py +----------------- +Symmetric encryption for sensitive control-plane fields (tenant DB passwords). + +Fernet (AES-128-CBC + HMAC) keyed by the CONTROL_FERNET_KEY env var. +Generate a key once: + + python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" + +Store it in the server environment / .env (never in git). Key rotation requires +re-encrypting existing tenant rows. +""" + +import os +from cryptography.fernet import Fernet + + +def _fernet() -> Fernet: + key = os.environ.get('CONTROL_FERNET_KEY') + if not key: + raise RuntimeError( + "CONTROL_FERNET_KEY is not set. Generate one with: " + "python -c \"from cryptography.fernet import Fernet; " + "print(Fernet.generate_key().decode())\"" + ) + return Fernet(key.encode() if isinstance(key, str) else key) + + +def encrypt(plaintext): + """Encrypt a string to a Fernet token (str). Passes None through.""" + if plaintext is None: + return None + return _fernet().encrypt(plaintext.encode()).decode() + + +def decrypt(token): + """Decrypt a Fernet token back to the original string. Passes None through.""" + if token is None: + return None + return _fernet().decrypt(token.encode()).decode() diff --git a/control/migrations/alembic.ini b/control/migrations/alembic.ini new file mode 100644 index 0000000..896e108 --- /dev/null +++ b/control/migrations/alembic.ini @@ -0,0 +1,41 @@ +# Alembic config for the JQC CONTROL plane (separate from the tenant chain). +# The database URL is injected at runtime from CONTROL_DATABASE_URL in env.py — +# never hard-coded here. + +[alembic] +script_location = control/migrations +prepend_sys_path = . +sqlalchemy.url = + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s diff --git a/control/migrations/env.py b/control/migrations/env.py new file mode 100644 index 0000000..aff15a5 --- /dev/null +++ b/control/migrations/env.py @@ -0,0 +1,67 @@ +""" +control/migrations/env.py +------------------------- +Standalone Alembic environment for the control plane. Unlike the data-plane +env (which pulls its engine from the Flask app), this reads the URL directly +from CONTROL_DATABASE_URL and targets ControlBase.metadata. No Flask import. +""" + +import os +import sys +from logging.config import fileConfig + +from sqlalchemy import engine_from_config, pool +from alembic import context + +# Make the repo root importable so `import control.*` resolves when Alembic +# is invoked as `alembic -c control/migrations/alembic.ini ...`. +sys.path.insert( + 0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) +) + +from control.base import ControlBase, get_control_database_url # noqa: E402 +import control.models # noqa: E402,F401 (registers tables on ControlBase.metadata) + +config = context.config +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# Inject the URL from the environment (escape % for ConfigParser). +config.set_main_option( + 'sqlalchemy.url', get_control_database_url().replace('%', '%%') +) + +target_metadata = ControlBase.metadata + + +def run_migrations_offline(): + context.configure( + url=config.get_main_option('sqlalchemy.url'), + target_metadata=target_metadata, + literal_binds=True, + compare_type=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix='sqlalchemy.', + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/control/migrations/script.py.mako b/control/migrations/script.py.mako new file mode 100644 index 0000000..9dab8a1 --- /dev/null +++ b/control/migrations/script.py.mako @@ -0,0 +1,22 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/control/migrations/versions/control0001_init.py b/control/migrations/versions/control0001_init.py new file mode 100644 index 0000000..1ad30d7 --- /dev/null +++ b/control/migrations/versions/control0001_init.py @@ -0,0 +1,171 @@ +"""control0001 — initial control-plane schema + +Creates the control-plane registry tables (MULTI_TENANT_PLAN.md §3): +plans, plan_features, superadmins, tenants, tenant_domains, +provisioning_jobs, tenant_audit. + +Raw MySQL DDL with INFORMATION_SCHEMA existence checks so the migration is +safe to re-run (CLAUDE.md Rule 14). InnoDB + utf8mb4 throughout. Targets the +CONTROL database only — never a tenant DB. +""" + +import sqlalchemy as sa +from alembic import op + +revision = 'control0001_init' +down_revision = None +branch_labels = None +depends_on = None + + +def _table_exists(bind, table: str) -> bool: + result = bind.execute(sa.text( + "SELECT COUNT(*) FROM information_schema.tables " + "WHERE table_schema = DATABASE() AND table_name = :t" + ), {'t': table}) + return result.scalar() > 0 + + +def upgrade(): + bind = op.get_bind() + + if not _table_exists(bind, 'plans'): + op.execute(sa.text(""" + CREATE TABLE plans ( + id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + code VARCHAR(32) NOT NULL, + name VARCHAR(100) NOT NULL, + active TINYINT(1) NOT NULL DEFAULT 1, + max_users INT NULL, + max_facilities INT NULL, + max_inspections_month INT NULL, + max_issues_month INT NULL, + allow_custom_domain TINYINT(1) NOT NULL DEFAULT 0, + allow_mobile_api TINYINT(1) NOT NULL DEFAULT 0, + allow_scheduled_reports TINYINT(1) NOT NULL DEFAULT 0, + allow_branding TINYINT(1) NOT NULL DEFAULT 0, + price_cents INT NULL, + billing_period VARCHAR(16) NULL, + created_at DATETIME NOT NULL, + UNIQUE KEY uq_plans_code (code) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """)) + + if not _table_exists(bind, 'plan_features'): + op.execute(sa.text(""" + CREATE TABLE plan_features ( + id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + plan_id INT NOT NULL, + feature_key VARCHAR(64) NOT NULL, + enabled TINYINT(1) NOT NULL DEFAULT 0, + CONSTRAINT fk_plan_features_plan FOREIGN KEY (plan_id) + REFERENCES plans(id) ON DELETE CASCADE, + UNIQUE KEY uq_plan_feature (plan_id, feature_key), + INDEX ix_plan_features_plan (plan_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """)) + + if not _table_exists(bind, 'superadmins'): + op.execute(sa.text(""" + CREATE TABLE superadmins ( + id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(100) NOT NULL, + email VARCHAR(255) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + active TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL, + UNIQUE KEY uq_superadmins_username (username), + UNIQUE KEY uq_superadmins_email (email) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """)) + + if not _table_exists(bind, 'tenants'): + op.execute(sa.text(""" + CREATE TABLE tenants ( + id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + slug VARCHAR(63) NOT NULL, + name VARCHAR(150) NOT NULL, + plan_id INT NOT NULL, + status ENUM('provisioning','active','suspended','deleted') + NOT NULL DEFAULT 'provisioning', + db_host VARCHAR(255) NOT NULL, + db_port INT NOT NULL DEFAULT 3306, + db_name VARCHAR(64) NOT NULL, + db_user VARCHAR(64) NOT NULL, + db_password_enc TEXT NULL, + alembic_head VARCHAR(64) NULL, + created_at DATETIME NOT NULL, + suspended_at DATETIME NULL, + notes TEXT NULL, + CONSTRAINT fk_tenants_plan FOREIGN KEY (plan_id) + REFERENCES plans(id), + UNIQUE KEY uq_tenants_slug (slug), + UNIQUE KEY uq_tenants_db_name (db_name), + INDEX ix_tenants_plan (plan_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """)) + + if not _table_exists(bind, 'tenant_domains'): + op.execute(sa.text(""" + CREATE TABLE tenant_domains ( + id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id INT NOT NULL, + domain VARCHAR(255) NOT NULL, + kind ENUM('subdomain','custom') NOT NULL, + is_primary TINYINT(1) NOT NULL DEFAULT 0, + verified TINYINT(1) NOT NULL DEFAULT 0, + verification_token VARCHAR(64) NULL, + tls_status ENUM('pending','active','failed') + NOT NULL DEFAULT 'pending', + created_at DATETIME NOT NULL, + CONSTRAINT fk_tenant_domains_tenant FOREIGN KEY (tenant_id) + REFERENCES tenants(id) ON DELETE CASCADE, + UNIQUE KEY uq_tenant_domains_domain (domain), + INDEX ix_tenant_domains_tenant (tenant_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """)) + + if not _table_exists(bind, 'provisioning_jobs'): + op.execute(sa.text(""" + CREATE TABLE provisioning_jobs ( + id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id INT NULL, + action ENUM('create_db','migrate','seed','suspend','resume','delete') + NOT NULL, + status ENUM('queued','running','ok','failed') + NOT NULL DEFAULT 'queued', + log TEXT NULL, + created_at DATETIME NOT NULL, + finished_at DATETIME NULL, + CONSTRAINT fk_provisioning_jobs_tenant FOREIGN KEY (tenant_id) + REFERENCES tenants(id) ON DELETE SET NULL, + INDEX ix_provisioning_jobs_tenant (tenant_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """)) + + if not _table_exists(bind, 'tenant_audit'): + op.execute(sa.text(""" + CREATE TABLE tenant_audit ( + id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + superadmin_id INT NULL, + action VARCHAR(50) NOT NULL, + tenant_id INT NULL, + details TEXT NULL, + ip_address VARCHAR(45) NULL, + created_at DATETIME NOT NULL, + CONSTRAINT fk_tenant_audit_superadmin FOREIGN KEY (superadmin_id) + REFERENCES superadmins(id) ON DELETE SET NULL, + CONSTRAINT fk_tenant_audit_tenant FOREIGN KEY (tenant_id) + REFERENCES tenants(id) ON DELETE SET NULL, + INDEX ix_tenant_audit_tenant (tenant_id), + INDEX ix_tenant_audit_created (created_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """)) + + +def downgrade(): + bind = op.get_bind() + for table in ('tenant_audit', 'provisioning_jobs', 'tenant_domains', + 'tenants', 'superadmins', 'plan_features', 'plans'): + if _table_exists(bind, table): + op.execute(sa.text(f'DROP TABLE {table}')) diff --git a/control/models.py b/control/models.py new file mode 100644 index 0000000..2166664 --- /dev/null +++ b/control/models.py @@ -0,0 +1,213 @@ +""" +control/models.py +----------------- +Control-plane ORM models. See MULTI_TENANT_PLAN.md §3 for the schema and §6 +for the plan-tier matrix. ENUM columns use raw string members to match the +data-plane house style (db.Enum('a','b',...)). +""" + +from urllib.parse import quote_plus + +from sqlalchemy import ( + Column, Integer, String, Boolean, DateTime, Text, + ForeignKey, Enum, UniqueConstraint, +) +from sqlalchemy.orm import relationship +from werkzeug.security import generate_password_hash, check_password_hash + +from control.base import ControlBase +from control.time_utils import now_eastern +from control import crypto + + +class Plan(ControlBase): + """A subscription tier. Quota columns are NULL = unlimited.""" + __tablename__ = 'plans' + + id = Column(Integer, primary_key=True) + code = Column(String(32), unique=True, nullable=False, index=True) + name = Column(String(100), nullable=False) + active = Column(Boolean, nullable=False, default=True) + + # Quota axes (NULL = unlimited) + max_users = Column(Integer, nullable=True) + max_facilities = Column(Integer, nullable=True) + max_inspections_month = Column(Integer, nullable=True) + max_issues_month = Column(Integer, nullable=True) + + # Feature gates + allow_custom_domain = Column(Boolean, nullable=False, default=False) + allow_mobile_api = Column(Boolean, nullable=False, default=False) + allow_scheduled_reports = Column(Boolean, nullable=False, default=False) + allow_branding = Column(Boolean, nullable=False, default=False) + + # Billing (future — MT-8) + price_cents = Column(Integer, nullable=True) + billing_period = Column(String(16), nullable=True) + + created_at = Column(DateTime, nullable=False, default=now_eastern) + + features = relationship('PlanFeature', back_populates='plan', + cascade='all, delete-orphan') + tenants = relationship('Tenant', back_populates='plan') + + def __repr__(self): + return f'' + + +class PlanFeature(ControlBase): + """EAV escape hatch for boolean feature flags added after launch.""" + __tablename__ = 'plan_features' + + id = Column(Integer, primary_key=True) + plan_id = Column(Integer, ForeignKey('plans.id', ondelete='CASCADE'), + nullable=False, index=True) + feature_key = Column(String(64), nullable=False) + enabled = Column(Boolean, nullable=False, default=False) + + __table_args__ = ( + UniqueConstraint('plan_id', 'feature_key', name='uq_plan_feature'), + ) + + plan = relationship('Plan', back_populates='features') + + def __repr__(self): + return f'' + + +class Tenant(ControlBase): + """A customer business. Owns its own MySQL database (db-per-tenant).""" + __tablename__ = 'tenants' + + id = Column(Integer, primary_key=True) + slug = Column(String(63), unique=True, nullable=False, index=True) # DNS label + name = Column(String(150), nullable=False) + plan_id = Column(Integer, ForeignKey('plans.id'), nullable=False, index=True) + status = Column( + Enum('provisioning', 'active', 'suspended', 'deleted', name='tenant_status'), + nullable=False, default='provisioning', + ) + + # Per-tenant database connection (MySQL user + password per tenant) + db_host = Column(String(255), nullable=False) + db_port = Column(Integer, nullable=False, default=3306) + db_name = Column(String(64), unique=True, nullable=False) + db_user = Column(String(64), nullable=False) + db_password_enc = Column(Text, nullable=True) # Fernet token + alembic_head = Column(String(64), nullable=True) # last tenant-schema rev applied + + created_at = Column(DateTime, nullable=False, default=now_eastern) + suspended_at = Column(DateTime, nullable=True) + notes = Column(Text, nullable=True) + + plan = relationship('Plan', back_populates='tenants') + domains = relationship('TenantDomain', back_populates='tenant', + cascade='all, delete-orphan') + + # ── Encrypted credential handling ────────────────────────────────────── + def set_db_password(self, plaintext): + """Store an encrypted tenant DB password.""" + self.db_password_enc = crypto.encrypt(plaintext) + + @property + def db_password(self): + """Decrypted tenant DB password (None if unset).""" + return crypto.decrypt(self.db_password_enc) if self.db_password_enc else None + + @property + def db_uri(self): + """SQLAlchemy URI for this tenant's database (password URL-encoded).""" + pw = self.db_password or '' + return ( + f"mysql+pymysql://{self.db_user}:{quote_plus(pw)}" + f"@{self.db_host}:{self.db_port}/{self.db_name}" + ) + + def __repr__(self): + return f'' + + +class TenantDomain(ControlBase): + """A hostname mapped to a tenant — subdomain (*.jqc.app) or custom domain.""" + __tablename__ = 'tenant_domains' + + id = Column(Integer, primary_key=True) + tenant_id = Column(Integer, ForeignKey('tenants.id', ondelete='CASCADE'), + nullable=False, index=True) + domain = Column(String(255), unique=True, nullable=False, index=True) + kind = Column(Enum('subdomain', 'custom', name='domain_kind'), + nullable=False) + is_primary = Column(Boolean, nullable=False, default=False) + verified = Column(Boolean, nullable=False, default=False) + verification_token = Column(String(64), nullable=True) + tls_status = Column(Enum('pending', 'active', 'failed', name='tls_status'), + nullable=False, default='pending') + created_at = Column(DateTime, nullable=False, default=now_eastern) + + tenant = relationship('Tenant', back_populates='domains') + + def __repr__(self): + return f'' + + +class Superadmin(ControlBase): + """Cross-tenant operator account. Lives only in the control DB.""" + __tablename__ = 'superadmins' + + id = Column(Integer, primary_key=True) + username = Column(String(100), unique=True, nullable=False, index=True) + email = Column(String(255), unique=True, nullable=False, index=True) + password_hash = Column(String(255), nullable=False) + active = Column(Boolean, nullable=False, default=True) + created_at = Column(DateTime, nullable=False, default=now_eastern) + + def set_password(self, password): + self.password_hash = generate_password_hash(password) + + def check_password(self, password): + return check_password_hash(self.password_hash, password) + + def __repr__(self): + return f'' + + +class ProvisioningJob(ControlBase): + """Record of a provisioning action (create_db / migrate / seed / ...).""" + __tablename__ = 'provisioning_jobs' + + id = Column(Integer, primary_key=True) + tenant_id = Column(Integer, ForeignKey('tenants.id', ondelete='SET NULL'), + nullable=True, index=True) + action = Column( + Enum('create_db', 'migrate', 'seed', 'suspend', 'resume', 'delete', + name='provisioning_action'), + nullable=False, + ) + status = Column( + Enum('queued', 'running', 'ok', 'failed', name='provisioning_status'), + nullable=False, default='queued', + ) + log = Column(Text, nullable=True) + created_at = Column(DateTime, nullable=False, default=now_eastern) + finished_at = Column(DateTime, nullable=True) + + def __repr__(self): + return f'' + + +class TenantAudit(ControlBase): + """Immutable log of superadmin actions in the control panel (MT-4).""" + __tablename__ = 'tenant_audit' + + id = Column(Integer, primary_key=True) + superadmin_id = Column(Integer, ForeignKey('superadmins.id', ondelete='SET NULL'), + nullable=True, index=True) + action = Column(String(50), nullable=False) + tenant_id = Column(Integer, ForeignKey('tenants.id', ondelete='SET NULL'), + nullable=True, index=True) + details = Column(Text, nullable=True) + ip_address = Column(String(45), nullable=True) + created_at = Column(DateTime, nullable=False, default=now_eastern, index=True) + + def __repr__(self): + return f'' diff --git a/control/seed.py b/control/seed.py new file mode 100644 index 0000000..87f1e8e --- /dev/null +++ b/control/seed.py @@ -0,0 +1,58 @@ +""" +control/seed.py +--------------- +Idempotent seeding of the baseline plans (upsert by `code`). Mirrors the +plan-tier matrix in MULTI_TENANT_PLAN.md §6. Safe to re-run — re-running +updates existing plan rows in place rather than creating duplicates. +""" + +from control.base import control_session +from control.models import Plan + +PLAN_DEFS = [ + dict(code='free', name='Free', + max_users=3, max_facilities=2, + max_inspections_month=50, max_issues_month=50, + allow_custom_domain=False, allow_mobile_api=False, + allow_scheduled_reports=False, allow_branding=False, + price_cents=0, billing_period='month'), + dict(code='starter', name='Starter', + max_users=15, max_facilities=10, + max_inspections_month=500, max_issues_month=500, + allow_custom_domain=False, allow_mobile_api=True, + allow_scheduled_reports=False, allow_branding=False, + price_cents=None, billing_period='month'), + dict(code='pro', name='Pro', + max_users=50, max_facilities=50, + max_inspections_month=5000, max_issues_month=5000, + allow_custom_domain=True, allow_mobile_api=True, + allow_scheduled_reports=True, allow_branding=True, + price_cents=None, billing_period='month'), + dict(code='enterprise', name='Enterprise', + max_users=None, max_facilities=None, + max_inspections_month=None, max_issues_month=None, + allow_custom_domain=True, allow_mobile_api=True, + allow_scheduled_reports=True, allow_branding=True, + price_cents=None, billing_period='month'), +] + + +def seed_plans(): + """Upsert baseline plans. Returns (created_count, updated_count).""" + created, updated = 0, 0 + with control_session() as s: + for d in PLAN_DEFS: + plan = s.query(Plan).filter_by(code=d['code']).first() + if plan is None: + s.add(Plan(**d)) + created += 1 + else: + for k, v in d.items(): + setattr(plan, k, v) + updated += 1 + return created, updated + + +if __name__ == '__main__': + c, u = seed_plans() + print(f'Plans seeded — created={c} updated={u}') diff --git a/control/time_utils.py b/control/time_utils.py new file mode 100644 index 0000000..ee29fc6 --- /dev/null +++ b/control/time_utils.py @@ -0,0 +1,17 @@ +""" +control/time_utils.py +--------------------- +Mirror of `app/utils/time_utils.now_eastern()` so the control plane keeps the +same naive-US/Eastern timestamp convention (CLAUDE.md Rule 2) WITHOUT importing +the data-plane app package. +""" + +from datetime import datetime +import pytz + +EASTERN = pytz.timezone('America/New_York') + + +def now_eastern() -> datetime: + """Current wall-clock time in US/Eastern as a naive datetime.""" + return datetime.now(tz=EASTERN).replace(tzinfo=None)