Files
JQC_multi_tenant/control/panel/__init__.py
T
2026-06-28 10:16:20 -04:00

112 lines
4.6 KiB
Python

"""
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
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: <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
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/…
# ── 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