""" 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 datetime import timedelta from logging.handlers import RotatingFileHandler from flask import Flask from flask_wtf.csrf import CSRFProtect from .auth import bp as auth_bp from .tenants import bp as tenants_bp from .health import bp as health_bp csrf = CSRFProtect() 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 # ── Session lifetime ────────────────────────────────────────────────── # Superadmin sessions expire after 4 hours of inactivity. # Flask default when permanent=True is 31 days — too long for privileged access. app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=4) # ── CSRF protection ─────────────────────────────────────────────────── # Protects all POST routes in the panel against cross-site request forgery. # Templates include: csrf.init_app(app) # Make csrf_token() available in all panel templates from flask_wtf.csrf import generate_csrf app.jinja_env.globals['csrf_token'] = generate_csrf # ── 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/… app.register_blueprint(health_bp) # /health/ # ── 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): from flask import request as _request 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:; " "object-src 'none'; base-uri 'self'; form-action 'self'; " "frame-ancestors 'none';" ) # HSTS over HTTPS only. The panel serves a single host (admin.jqc.app), # so includeSubDomains is safe here — but kept off for parity/caution. if _request.is_secure or _request.headers.get('X-Forwarded-Proto', '') == 'https': response.headers.setdefault('Strict-Transport-Security', 'max-age=31536000') return response return app