Jun 26 MT-0 phase

This commit is contained in:
2026-06-26 16:19:48 -04:00
parent 5d95cdbbfc
commit fc39491891
13 changed files with 1094 additions and 0 deletions
+84
View File
@@ -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.
+23
View File
@@ -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
"""
+75
View File
@@ -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()
+79
View File
@@ -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()
+41
View File
@@ -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()
+41
View File
@@ -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
+67
View File
@@ -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()
+22
View File
@@ -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}'))
+213
View File
@@ -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}>'
+58
View File
@@ -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}')
+17
View File
@@ -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)