258 lines
11 KiB
Python
258 lines
11 KiB
Python
"""
|
|
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, JSON,
|
|
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 (MT-8)
|
|
price_cents = Column(Integer, nullable=True)
|
|
billing_period = Column(String(16), nullable=True)
|
|
stripe_price_id = Column(String(100), 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
|
|
# MT-23: which Fernet key version db_password_enc was written under, so
|
|
# rotation progress is queryable rather than assumed:
|
|
# SELECT COUNT(*) FROM tenants WHERE key_version < <current>;
|
|
key_version = Column(Integer, nullable=False, default=1, server_default='1')
|
|
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)
|
|
|
|
# ── Billing (MT-8) ─────────────────────────────────────────────────────
|
|
stripe_customer_id = Column(String(64), nullable=True, index=True)
|
|
stripe_subscription_id = Column(String(64), nullable=True, index=True)
|
|
subscription_status = Column(
|
|
Enum('trial', 'active', 'past_due', 'cancelled', name='subscription_status'),
|
|
nullable=True,
|
|
)
|
|
trial_ends_at = Column(DateTime, nullable=True)
|
|
trial_reminder_sent_at = Column(DateTime, nullable=True) # set after trial warning email
|
|
current_period_end = Column(DateTime, nullable=True)
|
|
billing_email = Column(String(255), nullable=True)
|
|
|
|
# ── Dunning (payment failure reminders) ────────────────────────────────
|
|
past_due_since = Column(DateTime, nullable=True) # set when payment first fails
|
|
dunning_stage = Column(Integer, nullable=False, default=0) # 0=none 1=day3 2=day7 3=day14
|
|
dunning_sent_at = Column(DateTime, nullable=True) # last dunning email timestamp
|
|
|
|
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, stamped with the key version."""
|
|
self.db_password_enc = crypto.encrypt(plaintext)
|
|
self.key_version = crypto.current_key_version()
|
|
|
|
def rotate_db_password_key(self):
|
|
"""MT-23: re-encrypt the stored credential under the primary key.
|
|
|
|
Returns True when the row was rewritten, False when there was nothing
|
|
stored. The plaintext is never materialized here — MultiFernet.rotate
|
|
decrypts and re-encrypts internally.
|
|
"""
|
|
if not self.db_password_enc:
|
|
# Nothing to re-encrypt, but stamp the version so the row stops
|
|
# showing up as outstanding in rotation progress queries.
|
|
self.key_version = crypto.current_key_version()
|
|
return False
|
|
self.db_password_enc = crypto.rotate(self.db_password_enc)
|
|
self.key_version = crypto.current_key_version()
|
|
return True
|
|
|
|
@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)
|
|
|
|
# ── Two-factor (control0005) — opt-in TOTP, mirrors the data-plane User ──
|
|
mfa_enabled = Column(Boolean, nullable=False, default=False)
|
|
mfa_secret = Column(String(64), nullable=True)
|
|
mfa_recovery_codes = Column(JSON, nullable=True)
|
|
|
|
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}>'
|