diff --git a/control/panel/__init__.py b/control/panel/__init__.py new file mode 100644 index 0000000..6716a9b --- /dev/null +++ b/control/panel/__init__.py @@ -0,0 +1,93 @@ +""" +control/panel/__init__.py +-------------------------- +Superadmin control panel — MT-4. + +Serves as a STANDALONE Flask application at admin.jqc.app (separate WSGI +process). Shares only the `control` package (models, provision, tenant_migrate) +with the main app. Has NO import dependency on `app/`. + +Entry point: wsgi_panel.py + gunicorn wsgi_panel:panel_app + +Env vars required (same /etc/jqc/control.env sourced by the main app): + CONTROL_DATABASE_URL + CONTROL_FERNET_KEY + PANEL_SECRET_KEY — separate from the main app SECRET_KEY + PANEL_IMPERSONATE_KEY — HMAC key for impersonation tokens (32+ random bytes) + TENANT_BASE_DOMAIN — e.g. jqc.app +""" + +import os +import logging +from logging.handlers import RotatingFileHandler + +from flask import Flask + +from .auth import bp as auth_bp +from .tenants import bp as tenants_bp + + +def create_panel_app(): + app = Flask(__name__, template_folder='templates') + + secret = os.environ.get('PANEL_SECRET_KEY') + if not secret: + raise RuntimeError( + 'PANEL_SECRET_KEY is not set. ' + 'Generate one: python -c "import secrets; print(secrets.token_hex(32))"' + ) + app.secret_key = secret + + # ── Logging ────────────────────────────────────────────────────────── + log_level = logging.INFO + formatter = logging.Formatter( + '[%(asctime)s] %(levelname)s in %(module)s: %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + ) + sh = logging.StreamHandler() + sh.setLevel(log_level) + sh.setFormatter(formatter) + + log_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname( + os.path.abspath(__file__)))), 'logs') + os.makedirs(log_dir, exist_ok=True) + fh = RotatingFileHandler( + os.path.join(log_dir, 'jqc_panel.log'), + maxBytes=5 * 1024 * 1024, backupCount=5) + fh.setLevel(log_level) + fh.setFormatter(formatter) + + app.logger.setLevel(log_level) + for h in (sh, fh): + app.logger.addHandler(h) + + # ── Blueprints ──────────────────────────────────────────────────────── + app.register_blueprint(auth_bp) # /login, /logout + app.register_blueprint(tenants_bp) # /tenants/… + + # ── Root redirect ───────────────────────────────────────────────────── + from flask import redirect, url_for + + @app.route('/') + def index(): + return redirect(url_for('tenants.list_tenants')) + + # ── Security headers ────────────────────────────────────────────────── + @app.after_request + def security_headers(response): + response.headers.setdefault('X-Content-Type-Options', 'nosniff') + response.headers.setdefault('X-Frame-Options', 'DENY') + response.headers.setdefault('Referrer-Policy', 'strict-origin-when-cross-origin') + response.headers.setdefault( + 'Content-Security-Policy', + "default-src 'self'; " + "script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " + "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " + "font-src 'self' https://cdn.jsdelivr.net; " + "img-src 'self' data:; " + "frame-ancestors 'none';" + ) + return response + + return app diff --git a/control/panel/auth.py b/control/panel/auth.py new file mode 100644 index 0000000..ff96b28 --- /dev/null +++ b/control/panel/auth.py @@ -0,0 +1,88 @@ +""" +control/panel/auth.py +--------------------- +Superadmin authentication for the control panel (MT-4). + +Uses Flask session directly (no Flask-Login) — the panel is a standalone +app with no dependency on the main app's login_manager. + +Session key: 'sa_id' (superadmin.id when logged in) +""" + +import logging + +from flask import ( + Blueprint, render_template, redirect, url_for, + request, session, flash, +) + +from control.base import control_session +from control.models import Superadmin, TenantAudit +from control.time_utils import now_eastern + +logger = logging.getLogger(__name__) + +bp = Blueprint('auth', __name__) + + +def _log_audit(action, superadmin_id=None, tenant_id=None, details=None): + """Write a TenantAudit row for superadmin actions.""" + 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=superadmin_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) + + +# ── Login ───────────────────────────────────────────────────────────────────── + +@bp.route('/login', methods=['GET', 'POST']) +def login(): + if session.get('sa_id'): + return redirect(url_for('tenants.list_tenants')) + + error = None + if request.method == 'POST': + username = (request.form.get('username') or '').strip() + password = request.form.get('password') or '' + + with control_session() as s: + sa = s.query(Superadmin).filter_by(username=username, active=True).first() + if sa and sa.check_password(password): + session.permanent = True + session['sa_id'] = sa.id + session['sa_username'] = sa.username + logger.info('PANEL | login | superadmin=%s', username) + _log_audit('LOGIN', superadmin_id=sa.id, + details=f'superadmin={username}') + flash(f'Welcome, {sa.username}!', 'success') + next_url = request.args.get('next') or url_for('tenants.list_tenants') + return redirect(next_url) + else: + logger.warning('PANEL | login_fail | username=%s', username) + error = 'Invalid credentials.' + + return render_template('panel/login.html', error=error) + + +# ── Logout ──────────────────────────────────────────────────────────────────── + +@bp.route('/logout') +def logout(): + sa_id = session.get('sa_id') + sa_name = session.get('sa_username', '?') + session.clear() + logger.info('PANEL | logout | superadmin=%s', sa_name) + if sa_id: + _log_audit('LOGOUT', superadmin_id=sa_id, details=f'superadmin={sa_name}') + flash('Logged out.', 'info') + return redirect(url_for('auth.login')) diff --git a/control/panel/decorators.py b/control/panel/decorators.py new file mode 100644 index 0000000..d2470eb --- /dev/null +++ b/control/panel/decorators.py @@ -0,0 +1,30 @@ +""" +control/panel/decorators.py +---------------------------- +Access-control decorator for the superadmin control panel (MT-4). +""" + +from functools import wraps +from flask import session, redirect, url_for, request, flash + +from control.base import control_session +from control.models import Superadmin + + +def superadmin_required(f): + """Redirect to /login if the session has no valid superadmin.""" + @wraps(f) + def decorated(*args, **kwargs): + sa_id = session.get('sa_id') + if not sa_id: + flash('Please log in.', 'warning') + return redirect(url_for('auth.login', next=request.path)) + # Re-validate: account might have been deactivated since login. + with control_session() as s: + sa = s.get(Superadmin, sa_id) + if sa is None or not sa.active: + session.clear() + flash('Your superadmin account is no longer active.', 'danger') + return redirect(url_for('auth.login')) + return f(*args, **kwargs) + return decorated diff --git a/control/panel/impersonate.py b/control/panel/impersonate.py new file mode 100644 index 0000000..afe11b3 --- /dev/null +++ b/control/panel/impersonate.py @@ -0,0 +1,85 @@ +""" +control/panel/impersonate.py +----------------------------- +Impersonation flow (MT-4). + +How it works +------------ +1. Superadmin clicks "Impersonate" on the panel → panel generates a short-lived + signed token containing {tenant_id, expires_at, issued_by}. +2. Panel redirects the superadmin's browser to + https:///auth/impersonate?token= + (the main JQC app, not the panel). +3. The main app's impersonation route (app/routes/auth.py) validates the token, + sets a session key 'impersonating_tenant_id', and shows a banner. + The tenancy middleware reads this key to bind the session to that tenant's DB + even when multi-tenancy is enabled and the Host would resolve differently. +4. "End impersonation" clears the key and redirects back to the panel. + +Token format: HMAC-SHA256 signed JSON, base64url-encoded. + +Environment +----------- +PANEL_IMPERSONATE_KEY — 32+ random hex bytes (separate from PANEL_SECRET_KEY). + Generate: python -c "import secrets; print(secrets.token_hex(32))" + Must be identical in /etc/jqc/control.env AND the main + app's environment so both sides can verify tokens. +""" + +import hashlib +import hmac +import json +import os +import time +from base64 import urlsafe_b64decode, urlsafe_b64encode + +_TTL_SECONDS = 60 # token is single-use; 60 s is more than enough for a redirect + + +def _key() -> bytes: + raw = os.environ.get('PANEL_IMPERSONATE_KEY', '') + if not raw: + raise RuntimeError( + 'PANEL_IMPERSONATE_KEY is not set. ' + 'Generate: python -c "import secrets; print(secrets.token_hex(32))"' + ) + return raw.encode() + + +def _b64(data: bytes) -> str: + return urlsafe_b64encode(data).rstrip(b'=').decode() + + +def _unb64(s: str) -> bytes: + pad = 4 - len(s) % 4 + return urlsafe_b64decode(s + '=' * (pad % 4)) + + +def generate_token(tenant_id: int, superadmin_id: int) -> str: + """Return a URL-safe signed token good for _TTL_SECONDS.""" + payload = json.dumps({ + 'tid': tenant_id, + 'said': superadmin_id, + 'exp': int(time.time()) + _TTL_SECONDS, + }, separators=(',', ':')).encode() + sig = hmac.new(_key(), payload, hashlib.sha256).digest() + return f'{_b64(payload)}.{_b64(sig)}' + + +def validate_token(token: str) -> dict: + """Validate and decode a token. Returns payload dict or raises ValueError.""" + try: + raw_payload, raw_sig = token.rsplit('.', 1) + except ValueError: + raise ValueError('Malformed token') + + payload_bytes = _unb64(raw_payload) + expected_sig = hmac.new(_key(), payload_bytes, hashlib.sha256).digest() + if not hmac.compare_digest(expected_sig, _unb64(raw_sig)): + raise ValueError('Invalid token signature') + + payload = json.loads(payload_bytes) + if time.time() > payload.get('exp', 0): + raise ValueError('Token expired') + + return payload diff --git a/control/panel/templates/panel/base.html b/control/panel/templates/panel/base.html new file mode 100644 index 0000000..3e85bd9 --- /dev/null +++ b/control/panel/templates/panel/base.html @@ -0,0 +1,114 @@ + + + + + + {% block title %}JQC Control Panel{% endblock %} + + + + {% block extra_css %}{% endblock %} + + + +
+
+ + JQC ControlSUPER +
+ + +
+ +
+ + {% block page_title %}Control Panel{% endblock %} + +
+ +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% for category, message in messages %} + + {% endfor %} + {% endwith %} + + {% block content %}{% endblock %} +
+ + +{% block extra_js %}{% endblock %} + + diff --git a/control/panel/templates/panel/login.html b/control/panel/templates/panel/login.html new file mode 100644 index 0000000..9417e25 --- /dev/null +++ b/control/panel/templates/panel/login.html @@ -0,0 +1,61 @@ + + + + + + Control Panel Login — JQC + + + + + + + + diff --git a/control/panel/templates/panel/provision.html b/control/panel/templates/panel/provision.html new file mode 100644 index 0000000..c9d87c4 --- /dev/null +++ b/control/panel/templates/panel/provision.html @@ -0,0 +1,77 @@ +{% extends "panel/base.html" %} +{% block title %}New Tenant — JQC Control{% endblock %} +{% block page_title %}Provision New Tenant{% endblock %} + +{% block content %} +
+
+
+
+ New Tenant +
+
+
+ +
+ + +
DNS label — lowercase letters, digits, hyphens only.
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
Setup link will be sent to this address.
+
+ +
+ + +
+ +
+ + +
Leave blank to use subdomain only.
+
+ +
+ + Cancel +
+
+
+
+ +
+
+ + Provisioning will: create MySQL DB + user, build schema, seed admin account, + register subdomain. The setup link in the success message must be sent to the admin manually. +
+
+
+
+{% endblock %} diff --git a/control/panel/templates/panel/tenant_detail.html b/control/panel/templates/panel/tenant_detail.html new file mode 100644 index 0000000..6865efe --- /dev/null +++ b/control/panel/templates/panel/tenant_detail.html @@ -0,0 +1,309 @@ +{% extends "panel/base.html" %} +{% block title %}{{ tenant.slug }} — JQC Control{% endblock %} +{% block page_title %}Tenant: {{ tenant.name }} ({{ tenant.slug }}){% endblock %} + +{% block content %} + +{# ── breadcrumb ── #} + + +
+ + {# ═══ LEFT COLUMN ═══ #} +
+ + {# ── Info card ── #} +
+
+ Tenant Info + {{ tenant.status }} +
+
+
+
ID
{{ tenant.id }}
+
Slug
{{ tenant.slug }}
+
Name
{{ tenant.name }}
+
Created +
{{ tenant.created_at.strftime('%Y-%m-%d %H:%M') if tenant.created_at else '—' }}
+ {% if tenant.suspended_at %} +
Suspended +
{{ tenant.suspended_at.strftime('%Y-%m-%d %H:%M') }}
+ {% endif %} +
DB +
+ {{ tenant.db_user }}@{{ tenant.db_host }}:{{ tenant.db_port }}/{{ tenant.db_name }} +
+
+
+
+
+ + {# ── Migration card ── #} +
+
+ Schema Migration +
+ +
+
+
+
+
+ Chain head: + {{ chain_head }} +
+
+ Cached head: + {{ tenant.alembic_head or '—' }} +
+
+ Live DB rev: + {% if live_rev == chain_head %} + {{ live_rev }} + {% elif live_rev and not live_rev.startswith(' {{ live_rev }} (BEHIND) + {% else %} + {{ live_rev }} + {% endif %} +
+
+
+
+ + {# ── Domains card ── #} +
+
+ Domains +
+
+ + + + + + + + + + + + + + {% for d in domains %} + + + + + + + + + + {% else %} + + {% endfor %} + +
DomainKindPrimaryVerifiedTLSToken
{{ d.domain }}{{ d.kind }}{% if d.is_primary %}{% endif %} + {% if d.verified %} + + {% else %} +
+ +
+ {% endif %} +
{{ d.tls_status }}{{ d.verification_token or '—' }} + {% if not d.is_primary %} +
+ +
+ {% endif %} +
No domains.
+
+ +
+ + {# ── Provisioning jobs ── #} +
+
+ Recent Jobs +
+
+ + + + + + + + {% for j in jobs %} + + + + + + + + {% else %} + + {% endfor %} + +
#ActionStatusCreatedLog
{{ j.id }}{{ j.action }} + + {{ j.status }} + + {{ j.created_at.strftime('%m-%d %H:%M') if j.created_at else '—' }} + {{ j.log or '' }} +
No jobs recorded.
+
+
+ +
{# /col-lg-8 #} + + {# ═══ RIGHT COLUMN ═══ #} +
+ + {# ── Plan card ── #} +
+
+ Plan +
+
+
+
+ +
+ +
+
+
+ + {# ── Status actions ── #} +
+
+ Status Actions +
+
+ + {# Impersonate #} + {% if tenant.status == 'active' %} + + Impersonate + + {% endif %} + + {# Suspend #} + {% if tenant.status not in ('suspended', 'deleted') %} + + {% endif %} + + {# Resume #} + {% if tenant.status == 'suspended' %} +
+ +
+ {% endif %} + +
+
+ + {# ── Quick info ── #} +
+
+ Primary URL +
+
+ {% set primary_domain = domains | selectattr('is_primary') | first %} + {% if primary_domain %} + + https://{{ primary_domain.domain }} + + + {% else %} + No primary domain. + {% endif %} +
+
+ +
{# /col-lg-4 #} +
{# /row #} + +{# ── Suspend modal ── #} + + +{% endblock %} diff --git a/control/panel/templates/panel/tenants_list.html b/control/panel/templates/panel/tenants_list.html new file mode 100644 index 0000000..ea96b83 --- /dev/null +++ b/control/panel/templates/panel/tenants_list.html @@ -0,0 +1,69 @@ +{% extends "panel/base.html" %} +{% block title %}Tenants — JQC Control{% endblock %} +{% block page_title %}Tenants{% endblock %} + +{% block content %} +
+
+ + Chain head: {{ chain_head }} + +
+ + New Tenant + +
+ +
+
+ + + + + + + + + + + + + + {% for t in tenants %} + + + + + + + + + + {% else %} + + {% endfor %} + +
#Slug / NameStatusPlanSchemaCreated
{{ t.id }} +
{{ t.slug }}
+
{{ t.name }}
+
+ {{ t.status }} + {{ t.plan_name }} + {% if t.alembic_head == chain_head %} + current + {% elif t.alembic_head %} + {{ t.alembic_head[:12] }}… + {% else %} + unknown + {% endif %} + + {{ t.created_at.strftime('%Y-%m-%d') if t.created_at else '—' }} + + + + +
No tenants yet.
+
+
+{% endblock %} diff --git a/control/panel/tenants.py b/control/panel/tenants.py new file mode 100644 index 0000000..93d72fa --- /dev/null +++ b/control/panel/tenants.py @@ -0,0 +1,392 @@ +""" +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)) diff --git a/control/panel/wsgi_panel.py b/control/panel/wsgi_panel.py new file mode 100644 index 0000000..d905602 --- /dev/null +++ b/control/panel/wsgi_panel.py @@ -0,0 +1,24 @@ +""" +wsgi_panel.py +------------- +WSGI entry point for the JQC superadmin control panel (MT-4). + +Serve with a dedicated Gunicorn process + systemd unit: + gunicorn --workers 2 --bind 127.0.0.1:5001 wsgi_panel:panel_app + +Nginx proxies admin.jqc.app → 127.0.0.1:5001 (see deploy notes in CLAUDE.md). + +Required env vars (same /etc/jqc/control.env loaded by the main app): + CONTROL_DATABASE_URL + CONTROL_FERNET_KEY + PANEL_SECRET_KEY + PANEL_IMPERSONATE_KEY + TENANT_BASE_DOMAIN +""" + +from control.panel import create_panel_app + +panel_app = create_panel_app() + +if __name__ == '__main__': + panel_app.run(host='127.0.0.1', port=5001, debug=False)