Files
JQC_multi_tenant/control/panel/tenants.py
T
2026-06-27 11:11:00 -04:00

393 lines
17 KiB
Python

"""
control/panel/tenants.py
-------------------------
Superadmin control panel — tenant management routes (MT-4).
Routes
------
GET /tenants/ — list all tenants
GET /tenants/<id> — tenant detail + migration status
POST /tenants/<id>/plan — change plan
POST /tenants/<id>/suspend — suspend
POST /tenants/<id>/resume — resume
GET /tenants/<id>/impersonate — generate token + redirect into tenant
POST /tenants/<id>/domain/add — add custom domain
POST /tenants/<id>/domain/<d_id>/verify — mark domain verified
POST /tenants/<id>/domain/<d_id>/delete — remove domain
POST /tenants/provision — create new tenant (calls provision.create_tenant)
POST /tenants/<id>/migrate — run upgrade_tenant for this tenant
"""
import logging
import os
from flask import (
Blueprint, render_template, redirect, url_for,
request, session, flash,
)
from control.base import control_session
from control.models import Plan, Tenant, TenantDomain, TenantAudit, ProvisioningJob
from control.time_utils import now_eastern
from control.tenant_migrate import upgrade_tenant, current_revision, chain_head, TenantRef
from .decorators import superadmin_required
from .impersonate import generate_token
logger = logging.getLogger(__name__)
bp = Blueprint('tenants', __name__, url_prefix='/tenants')
_BASE_DOMAIN = lambda: os.environ.get('TENANT_BASE_DOMAIN', 'jqc.app')
# ── audit helper ──────────────────────────────────────────────────────────────
def _audit(action, tenant_id=None, details=None):
sa_id = session.get('sa_id')
try:
ip = (request.headers.get('X-Forwarded-For', '').split(',')[0].strip()
or request.remote_addr)
with control_session() as s:
s.add(TenantAudit(
superadmin_id=sa_id,
action=action[:50],
tenant_id=tenant_id,
details=details,
ip_address=(ip or '')[:45],
created_at=now_eastern(),
))
except Exception as exc:
logger.error('TenantAudit write failed: %s', exc)
# ── list ──────────────────────────────────────────────────────────────────────
@bp.route('/')
@superadmin_required
def list_tenants():
with control_session() as s:
tenants = (s.query(Tenant)
.order_by(Tenant.id)
.all())
plans = {p.id: p for p in s.query(Plan).all()}
# Detach: collect plain dicts so session can close safely.
rows = []
for t in tenants:
rows.append({
'id': t.id, 'slug': t.slug, 'name': t.name,
'status': t.status,
'plan_name': plans[t.plan_id].name if t.plan_id in plans else '?',
'alembic_head': t.alembic_head,
'created_at': t.created_at,
})
head = chain_head()
return render_template('panel/tenants_list.html',
tenants=rows, chain_head=head,
sa_username=session.get('sa_username'))
# ── detail ────────────────────────────────────────────────────────────────────
@bp.route('/<int:tenant_id>')
@superadmin_required
def tenant_detail(tenant_id):
with control_session() as s:
t = s.get(Tenant, tenant_id)
if t is None:
flash('Tenant not found.', 'danger')
return redirect(url_for('tenants.list_tenants'))
plans = s.query(Plan).order_by(Plan.id).all()
domains = list(t.domains) # load while session open
jobs = (s.query(ProvisioningJob)
.filter_by(tenant_id=tenant_id)
.order_by(ProvisioningJob.id.desc())
.limit(20)
.all())
# Live revision from DB (may differ from cached tenants.alembic_head)
try:
live_rev = current_revision(t.db_uri)
except Exception as e:
live_rev = f'<error: {e}>'
head = chain_head()
info = {
'id': t.id, 'slug': t.slug, 'name': t.name, 'status': t.status,
'plan_id': t.plan_id, 'db_name': t.db_name, 'db_user': t.db_user,
'db_host': t.db_host, 'db_port': t.db_port,
'alembic_head': t.alembic_head, 'created_at': t.created_at,
'suspended_at': t.suspended_at, 'notes': t.notes,
}
plan_rows = [{'id': p.id, 'code': p.code, 'name': p.name} for p in plans]
domain_rows = [
{'id': d.id, 'domain': d.domain, 'kind': d.kind,
'is_primary': d.is_primary, 'verified': d.verified,
'tls_status': d.tls_status, 'verification_token': d.verification_token}
for d in domains
]
job_rows = [
{'id': j.id, 'action': j.action, 'status': j.status,
'created_at': j.created_at, 'finished_at': j.finished_at,
'log': j.log}
for j in jobs
]
return render_template(
'panel/tenant_detail.html',
tenant=info, plans=plan_rows, domains=domain_rows,
jobs=job_rows, live_rev=live_rev, chain_head=head,
sa_username=session.get('sa_username'),
base_domain=_BASE_DOMAIN(),
)
# ── plan change ───────────────────────────────────────────────────────────────
@bp.route('/<int:tenant_id>/plan', methods=['POST'])
@superadmin_required
def change_plan(tenant_id):
plan_id = request.form.get('plan_id', type=int)
with control_session() as s:
t = s.get(Tenant, tenant_id)
if t is None:
flash('Tenant not found.', 'danger')
return redirect(url_for('tenants.list_tenants'))
plan = s.get(Plan, plan_id)
if plan is None:
flash('Invalid plan.', 'danger')
return redirect(url_for('tenants.tenant_detail', tenant_id=tenant_id))
old_plan_id = t.plan_id
t.plan_id = plan_id
_audit('PLAN_CHANGE', tenant_id=tenant_id,
details=f'plan_id {old_plan_id}{plan_id} ({plan.code})')
logger.info('PANEL | plan_change | tenant=%s old=%s new=%s(%s)',
tenant_id, old_plan_id, plan_id, plan.code)
flash(f'Plan updated to {plan.name}.', 'success')
return redirect(url_for('tenants.tenant_detail', tenant_id=tenant_id))
# ── suspend / resume ──────────────────────────────────────────────────────────
@bp.route('/<int:tenant_id>/suspend', methods=['POST'])
@superadmin_required
def suspend_tenant(tenant_id):
with control_session() as s:
t = s.get(Tenant, tenant_id)
if t is None:
flash('Tenant not found.', 'danger')
return redirect(url_for('tenants.list_tenants'))
if t.status == 'suspended':
flash('Already suspended.', 'info')
return redirect(url_for('tenants.tenant_detail', tenant_id=tenant_id))
reason = (request.form.get('reason') or '').strip()[:255]
t.status = 'suspended'
t.suspended_at = now_eastern()
s.add(ProvisioningJob(
tenant_id=tenant_id, action='suspend', status='ok',
created_at=now_eastern(), finished_at=now_eastern(),
log=f'suspended by superadmin (reason: {reason or "none"})'))
_audit('SUSPEND', tenant_id=tenant_id, details=f'reason={reason}')
logger.info('PANEL | suspend | tenant=%s reason=%s', tenant_id, reason)
flash('Tenant suspended.', 'warning')
return redirect(url_for('tenants.tenant_detail', tenant_id=tenant_id))
@bp.route('/<int:tenant_id>/resume', methods=['POST'])
@superadmin_required
def resume_tenant(tenant_id):
with control_session() as s:
t = s.get(Tenant, tenant_id)
if t is None:
flash('Tenant not found.', 'danger')
return redirect(url_for('tenants.list_tenants'))
if t.status != 'suspended':
flash('Tenant is not suspended.', 'info')
return redirect(url_for('tenants.tenant_detail', tenant_id=tenant_id))
t.status = 'active'
t.suspended_at = None
s.add(ProvisioningJob(
tenant_id=tenant_id, action='resume', status='ok',
created_at=now_eastern(), finished_at=now_eastern(),
log='resumed by superadmin'))
_audit('RESUME', tenant_id=tenant_id)
logger.info('PANEL | resume | tenant=%s', tenant_id)
flash('Tenant resumed.', 'success')
return redirect(url_for('tenants.tenant_detail', tenant_id=tenant_id))
# ── impersonation ─────────────────────────────────────────────────────────────
@bp.route('/<int:tenant_id>/impersonate')
@superadmin_required
def impersonate(tenant_id):
"""Generate a short-lived signed token and redirect into the tenant app."""
sa_id = session.get('sa_id')
with control_session() as s:
t = s.get(Tenant, tenant_id)
if t is None:
flash('Tenant not found.', 'danger')
return redirect(url_for('tenants.list_tenants'))
if t.status != 'active':
flash('Can only impersonate active tenants.', 'danger')
return redirect(url_for('tenants.tenant_detail', tenant_id=tenant_id))
# Find the primary domain.
primary = next(
(d.domain for d in t.domains if d.is_primary and d.verified),
None
)
if primary is None:
flash('Tenant has no verified primary domain.', 'danger')
return redirect(url_for('tenants.tenant_detail', tenant_id=tenant_id))
token = generate_token(tenant_id, sa_id)
_audit('IMPERSONATE', tenant_id=tenant_id,
details=f'superadmin_id={sa_id} target_domain={primary}')
logger.info('PANEL | impersonate | sa=%s tenant=%s domain=%s',
sa_id, tenant_id, primary)
# Redirect into the main app's impersonation endpoint.
target = f'https://{primary}/auth/impersonate?token={token}'
return redirect(target)
# ── domain management ─────────────────────────────────────────────────────────
@bp.route('/<int:tenant_id>/domain/add', methods=['POST'])
@superadmin_required
def add_domain(tenant_id):
import secrets as _secrets
domain = (request.form.get('domain') or '').strip().lower()
kind = request.form.get('kind', 'custom')
if not domain:
flash('Domain is required.', 'danger')
return redirect(url_for('tenants.tenant_detail', tenant_id=tenant_id))
with control_session() as s:
t = s.get(Tenant, tenant_id)
if t is None:
flash('Tenant not found.', 'danger')
return redirect(url_for('tenants.list_tenants'))
existing = s.query(TenantDomain).filter_by(domain=domain).first()
if existing:
flash(f'Domain {domain} already registered.', 'warning')
return redirect(url_for('tenants.tenant_detail', tenant_id=tenant_id))
s.add(TenantDomain(
tenant_id=tenant_id, domain=domain,
kind=kind, is_primary=False, verified=(kind == 'subdomain'),
verification_token=_secrets.token_hex(16),
tls_status='pending', created_at=now_eastern(),
))
_audit('DOMAIN_ADD', tenant_id=tenant_id, details=f'domain={domain} kind={kind}')
flash(f'Domain {domain} added.', 'success')
return redirect(url_for('tenants.tenant_detail', tenant_id=tenant_id))
@bp.route('/<int:tenant_id>/domain/<int:domain_id>/verify', methods=['POST'])
@superadmin_required
def verify_domain(tenant_id, domain_id):
with control_session() as s:
d = s.get(TenantDomain, domain_id)
if d is None or d.tenant_id != tenant_id:
flash('Domain not found.', 'danger')
return redirect(url_for('tenants.tenant_detail', tenant_id=tenant_id))
d.verified = True
d.tls_status = 'active'
_audit('DOMAIN_VERIFY', tenant_id=tenant_id,
details=f'domain_id={domain_id} domain={d.domain}')
flash(f'Domain {d.domain} marked verified.', 'success')
return redirect(url_for('tenants.tenant_detail', tenant_id=tenant_id))
@bp.route('/<int:tenant_id>/domain/<int:domain_id>/delete', methods=['POST'])
@superadmin_required
def delete_domain(tenant_id, domain_id):
with control_session() as s:
d = s.get(TenantDomain, domain_id)
if d is None or d.tenant_id != tenant_id:
flash('Domain not found.', 'danger')
return redirect(url_for('tenants.tenant_detail', tenant_id=tenant_id))
if d.is_primary:
flash('Cannot delete the primary domain.', 'danger')
return redirect(url_for('tenants.tenant_detail', tenant_id=tenant_id))
domain_str = d.domain
s.delete(d)
_audit('DOMAIN_DELETE', tenant_id=tenant_id, details=f'domain={domain_str}')
flash(f'Domain {domain_str} removed.', 'success')
return redirect(url_for('tenants.tenant_detail', tenant_id=tenant_id))
# ── provision new tenant ──────────────────────────────────────────────────────
@bp.route('/provision', methods=['GET', 'POST'])
@superadmin_required
def provision_tenant():
if request.method == 'GET':
with control_session() as s:
plans = [{'id': p.id, 'code': p.code, 'name': p.name}
for p in s.query(Plan).filter_by(active=True).order_by(Plan.id).all()]
return render_template('panel/provision.html',
plans=plans,
sa_username=session.get('sa_username'))
# POST — provision
slug = (request.form.get('slug') or '').strip().lower()
name = (request.form.get('name') or '').strip()
plan_code = (request.form.get('plan_code') or '').strip()
admin_email = (request.form.get('admin_email') or '').strip()
admin_username = (request.form.get('admin_username') or '').strip() or None
custom_domain = (request.form.get('custom_domain') or '').strip() or None
if not (slug and name and plan_code and admin_email):
flash('Slug, name, plan, and admin email are required.', 'danger')
return redirect(url_for('tenants.provision_tenant'))
try:
from control.provision import create_tenant
info = create_tenant(
slug=slug, name=name, plan_code=plan_code,
admin_email=admin_email, admin_username=admin_username,
custom_domain=custom_domain,
)
except ValueError as e:
flash(str(e), 'danger')
return redirect(url_for('tenants.provision_tenant'))
except Exception as e:
logger.error('PANEL | provision_fail | slug=%s error=%s', slug, e, exc_info=True)
flash(f'Provisioning failed: {e}', 'danger')
return redirect(url_for('tenants.provision_tenant'))
_audit('PROVISION', tenant_id=info['tenant_id'],
details=f'slug={slug} plan={plan_code} admin={admin_email}')
logger.info('PANEL | provisioned | tenant=%s id=%s', slug, info['tenant_id'])
flash(
f'Tenant "{name}" ({slug}) provisioned. '
f'Admin setup link: {info["setup_link"]}',
'success',
)
return redirect(url_for('tenants.tenant_detail', tenant_id=info['tenant_id']))
# ── run migration ─────────────────────────────────────────────────────────────
@bp.route('/<int:tenant_id>/migrate', methods=['POST'])
@superadmin_required
def migrate_tenant(tenant_id):
with control_session() as s:
t = s.get(Tenant, tenant_id)
if t is None:
flash('Tenant not found.', 'danger')
return redirect(url_for('tenants.list_tenants'))
ref = TenantRef(t.id, t.slug, t.db_uri)
try:
applied = upgrade_tenant(ref)
_audit('MIGRATE', tenant_id=tenant_id, details=f'head={applied}')
flash(f'Migration complete → {applied}', 'success')
except Exception as e:
logger.error('PANEL | migrate_fail | tenant=%s error=%s', tenant_id, e)
flash(f'Migration failed: {e}', 'danger')
return redirect(url_for('tenants.tenant_detail', tenant_id=tenant_id))