""" 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 — ``":"`` — 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