""" control/panel/tenants.py ------------------------- Superadmin control panel — tenant management routes (MT-4). Routes ------ GET /tenants/ — list all tenants GET /tenants/ — tenant detail + migration status POST /tenants//plan — change plan POST /tenants//suspend — suspend POST /tenants//resume — resume GET /tenants//impersonate — generate token + redirect into tenant POST /tenants//domain/add — add custom domain POST /tenants//domain//verify — mark domain verified POST /tenants//domain//delete — remove domain POST /tenants/provision — create new tenant (calls provision.create_tenant) POST /tenants//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('/') @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'' 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('//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('//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('//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('//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('//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('//domain//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('//domain//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('//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))