Jun 26 MT-0 phase
This commit is contained in:
@@ -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_<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/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 <id|all>` 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 <id|all>`; 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.
|
||||
@@ -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 '<pw>';"
|
||||
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:<pw>@localhost/jqc_control'
|
||||
export CONTROL_FERNET_KEY='<generated 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.
|
||||
@@ -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='<generated 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
|
||||
"""
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"}
|
||||
@@ -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}'))
|
||||
@@ -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'<Plan {self.code}>'
|
||||
|
||||
|
||||
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'<PlanFeature plan={self.plan_id} {self.feature_key}={self.enabled}>'
|
||||
|
||||
|
||||
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'<Tenant {self.slug}>'
|
||||
|
||||
|
||||
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'<TenantDomain {self.domain} ({self.kind})>'
|
||||
|
||||
|
||||
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'<Superadmin {self.username}>'
|
||||
|
||||
|
||||
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'<ProvisioningJob {self.action} tenant={self.tenant_id} {self.status}>'
|
||||
|
||||
|
||||
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'<TenantAudit {self.action} tenant={self.tenant_id}>'
|
||||
@@ -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}')
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user