From 53d4cfc58dceeaa8d9ab9cd005551c82fdd8ae88 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Fri, 26 Jun 2026 17:31:45 -0400 Subject: [PATCH] Jun 26 MT-3 --- MULTI_TENANT_PLAN.md | 2 +- control/provision.py | 445 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 446 insertions(+), 1 deletion(-) create mode 100644 control/provision.py diff --git a/MULTI_TENANT_PLAN.md b/MULTI_TENANT_PLAN.md index 1dc6e41..4f8c9e6 100644 --- a/MULTI_TENANT_PLAN.md +++ b/MULTI_TENANT_PLAN.md @@ -164,7 +164,7 @@ Each phase additive; existing tenant-zero traffic keeps working throughout. > > **Operational note:** any *fresh* database (including a new dev DB) must use the bootstrap flow, not a naive `flask db upgrade` from empty, because the historical phase replay still hits the unguarded migrations. Cross-check the baseline against `mysqldump --no-data` of LT and test `bootstrap` on a scratch MySQL before going live. -**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-3 — Provisioning service. ✅ DONE.** `control/provision.py`: `create_tenant()` (admin account from `PROVISION_DB_URL` creates per-tenant DB + least-privilege user + grant → `bootstrap_tenant` builds schema + stamps head → `seed_admin` inserts the first admin with a set-password token → registers subdomain + optional custom domain → status active; best-effort rollback drops the DB/user + tenant row on failure). `register_tenant_zero()` adopts an existing DB in place (no DB/user creation, no bootstrap; reads its current head, maps `lts.` + LT's live domain as a verified custom domain — zero data movement). `delete_tenant()` removes control records, optional guarded DB drop (refuses non-provisioner-named DBs, protecting tenant-zero). CLI: `python -m control.provision create-tenant|register-tenant-zero|delete-tenant`. Admin setup link uses `/customers/set-password/` (the route that sets `password_set=True`). Creds Fernet-encrypted; `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. diff --git a/control/provision.py b/control/provision.py new file mode 100644 index 0000000..9339dbf --- /dev/null +++ b/control/provision.py @@ -0,0 +1,445 @@ +""" +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()