Files
JQC_multi_tenant/app/tenancy/middleware.py
T

116 lines
5.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
app/tenancy/middleware.py
-------------------------
Wires tenant resolution into the Flask request lifecycle.
`init_tenancy(app)` registers a single app-level before_request handler that:
* always clears g.tenant / g.tenant_engine (so downstream code can rely on them)
* does NOTHING further when MULTI_TENANT_ENABLED is False → today's behaviour
* bypasses static + configured exempt paths (health checks)
* MT-4: checks session['impersonating_tenant_id'] and short-circuits Host
resolution when a superadmin is impersonating a tenant
* otherwise resolves the Host header to a tenant and selects its engine
* returns a 404 page for an unknown / unverified / suspended host
Flask-SQLAlchemy already removes the scoped session on app-context teardown,
so each request rebinds via RoutingSession.get_bind against the fresh
g.tenant_engine — no teardown handler is needed here.
"""
from flask import g, request, current_app, Response, session
from app.tenancy.resolver import resolve_tenant
from app.tenancy.engine_cache import get_tenant_engine
_UNKNOWN_TENANT_PAGE = (
"<!doctype html><html lang='en'><head><meta charset='utf-8'>"
"<meta name='viewport' content='width=device-width, initial-scale=1'>"
"<title>Workspace not found</title>"
"<style>body{font-family:-apple-system,Segoe UI,Roboto,sans-serif;"
"background:#f6f7f9;color:#1f2937;display:flex;min-height:100vh;margin:0;"
"align-items:center;justify-content:center;text-align:center}"
".card{background:#fff;padding:2.5rem 3rem;border-radius:12px;"
"box-shadow:0 1px 4px rgba(0,0,0,.08);max-width:30rem}"
"h1{font-size:1.25rem;margin:0 0 .5rem}p{margin:.25rem 0;color:#6b7280}"
"</style></head><body><div class='card'>"
"<h1>Workspace not found</h1>"
"<p>This address isnt linked to an active JQC workspace.</p>"
"<p>Check the URL, or contact your administrator.</p>"
"</div></body></html>"
)
def _is_exempt(path):
if path.startswith('/static/'):
return True
for prefix in current_app.config.get('MULTI_TENANT_EXEMPT_PATHS', []):
if prefix and path.startswith(prefix):
return True
return False
def init_tenancy(app):
@app.before_request
def _resolve_tenant():
# Default state — referenced safely by downstream code regardless of mode.
g.tenant = None
g.tenant_engine = None
if not current_app.config.get('MULTI_TENANT_ENABLED', False):
return # inert: default database serves everything (single-tenant)
if _is_exempt(request.path):
return
# ── MT-4: Superadmin impersonation override ───────────────────────
# When the control panel places a signed token in the session via
# /auth/impersonate, bypass Host resolution and bind directly to that
# tenant's DB. The session is server-signed so this is safe.
imp_id = session.get('impersonating_tenant_id')
if imp_id is not None:
try:
from control.base import control_session
from control.models import Tenant
from app.tenancy.context import TenantContext
with control_session() as s:
t = s.get(Tenant, imp_id)
if t and t.status == 'active':
plan = t.plan
ctx = TenantContext(
id=t.id,
slug=t.slug,
name=t.name,
plan_id=t.plan_id,
db_uri=t.db_uri,
plan_code=plan.code if plan else None,
max_users=plan.max_users if plan else None,
max_facilities=plan.max_facilities if plan else None,
max_inspections_month=plan.max_inspections_month if plan else None,
max_issues_month=plan.max_issues_month if plan else None,
allow_mobile_api=plan.allow_mobile_api if plan else True,
allow_scheduled_reports=plan.allow_scheduled_reports if plan else True,
allow_branding=plan.allow_branding if plan else True,
allow_custom_domain=plan.allow_custom_domain if plan else True,
)
g.tenant = ctx
g.tenant_engine = get_tenant_engine(ctx)
return # skip normal Host resolution
except Exception:
import logging as _logging
_logging.getLogger(__name__).warning(
'TENANCY | impersonation_failed | tenant_id=%s', imp_id
)
# Tenant not found, suspended, or engine error — clear stale session
# keys so the next request doesn't retry a permanently failing lookup.
session.pop('impersonating_tenant_id', None)
session.pop('impersonating_superadmin_id', None)
# ── Normal Host → tenant resolution ──────────────────────────────
host = (request.host or '').split(':')[0].strip().lower()
tenant = resolve_tenant(host)
if tenant is None:
return Response(_UNKNOWN_TENANT_PAGE, status=404, mimetype='text/html')
g.tenant = tenant
g.tenant_engine = get_tenant_engine(tenant)