267 lines
13 KiB
Python
267 lines
13 KiB
Python
"""
|
|
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, jsonify)
|
|
|
|
from app.tenancy.resolver import resolve_tenant
|
|
from app.tenancy.engine_cache import get_tenant_engine
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_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 isn't linked to an active JQC workspace.</p>"
|
|
"<p>Check the URL, or contact your administrator.</p>"
|
|
"</div></body></html>"
|
|
)
|
|
|
|
|
|
def _wants_json():
|
|
"""True when this request is the mobile API (or explicitly asks for JSON).
|
|
|
|
Mirrors gates._is_api_request(). The tenancy and billing gates run BEFORE
|
|
any route, so without this they answer an iPad with a 302 to an HTML page:
|
|
URLSession follows it, the client decodes the login/billing markup as JSON
|
|
and reports "the data couldn't be read". The inspector sees a parse error
|
|
instead of "your subscription has expired", and nothing in the app can tell
|
|
the two apart.
|
|
"""
|
|
return (request.path.startswith('/api/')
|
|
or request.accept_mimetypes.best == 'application/json')
|
|
|
|
|
|
def _json(payload, status):
|
|
"""Small local responder — the API error helpers live in a blueprint that
|
|
is not necessarily importable this early in the request."""
|
|
return jsonify(payload), status
|
|
|
|
|
|
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)
|
|
# MT-21: impersonation deliberately rebinds the session
|
|
# to the impersonated tenant, so stamp it rather than
|
|
# clearing it. /auth/impersonate has already written the
|
|
# same marker; this keeps it correct if the superadmin's
|
|
# target changes mid-session.
|
|
from app.tenancy.session_binding import bind_session_tenant
|
|
bind_session_tenant()
|
|
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:
|
|
if _wants_json():
|
|
# An HTML "Workspace not found" page is unreadable to the iPad
|
|
# — it decodes as a parse failure, which looks like a bug in
|
|
# the app rather than a wrong/retired server address.
|
|
return _json({'ok': False,
|
|
'error': 'Workspace not found for this address.'}, 404)
|
|
return Response(_UNKNOWN_TENANT_PAGE, status=404, mimetype='text/html')
|
|
|
|
g.tenant = tenant
|
|
g.tenant_engine = get_tenant_engine(tenant)
|
|
|
|
# MT-21: a session cookie signed for a different tenant validates fine
|
|
# here — same app, same SECRET_KEY — so drop it before any downstream
|
|
# code reads identity out of it. Covers pre-auth session state
|
|
# (mfa_pending_user_id); the tenant tag in User.get_id() covers the
|
|
# authenticated session and the remember-me cookie.
|
|
from app.tenancy.session_binding import enforce_session_tenant
|
|
enforce_session_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/')):
|
|
if _wants_json():
|
|
# 402, not a redirect: the caller is a program.
|
|
# Distinct from 401 on purpose — the iPad retries a
|
|
# 401 by refreshing its token, which would loop
|
|
# forever against a billing block.
|
|
return _json({
|
|
'ok': False,
|
|
'error': 'This workspace\'s trial has ended. '
|
|
'An administrator needs to choose a plan '
|
|
'before the app can sync again.',
|
|
'billing_required': True,
|
|
}, 402)
|
|
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.
|
|
if _wants_json():
|
|
return _json({
|
|
'ok': False,
|
|
'error': 'This workspace is suspended. An administrator needs to '
|
|
'reactivate the subscription before the app can sync again.',
|
|
'billing_required': True,
|
|
}, 402)
|
|
return redirect(url_for('billing.suspended'))
|