Jun 26 MT-3
This commit is contained in:
@@ -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.<base>` + 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/<token>` (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.
|
||||
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user