"""
app/tenancy/middleware.py
-------------------------
Wires tenant resolution into the Flask request lifecycle.
`init_tenancy(app)` registers two app-level before_request handlers:
1. `_resolve_tenant` — Host → tenant resolution (MT-1 / MT-4):
* always clears g.tenant / g.tenant_engine
* 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
2. `_billing_gate` — Stripe subscription enforcement (MT-8):
* inert when BILLING_ENABLED=False or MULTI_TENANT_ENABLED=False
* sets g.billing_warning for past_due tenants (banner shown in base.html)
* redirects cancelled/suspended-by-billing tenants to /billing/suspended
* always exempts /billing/* paths so tenants can manage their subscription
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.
"""
import logging
from flask import g, request, current_app, Response, session, redirect, url_for
from app.tenancy.resolver import resolve_tenant
from app.tenancy.engine_cache import get_tenant_engine
logger = logging.getLogger(__name__)
_UNKNOWN_TENANT_PAGE = (
"
"
""
"Workspace not found"
""
"
Workspace not found
"
"
This address isn't linked to an active JQC workspace.
"
"
Check the URL, or contact your administrator.
"
"
"
)
def _is_exempt(path):
if path.startswith('/static/'):
return True
if path.startswith('/signup'):
return True # public self-service signup has no tenant context
if path.startswith('/welcome'):
return True # public marketing/landing page has no tenant context
if path.startswith('/notifications/trial-reminders'):
return True # cross-tenant cron — iterates all tenants from control DB
if path.startswith('/notifications/dunning-reminders'):
return True # cross-tenant cron — iterates past_due tenants from control DB
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
g.billing_warning = None # MT-8: set to 'past_due' by _billing_gate when needed
g.is_apex = False # True when serving the public apex/landing host
# ── Public apex (marketing) host → landing page ──────────────────────
# The apex domain (TENANT_BASE_DOMAIN, e.g. jqc.app) and its www. variant
# are NEVER a tenant. Serve the public landing page for '/' and bounce any
# other non-exempt apex path back to '/'. This runs BEFORE the
# MULTI_TENANT_ENABLED gate on purpose: the marketing site must work even
# in single-tenant mode (MT flag off), where the app would otherwise serve
# its default database (tenant-zero) for every host. /welcome, /signup,
# and /static/ are exempt and pass straight through.
host = (request.host or '').split(':')[0].strip().lower()
base = (current_app.config.get('TENANT_BASE_DOMAIN') or '').strip().lower()
if base and host in (base, f'www.{base}'):
g.is_apex = True
if _is_exempt(request.path):
return
if request.path == '/':
# Serve the landing view without a redirect (the dashboard owns
# '/' on tenant hosts, so we can't register a second '/' route).
return current_app.view_functions['landing.index']()
return redirect('/')
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,
# MT-8: billing state
subscription_status=t.subscription_status,
trial_ends_at=t.trial_ends_at,
)
g.tenant = ctx
g.tenant_engine = get_tenant_engine(ctx)
return # skip normal Host resolution
except Exception:
logger.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)
@app.before_request
def _billing_gate():
"""MT-8: Enforce subscription status. Inert when BILLING_ENABLED=False."""
if not current_app.config.get('BILLING_ENABLED', False):
return
if not current_app.config.get('MULTI_TENANT_ENABLED', False):
return
tenant = getattr(g, 'tenant', None)
if tenant is None:
return # resolver already handled this (404 or exempt path)
# Billing routes must always be reachable so tenants can manage their
# subscription even when blocked, and the webhook can receive events.
if (request.path.startswith('/billing/')
or request.path.startswith('/static/')
or _is_exempt(request.path)):
return
status = tenant.subscription_status
trial_ends_at = tenant.trial_ends_at
if status is None or status == 'active':
return
if status == 'trial':
if trial_ends_at is not None:
from app.utils.time_utils import now_eastern
now = now_eastern()
if now >= trial_ends_at:
# Trial expired — redirect to subscribe; allow /settings/ so
# they can still see their plan page and the subscribe button.
if not (request.path.startswith('/billing/')
or request.path.startswith('/settings/')):
return redirect(url_for('billing.subscribe'))
else:
days_left = (trial_ends_at - now).days
if days_left <= 3:
g.billing_warning = 'trial_ending'
return
if status == 'past_due':
g.billing_warning = 'past_due'
return
# status == 'cancelled' — block and redirect to subscription page.
return redirect(url_for('billing.suspended'))