Jun 26 MT-0 phase
This commit is contained in:
@@ -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