Files
JQC_multi_tenant/control/provision.py
T
2026-06-26 17:31:45 -04:00

446 lines
18 KiB
Python

"""
control/provision.py
--------------------
Tenant provisioning service (MT-3).
Two entry points:
create_tenant(...) — provision a brand-new tenant:
1. CREATE the per-tenant MySQL database + least-privilege user + grant
(using a dedicated admin account from PROVISION_DB_URL)
2. insert the control Tenant row (Fernet-encrypted credentials)
3. bootstrap the schema (baseline + stamp head) [control.tenant_migrate]
4. seed the first tenant admin (set-password token + link)
5. register the subdomain (+ optional custom domain)
On failure after the database is created, best-effort rollback drops the
database/user and removes the tenant row so a retry starts clean.
register_tenant_zero(...) — adopt an EXISTING database (e.g. LT) in place:
no DB/user creation, no bootstrap, no admin seeding — just register the
tenant row pointing at the live DB, read its current alembic head, and map
its domains. Zero data movement, zero disruption.
Environment:
PROVISION_DB_URL admin connection that can CREATE DATABASE / CREATE USER /
GRANT, e.g. mysql+pymysql://jqc_provisioner:pw@127.0.0.1/
TENANT_BASE_DOMAIN subdomain suffix for tenants, e.g. jqc.app
CONTROL_DATABASE_URL, CONTROL_FERNET_KEY (as for the rest of the control plane)
Required grants for the provisioner account (least privilege):
GRANT CREATE, CREATE USER, GRANT OPTION, ... ON *.* (or scoped jqc_% dbs)
"""
import argparse
import os
import re
import secrets
import sys
from sqlalchemy import create_engine, text
from sqlalchemy.engine import make_url
from werkzeug.security import generate_password_hash
from control.base import control_session
from control.models import Plan, Tenant, TenantDomain, ProvisioningJob
from control.time_utils import now_eastern
from control.tenant_migrate import bootstrap_tenant, current_revision
SET_PASSWORD_PATH = '/customers/set-password' # role-agnostic; sets password_set=True
_SLUG_RE = re.compile(r'^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$') # DNS label
# ── helpers ─────────────────────────────────────────────────────────────────
def _require(name):
v = os.environ.get(name)
if not v:
raise RuntimeError(f"{name} is not set.")
return v
def _sanitize_ident(slug):
"""MySQL-safe identifier fragment from a slug (alnum + underscore)."""
return re.sub(r'[^a-z0-9]', '_', slug.lower())
def db_name_for(slug):
return f'jqc_{_sanitize_ident(slug)}'[:64]
def db_user_for(slug):
return f'jqc_{_sanitize_ident(slug)}_u'[:32]
def _provision_engine():
return create_engine(_require('PROVISION_DB_URL'), future=True)
def _default_db_host():
try:
return make_url(_require('PROVISION_DB_URL')).host or '127.0.0.1'
except Exception:
return '127.0.0.1'
def _base_domain(override=None):
return override or _require('TENANT_BASE_DOMAIN')
def setup_link(primary_domain, token, scheme='https'):
return f'{scheme}://{primary_domain}{SET_PASSWORD_PATH}/{token}'
# ── MySQL admin operations ───────────────────────────────────────────────────
def create_mysql_db_and_user(db_name, db_user, password, user_host='%'):
"""Idempotently create the tenant database, user, and scoped grant."""
eng = _provision_engine()
try:
with eng.connect() as c:
c.execution_options(isolation_level='AUTOCOMMIT')
c.execute(text(
f"CREATE DATABASE IF NOT EXISTS `{db_name}` "
f"CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"))
c.execute(text(
f"CREATE USER IF NOT EXISTS '{db_user}'@'{user_host}' "
f"IDENTIFIED BY :pw"), {'pw': password})
# Ensure the stored password matches (in case the user pre-existed).
c.execute(text(
f"ALTER USER '{db_user}'@'{user_host}' IDENTIFIED BY :pw"),
{'pw': password})
c.execute(text(
f"GRANT ALL PRIVILEGES ON `{db_name}`.* "
f"TO '{db_user}'@'{user_host}'"))
c.execute(text("FLUSH PRIVILEGES"))
finally:
eng.dispose()
def drop_mysql_db_and_user(db_name, db_user, user_host='%'):
"""Best-effort teardown (used for rollback on failed provisioning)."""
eng = _provision_engine()
try:
with eng.connect() as c:
c.execution_options(isolation_level='AUTOCOMMIT')
c.execute(text(f"DROP DATABASE IF EXISTS `{db_name}`"))
c.execute(text(f"DROP USER IF EXISTS '{db_user}'@'{user_host}'"))
c.execute(text("FLUSH PRIVILEGES"))
finally:
eng.dispose()
# ── admin seeding (writes into the tenant DB) ────────────────────────────────
def seed_admin(db_uri, email, username=None, full_name=None, expires_hours=72):
"""Insert the first admin into a tenant DB with a set-password token.
Returns (username, token). The account is created with password_set=0 and an
unusable placeholder hash; the admin completes setup via the returned link.
"""
username = username or re.sub(r'[^a-zA-Z0-9_.-]', '', email.split('@')[0]) or 'admin'
full_name = full_name or username
token = secrets.token_hex(32)
placeholder = generate_password_hash(secrets.token_urlsafe(32))
now = now_eastern()
from datetime import timedelta
expires = now + timedelta(hours=expires_hours)
eng = create_engine(db_uri, future=True)
try:
with eng.begin() as c:
c.execute(text("""
INSERT INTO users
(username, full_name, email, password_hash, role,
created_at, active, password_set,
set_password_token, set_password_token_expires)
VALUES
(:u, :fn, :em, :ph, 'admin',
:ca, 1, 0, :tok, :exp)
"""), {'u': username, 'fn': full_name, 'em': email, 'ph': placeholder,
'ca': now, 'tok': token, 'exp': expires})
finally:
eng.dispose()
return username, token
# ── domain registration ──────────────────────────────────────────────────────
def _add_domains(session, tenant_id, slug, base_domain, custom_domain=None):
primary = f'{slug}.{base_domain}'.lower()
session.add(TenantDomain(
tenant_id=tenant_id, domain=primary, kind='subdomain',
is_primary=True, verified=True, tls_status='pending',
created_at=now_eastern()))
if custom_domain:
session.add(TenantDomain(
tenant_id=tenant_id, domain=custom_domain.lower(), kind='custom',
is_primary=False, verified=False,
verification_token=secrets.token_hex(16),
tls_status='pending', created_at=now_eastern()))
return primary
# ── public: create a brand-new tenant ────────────────────────────────────────
def create_tenant(slug, name, plan_code, admin_email, admin_username=None,
admin_full_name=None, custom_domain=None, base_domain=None,
db_host=None, user_host='%'):
if not _SLUG_RE.match(slug or ''):
raise ValueError(f"Invalid slug '{slug}' (must be a DNS label).")
base = _base_domain(base_domain)
host = db_host or _default_db_host()
dbname = db_name_for(slug)
dbuser = db_user_for(slug)
password = secrets.token_urlsafe(24)
with control_session() as s:
if s.query(Tenant).filter_by(slug=slug).first():
raise ValueError(f"Tenant slug '{slug}' already exists.")
plan = s.query(Plan).filter_by(code=plan_code).first()
if plan is None:
raise ValueError(f"Unknown plan '{plan_code}'.")
plan_id = plan.id
job_id = None
tenant_id = None
db_created = False
try:
with control_session() as s:
job = ProvisioningJob(action='create_db', status='running',
created_at=now_eastern())
s.add(job); s.flush(); job_id = job.id
create_mysql_db_and_user(dbname, dbuser, password, user_host)
db_created = True
with control_session() as s:
t = Tenant(slug=slug, name=name, plan_id=plan_id, status='provisioning',
db_host=host, db_port=3306, db_name=dbname, db_user=dbuser,
created_at=now_eastern())
t.set_db_password(password)
s.add(t); s.flush()
tenant_id = t.id
db_uri = t.db_uri
j = s.get(ProvisioningJob, job_id)
if j:
j.tenant_id = tenant_id
# schema: baseline + stamp head (records its own migrate job + alembic_head)
class _Ref:
pass
ref = _Ref(); ref.id = tenant_id; ref.db_uri = db_uri
bootstrap_tenant(ref)
# first admin + setup link
username, token = seed_admin(db_uri, admin_email, admin_username, admin_full_name)
with control_session() as s:
primary = _add_domains(s, tenant_id, slug, base, custom_domain)
t = s.get(Tenant, tenant_id)
t.status = 'active'
j = s.get(ProvisioningJob, job_id)
if j:
j.status = 'ok'; j.finished_at = now_eastern()
j.log = f'provisioned tenant {slug} (db={dbname})'
return {
'tenant_id': tenant_id, 'slug': slug, 'db_name': dbname,
'db_user': dbuser, 'primary_domain': primary,
'custom_domain': custom_domain, 'admin_username': username,
'setup_link': setup_link(primary, token),
}
except Exception as e:
# best-effort rollback so a retry starts clean
if job_id is not None:
with control_session() as s:
j = s.get(ProvisioningJob, job_id)
if j:
j.status = 'failed'; j.finished_at = now_eastern()
j.log = f'{type(e).__name__}: {e}'
if tenant_id is not None:
with control_session() as s:
s.query(TenantDomain).filter_by(tenant_id=tenant_id).delete()
t = s.get(Tenant, tenant_id)
if t:
s.delete(t)
if db_created:
try:
drop_mysql_db_and_user(dbname, dbuser, user_host)
except Exception:
pass
raise
# ── public: adopt an existing DB as tenant-zero ──────────────────────────────
def register_tenant_zero(slug, name, plan_code, db_host, db_name, db_user,
db_password, custom_domain=None, base_domain=None,
db_port=3306):
if not _SLUG_RE.match(slug or ''):
raise ValueError(f"Invalid slug '{slug}' (must be a DNS label).")
base = _base_domain(base_domain)
with control_session() as s:
if s.query(Tenant).filter_by(slug=slug).first():
raise ValueError(f"Tenant slug '{slug}' already exists.")
plan = s.query(Plan).filter_by(code=plan_code).first()
if plan is None:
raise ValueError(f"Unknown plan '{plan_code}'.")
plan_id = plan.id
# build a temporary tenant to read the existing DB's current head
with control_session() as s:
t = Tenant(slug=slug, name=name, plan_id=plan_id, status='active',
db_host=db_host, db_port=db_port, db_name=db_name,
db_user=db_user, created_at=now_eastern())
t.set_db_password(db_password)
s.add(t); s.flush()
tenant_id = t.id
db_uri = t.db_uri
head = current_revision(db_uri) # read existing alembic_version (no changes)
with control_session() as s:
t = s.get(Tenant, tenant_id)
t.alembic_head = head
primary = _add_domains(s, tenant_id, slug, base, None)
if custom_domain:
# tenant-zero's existing domain is trusted/verified (already live)
s.add(TenantDomain(
tenant_id=tenant_id, domain=custom_domain.lower(), kind='custom',
is_primary=False, verified=True, tls_status='active',
created_at=now_eastern()))
s.add(ProvisioningJob(tenant_id=tenant_id, action='create_db', status='ok',
created_at=now_eastern(), finished_at=now_eastern(),
log=f'registered tenant-zero {slug} in place (head={head})'))
return {'tenant_id': tenant_id, 'slug': slug, 'db_name': db_name,
'primary_domain': primary, 'custom_domain': custom_domain,
'alembic_head': head}
# ── public: delete / deregister a tenant ─────────────────────────────────────
def delete_tenant(slug, drop_db=False, user_host='%'):
"""Remove a tenant's control records. Optionally drop its MySQL database.
Safety: the MySQL drop only proceeds when the stored db_name matches the
provisioner's naming convention (db_name_for(slug)). This refuses to drop an
externally-named database such as tenant-zero's live LT database.
Returns a summary dict.
"""
with control_session() as s:
t = s.query(Tenant).filter_by(slug=slug).first()
if t is None:
raise ValueError(f"No tenant with slug '{slug}'.")
tenant_id = t.id
db_name = t.db_name
db_user = t.db_user
dropped = False
if drop_db:
if db_name != db_name_for(slug):
raise ValueError(
f"Refusing to drop database '{db_name}': it does not match the "
f"provisioner naming ('{db_name_for(slug)}'). Drop it manually if "
f"you are certain (this guard protects adopted DBs like tenant-zero).")
drop_mysql_db_and_user(db_name, db_user, user_host)
dropped = True
with control_session() as s:
s.query(TenantDomain).filter_by(tenant_id=tenant_id).delete()
s.query(ProvisioningJob).filter_by(tenant_id=tenant_id).delete()
t = s.get(Tenant, tenant_id)
if t:
s.delete(t)
return {'slug': slug, 'tenant_id': tenant_id, 'db_dropped': dropped,
'db_name': db_name}
# ── CLI ──────────────────────────────────────────────────────────────────────
def _cmd_create(args):
info = create_tenant(
slug=args.slug, name=args.name, plan_code=args.plan,
admin_email=args.admin_email, admin_username=args.admin_username,
admin_full_name=args.admin_name, custom_domain=args.custom_domain,
base_domain=args.base_domain, db_host=args.db_host, user_host=args.user_host)
print('Tenant provisioned:')
for k in ('tenant_id', 'slug', 'db_name', 'db_user', 'primary_domain',
'custom_domain', 'admin_username'):
print(f' {k}: {info[k]}')
print(f'\n Admin setup link (send to {args.admin_email}):\n {info["setup_link"]}')
return 0
def _cmd_register_zero(args):
info = register_tenant_zero(
slug=args.slug, name=args.name, plan_code=args.plan,
db_host=args.db_host, db_name=args.db_name, db_user=args.db_user,
db_password=args.db_password, custom_domain=args.custom_domain,
base_domain=args.base_domain, db_port=args.db_port)
print('Tenant-zero registered (no data moved):')
for k, v in info.items():
print(f' {k}: {v}')
return 0
def _cmd_delete(args):
if not args.yes:
print('Refusing to delete without --yes.')
return 1
info = delete_tenant(args.slug, drop_db=args.drop_db, user_host=args.user_host)
print('Tenant deleted:')
for k, v in info.items():
print(f' {k}: {v}')
return 0
def main(argv=None):
p = argparse.ArgumentParser(prog='control.provision',
description='Tenant provisioning')
sub = p.add_subparsers(dest='command', required=True)
c = sub.add_parser('create-tenant', help='Provision a brand-new tenant')
c.add_argument('--slug', required=True)
c.add_argument('--name', required=True)
c.add_argument('--plan', required=True, help='plan code, e.g. pro')
c.add_argument('--admin-email', required=True)
c.add_argument('--admin-username', default=None)
c.add_argument('--admin-name', default=None)
c.add_argument('--custom-domain', default=None)
c.add_argument('--base-domain', default=None, help='overrides TENANT_BASE_DOMAIN')
c.add_argument('--db-host', default=None, help='host the app uses to reach the tenant DB')
c.add_argument('--user-host', default='%')
c.set_defaults(func=_cmd_create)
z = sub.add_parser('register-tenant-zero', help='Adopt an existing DB in place')
z.add_argument('--slug', required=True)
z.add_argument('--name', required=True)
z.add_argument('--plan', required=True)
z.add_argument('--db-host', required=True)
z.add_argument('--db-name', required=True)
z.add_argument('--db-user', required=True)
z.add_argument('--db-password', required=True)
z.add_argument('--db-port', type=int, default=3306)
z.add_argument('--custom-domain', default=None, help="tenant-zero's existing live domain")
z.add_argument('--base-domain', default=None)
z.set_defaults(func=_cmd_register_zero)
d = sub.add_parser('delete-tenant', help='Remove a tenant (control records; optional DB drop)')
d.add_argument('--slug', required=True)
d.add_argument('--drop-db', action='store_true',
help='also DROP the MySQL database + user (guarded to provisioner-named DBs)')
d.add_argument('--user-host', default='%')
d.add_argument('--yes', action='store_true', help='confirm deletion')
d.set_defaults(func=_cmd_delete)
args = p.parse_args(argv)
sys.exit(args.func(args))
if __name__ == '__main__':
main()