Jun 26 MT-1 phase

This commit is contained in:
2026-06-26 16:37:44 -04:00
parent fc39491891
commit 07cf2e5816
9 changed files with 300 additions and 55 deletions
+18
View File
@@ -0,0 +1,18 @@
"""
app/tenancy/
============
Host-based tenant resolution and per-tenant database routing (MT-1).
Public surface:
RoutingSession — tenant-aware session class (installed on `db`)
init_tenancy — registers the before_request resolver hook
TenantContext — detached descriptor carried on g.tenant
Inert unless config MULTI_TENANT_ENABLED is True.
"""
from app.tenancy.routing import RoutingSession
from app.tenancy.middleware import init_tenancy
from app.tenancy.context import TenantContext
__all__ = ['RoutingSession', 'init_tenancy', 'TenantContext']
+19
View File
@@ -0,0 +1,19 @@
"""
app/tenancy/context.py
----------------------
Lightweight, detached descriptor for the resolved tenant. Populated by the
resolver while a control-plane session is open, then carried on `g.tenant`
for the lifetime of the request. Holds no live ORM object — safe to use after
the control session closes.
"""
from dataclasses import dataclass
@dataclass(frozen=True)
class TenantContext:
id: int
slug: str
name: str
plan_id: int
db_uri: str
+59
View File
@@ -0,0 +1,59 @@
"""
app/tenancy/engine_cache.py
---------------------------
Process-local cache of per-tenant SQLAlchemy engines, keyed by tenant id.
Each tenant has its own database, hence its own engine + connection pool.
Engines are created lazily on first use and reused across requests. Total
backend connections ≈ workers × cached-tenants × pool_size, so pool sizing is
a real scaling lever (see MULTI_TENANT_PLAN.md §4); tune via config, or set a
small pool / switch to NullPool when the tenant count grows large.
`invalidate(tenant_id)` drops a cached engine (e.g. after credential rotation
or tenant suspension); the next request rebuilds it.
"""
import threading
from flask import current_app
from sqlalchemy import create_engine
_engines = {}
_lock = threading.Lock()
def get_tenant_engine(tenant):
"""Return (building if needed) the cached engine for a TenantContext."""
engine = _engines.get(tenant.id)
if engine is not None:
return engine
with _lock:
engine = _engines.get(tenant.id)
if engine is None:
engine = create_engine(
tenant.db_uri,
pool_pre_ping=True,
pool_size=current_app.config.get('TENANT_ENGINE_POOL_SIZE', 5),
max_overflow=current_app.config.get('TENANT_ENGINE_MAX_OVERFLOW', 5),
pool_recycle=current_app.config.get('TENANT_ENGINE_POOL_RECYCLE', 1800),
future=True,
)
_engines[tenant.id] = engine
return engine
def invalidate(tenant_id):
"""Drop and dispose a cached tenant engine, if present."""
with _lock:
engine = _engines.pop(tenant_id, None)
if engine is not None:
engine.dispose()
def clear():
"""Dispose and drop all cached engines (test/teardown helper)."""
with _lock:
engines = list(_engines.values())
_engines.clear()
for engine in engines:
engine.dispose()
+69
View File
@@ -0,0 +1,69 @@
"""
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)
* 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
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
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)
+51
View File
@@ -0,0 +1,51 @@
"""
app/tenancy/resolver.py
-----------------------
Resolve an incoming Host header to a tenant by querying the control plane.
The `control` package is imported lazily inside the function so that the data
plane carries no import-time dependency on the control plane when
multi-tenancy is disabled.
Resolution rules:
* exact match on tenant_domains.domain (host, lowercased, port stripped)
* tenant must be status='active'
* custom domains must be verified; subdomains we issue are trusted
Returns a detached TenantContext or None.
"""
from app.tenancy.context import TenantContext
def resolve_tenant(host):
if not host:
return None
# Lazy import — keeps control plane optional when MT is disabled.
from control.base import control_session
from control.models import TenantDomain
with control_session() as s:
domain = (
s.query(TenantDomain)
.filter(TenantDomain.domain == host)
.first()
)
if domain is None:
return None
if domain.kind == 'custom' and not domain.verified:
return None
tenant = domain.tenant
if tenant is None or tenant.status != 'active':
return None
# Materialise everything needed while the session is still open
# (db_uri decrypts the stored credential).
return TenantContext(
id=tenant.id,
slug=tenant.slug,
name=tenant.name,
plan_id=tenant.plan_id,
db_uri=tenant.db_uri,
)
+29
View File
@@ -0,0 +1,29 @@
"""
app/tenancy/routing.py
----------------------
RoutingSession — the per-request tenant-aware SQLAlchemy session.
Subclasses Flask-SQLAlchemy's own Session so that all existing behaviour
(default bind, __bind_key__ resolution) is preserved. The ONLY change: when a
tenant engine has been selected for the current request (g.tenant_engine, set
by the resolver middleware), every query binds to that engine instead.
When no tenant engine is present — multi-tenancy disabled, an exempt path, a
CLI invocation, or any non-request context — this falls through to the normal
Flask-SQLAlchemy behaviour, i.e. the app's configured default database. This
makes the routing layer completely inert until a tenant is actually resolved,
so the existing single-tenant deployment is unaffected.
"""
from flask import g, has_app_context
from flask_sqlalchemy.session import Session as _FlaskSQLAlchemySession
class RoutingSession(_FlaskSQLAlchemySession):
def get_bind(self, mapper=None, clause=None, bind=None, **kwargs):
# Respect an explicitly supplied bind (engine-targeted operations).
if bind is None and has_app_context():
tenant_engine = g.get('tenant_engine', None)
if tenant_engine is not None:
return tenant_engine
return super().get_bind(mapper=mapper, clause=clause, bind=bind, **kwargs)