393 lines
17 KiB
Python
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))
|