""" 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 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 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'))