Aug 20 - Session tenant binding
This commit is contained in:
@@ -12,6 +12,10 @@ from app.tenancy.middleware import init_tenancy
|
||||
from app.tenancy.context import TenantContext
|
||||
from app.tenancy.gates import feature_required, quota_soft_check
|
||||
from app.tenancy.quota import check_quota, check_feature
|
||||
from app.tenancy.session_binding import (
|
||||
bind_session_tenant, enforce_session_tenant, current_tenant_id,
|
||||
tag_user_id, parse_user_id, SESSION_TENANT_KEY,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'RoutingSession',
|
||||
@@ -21,4 +25,11 @@ __all__ = [
|
||||
'quota_soft_check',
|
||||
'check_quota',
|
||||
'check_feature',
|
||||
# MT-21
|
||||
'bind_session_tenant',
|
||||
'enforce_session_tenant',
|
||||
'current_tenant_id',
|
||||
'tag_user_id',
|
||||
'parse_user_id',
|
||||
'SESSION_TENANT_KEY',
|
||||
]
|
||||
|
||||
@@ -5,40 +5,75 @@ 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.
|
||||
backend connections ≈ workers × cached-tenants × (pool_size + max_overflow),
|
||||
so the cache is BOUNDED (MT-21): it holds at most
|
||||
``TENANT_ENGINE_CACHE_MAX`` engines and evicts the least-recently-used one
|
||||
beyond that, disposing it. Without the bound, a process that has served N
|
||||
tenants holds N pools forever, and the connection count grows without limit
|
||||
until MySQL's ``max_connections`` (default 151) rejects new sessions.
|
||||
|
||||
Worked example — 8 workers, pool_size 2 + max_overflow 3, cache cap 32:
|
||||
8 × 32 × 5 = 1280 worst case, vs. unbounded before this change.
|
||||
|
||||
Eviction disposes the engine, which closes its *idle* pooled connections.
|
||||
Connections already checked out by another thread stay valid and are closed
|
||||
when returned, so eviction is safe under concurrency; the evicted tenant
|
||||
simply rebuilds its engine on the next request.
|
||||
|
||||
`invalidate(tenant_id)` drops a cached engine (e.g. after credential rotation
|
||||
or tenant suspension); the next request rebuilds it.
|
||||
"""
|
||||
|
||||
import threading
|
||||
from collections import OrderedDict
|
||||
|
||||
from flask import current_app
|
||||
from sqlalchemy import create_engine
|
||||
|
||||
_engines = {}
|
||||
_engines = OrderedDict()
|
||||
_lock = threading.Lock()
|
||||
|
||||
# Fallback cap used when no application context is available (CLI/cron paths
|
||||
# that touch the cache outside a request). Mirrors the config default.
|
||||
_DEFAULT_CACHE_MAX = 32
|
||||
|
||||
|
||||
def _cache_max():
|
||||
try:
|
||||
return int(current_app.config.get('TENANT_ENGINE_CACHE_MAX', _DEFAULT_CACHE_MAX))
|
||||
except Exception:
|
||||
return _DEFAULT_CACHE_MAX
|
||||
|
||||
|
||||
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
|
||||
evicted = []
|
||||
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_size=current_app.config.get('TENANT_ENGINE_POOL_SIZE', 2),
|
||||
max_overflow=current_app.config.get('TENANT_ENGINE_MAX_OVERFLOW', 3),
|
||||
pool_recycle=current_app.config.get('TENANT_ENGINE_POOL_RECYCLE', 1800),
|
||||
future=True,
|
||||
)
|
||||
_engines[tenant.id] = engine
|
||||
# Bound the cache: drop least-recently-used engines beyond the cap.
|
||||
cap = _cache_max()
|
||||
while cap > 0 and len(_engines) > cap:
|
||||
old_id, old_engine = _engines.popitem(last=False)
|
||||
evicted.append((old_id, old_engine))
|
||||
else:
|
||||
_engines.move_to_end(tenant.id)
|
||||
|
||||
# Dispose outside the lock — dispose() can block on socket teardown.
|
||||
for old_id, old_engine in evicted:
|
||||
try:
|
||||
old_engine.dispose()
|
||||
except Exception:
|
||||
pass
|
||||
return engine
|
||||
|
||||
|
||||
|
||||
@@ -139,6 +139,13 @@ def init_tenancy(app):
|
||||
)
|
||||
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)
|
||||
@@ -156,6 +163,14 @@ def init_tenancy(app):
|
||||
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."""
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
"""
|
||||
app/tenancy/session_binding.py
|
||||
------------------------------
|
||||
MT-21: bind a browser session to the tenant that issued it.
|
||||
|
||||
Why this exists
|
||||
---------------
|
||||
Every tenant is served by the same Flask application with the same
|
||||
``SECRET_KEY``. Before this module, nothing in a signed cookie identified
|
||||
*which* tenant it was issued by, so a cookie minted on one tenant host
|
||||
validated perfectly on another:
|
||||
|
||||
1. A legitimate user of tenant A logs in at a.jqc.app.
|
||||
2. They copy their session cookie onto b.jqc.app (devtools / curl —
|
||||
host-only cookie scoping stops the *browser* replaying it, but not a
|
||||
person doing it by hand).
|
||||
3. The tenancy middleware resolves Host → tenant B and binds tenant B's
|
||||
database.
|
||||
4. Flask-Login calls load_user('7') and gets **tenant B's user #7**.
|
||||
|
||||
The signature was always valid, because it is the same key. The identity was
|
||||
never checked against the tenant. Result: authentication as an arbitrary user
|
||||
in any tenant whose hostname the attacker knows.
|
||||
|
||||
Two layers close this, and both live here:
|
||||
|
||||
``SESSION_TENANT_KEY``
|
||||
A ``_tenant_id`` marker written into the session at every point where the
|
||||
session starts carrying identity (login, MFA challenge hand-off,
|
||||
impersonation). ``enforce_session_tenant()`` clears the whole session when
|
||||
that marker disagrees with the resolved tenant, so pre-authentication
|
||||
session state (``mfa_pending_user_id`` and friends) cannot cross tenants
|
||||
either.
|
||||
|
||||
``tag_user_id`` / ``parse_user_id``
|
||||
Flask-Login derives BOTH the session ``_user_id`` and the "remember me"
|
||||
cookie payload from ``User.get_id()``, and feeds both back through
|
||||
``user_loader``. Tagging the id there — ``"<tenant_id>:<user_id>"`` — is
|
||||
therefore a single seam that covers both cookies. Clearing the session
|
||||
alone would not have been enough: a remember-me cookie repopulates the
|
||||
session immediately afterwards.
|
||||
|
||||
Single-tenant behaviour is unchanged. When ``MULTI_TENANT_ENABLED`` is False,
|
||||
or no tenant is bound (cron, CLI, exempt paths), ids stay bare integers and
|
||||
the enforcement is a no-op.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from flask import current_app, g, session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SESSION_TENANT_KEY = '_tenant_id'
|
||||
|
||||
|
||||
def current_tenant_id():
|
||||
"""Resolved tenant id for this request, or None in single-tenant mode."""
|
||||
if not current_app.config.get('MULTI_TENANT_ENABLED', False):
|
||||
return None
|
||||
tenant = getattr(g, 'tenant', None)
|
||||
return tenant.id if tenant is not None else None
|
||||
|
||||
|
||||
def bind_session_tenant():
|
||||
"""Stamp the current session with the tenant that issued it.
|
||||
|
||||
Called at every point that puts identity into the session. No-op in
|
||||
single-tenant mode so existing sessions keep working untouched.
|
||||
"""
|
||||
tid = current_tenant_id()
|
||||
if tid is not None:
|
||||
session[SESSION_TENANT_KEY] = tid
|
||||
|
||||
|
||||
def enforce_session_tenant():
|
||||
"""Clear the session if it was issued by a different tenant.
|
||||
|
||||
Returns True when the session was cleared. Runs after tenant resolution,
|
||||
from the tenancy middleware.
|
||||
"""
|
||||
tid = current_tenant_id()
|
||||
if tid is None:
|
||||
return False
|
||||
|
||||
bound = session.get(SESSION_TENANT_KEY)
|
||||
|
||||
if bound is None:
|
||||
# An untagged session carrying identity predates this binding, or was
|
||||
# lifted from somewhere else. Either way it cannot be trusted here.
|
||||
if '_user_id' in session or 'mfa_pending_user_id' in session:
|
||||
logger.warning('TENANCY | session_untagged_cleared | tenant=%s', tid)
|
||||
session.clear()
|
||||
return True
|
||||
return False
|
||||
|
||||
if bound != tid:
|
||||
logger.warning('TENANCY | session_tenant_mismatch | bound=%s resolved=%s',
|
||||
bound, tid)
|
||||
session.clear()
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def tag_user_id(user_id):
|
||||
"""Render a Flask-Login identity string, tenant-tagged when applicable.
|
||||
|
||||
Used by ``User.get_id()``. Feeds both the session ``_user_id`` and the
|
||||
remember-me cookie.
|
||||
"""
|
||||
tid = current_tenant_id()
|
||||
if tid is None:
|
||||
return str(user_id)
|
||||
return f'{tid}:{user_id}'
|
||||
|
||||
|
||||
def parse_user_id(raw):
|
||||
"""Inverse of :func:`tag_user_id`, with the tenant check applied.
|
||||
|
||||
Returns the integer user id, or None when the identity must be rejected —
|
||||
a foreign tenant tag, an untagged id arriving in multi-tenant mode, or
|
||||
anything unparseable. ``user_loader`` turns None into an anonymous user,
|
||||
which sends the caller back to the login page.
|
||||
"""
|
||||
if raw is None:
|
||||
return None
|
||||
raw = str(raw)
|
||||
tid = current_tenant_id()
|
||||
|
||||
if ':' in raw:
|
||||
tag, _, uid = raw.partition(':')
|
||||
if tid is None:
|
||||
# Tagged id replayed at a single-tenant / unbound context.
|
||||
return None
|
||||
try:
|
||||
if int(tag) != tid:
|
||||
logger.warning('TENANCY | user_id_tenant_mismatch | tag=%s resolved=%s',
|
||||
tag, tid)
|
||||
return None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
else:
|
||||
uid = raw
|
||||
if tid is not None:
|
||||
# Untagged id in multi-tenant mode: either a session predating
|
||||
# MT-21 or one lifted from another host. Force re-authentication.
|
||||
logger.warning('TENANCY | user_id_untagged_rejected | tenant=%s', tid)
|
||||
return None
|
||||
|
||||
try:
|
||||
return int(uid)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
Reference in New Issue
Block a user