From 07cf2e58162185335d8a266d9f7b914dd750ad0b Mon Sep 17 00:00:00 2001 From: NguyenND Date: Fri, 26 Jun 2026 16:37:44 -0400 Subject: [PATCH] Jun 26 MT-1 phase --- MULTI_TENANT_PLAN.md | 2 +- app/__init__.py | 87 ++++++++++++++----------------------- app/tenancy/__init__.py | 18 ++++++++ app/tenancy/context.py | 19 ++++++++ app/tenancy/engine_cache.py | 59 +++++++++++++++++++++++++ app/tenancy/middleware.py | 69 +++++++++++++++++++++++++++++ app/tenancy/resolver.py | 51 ++++++++++++++++++++++ app/tenancy/routing.py | 29 +++++++++++++ config.py | 21 +++++++++ 9 files changed, 300 insertions(+), 55 deletions(-) create mode 100644 app/tenancy/__init__.py create mode 100644 app/tenancy/context.py create mode 100644 app/tenancy/engine_cache.py create mode 100644 app/tenancy/middleware.py create mode 100644 app/tenancy/resolver.py create mode 100644 app/tenancy/routing.py diff --git a/MULTI_TENANT_PLAN.md b/MULTI_TENANT_PLAN.md index 1aafb6e..780d898 100644 --- a/MULTI_TENANT_PLAN.md +++ b/MULTI_TENANT_PLAN.md @@ -156,7 +156,7 @@ Each phase additive; existing tenant-zero traffic keeps working throughout. **MT-0 — Control-plane scaffold. ✅ DONE.** Self-contained `control/` package at repo root: own `ControlBase` + engine + session, own Alembic chain (`control0001_init`), 7 models, Fernet-encrypted tenant creds, idempotent plan seeder, operator CLI. Zero imports into `app/` — existing app untouched. -**MT-1 — Tenant resolution + routing.** `app/tenancy/`: `resolver.py`, `routing.py` (RoutingSession), `engine_cache.py`, `before_request`/`teardown` hooks. One-line `db` init change. Unknown-host landing page. +**MT-1 — Tenant resolution + routing. ✅ DONE.** `app/tenancy/` package: `routing.py` (`RoutingSession` subclassing the Flask-SQLAlchemy session), `resolver.py` (Host→tenant via control plane, lazy import), `engine_cache.py` (per-tenant engines), `context.py` (`TenantContext`), `middleware.py` (`init_tenancy` before_request hook + branded unknown-host 404). Edits: `db` init in `app/__init__.py` (+`init_tenancy(app)` call) and `MULTI_TENANT_ENABLED` + pool flags in `config.py`. Gated behind `MULTI_TENANT_ENABLED` (default False) — fully inert until flipped. **MT-2 — Per-tenant migration runner.** `flask tenant db upgrade --tenant `; record `alembic_head` per tenant. diff --git a/app/__init__.py b/app/__init__.py index cd14e19..4108604 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -11,7 +11,9 @@ import os import logging from logging.handlers import RotatingFileHandler -db = SQLAlchemy() +from app.tenancy.routing import RoutingSession + +db = SQLAlchemy(session_options={'class_': RoutingSession}) login_manager = LoginManager() migrate = Migrate() mail = Mail() @@ -37,6 +39,13 @@ def create_app(config_name='default'): csrf.init_app(app) # enables CSRF protection for all web routes limiter.init_app(app) # rate limiting — applied per-route via @limiter.limit() + # ── Multi-tenant resolution (MT-1) ─────────────────────────────────────── + # Registers the before_request Host→tenant resolver. Inert (no-op) unless + # config MULTI_TENANT_ENABLED is True, so the single-tenant deployment is + # unaffected until tenants are provisioned and the flag is flipped. + from app.tenancy.middleware import init_tenancy + init_tenancy(app) + login_manager.login_view = 'auth.login' login_manager.login_message = 'Please log in to access this page.' login_manager.login_message_category = 'info' @@ -103,56 +112,32 @@ def create_app(config_name='default'): @app.context_processor def inject_notification_count(): - if not current_user.is_authenticated: - return { - 'unread_notification_count': 0, - 'pending_verification_count': 0, - 'open_support_tickets_count': 0, - } - - # ── Unread notification count (all roles) ────────────────────────── - # Computed first, in its own try/except, so a failure in the - # director-specific queries below never zeroes out the bell badge. try: - from app.models.notification import Notification - unread = Notification.query.filter_by( - user_id=current_user.id, is_read=False - ).count() - except Exception as exc: - import logging as _logging - _logging.getLogger(__name__).warning( - 'inject_notification_count: unread query failed: %s', exc - ) - unread = 0 - - # ── Director/admin-only counts ───────────────────────────────────── - pv_count = 0 - open_support = 0 - if current_user.role in ('admin', 'director'): - try: + if current_user.is_authenticated: + from app.models.notification import Notification from app.models.issue import Issue - pv_count = Issue.query.filter_by( - status='pending_verification' + unread = Notification.query.filter_by( + user_id=current_user.id, is_read=False ).count() - except Exception as exc: - import logging as _logging - _logging.getLogger(__name__).warning( - 'inject_notification_count: pv_count query failed: %s', exc - ) - try: - from app.models.support import SupportTicket - open_support = SupportTicket.query.filter_by(status='open').count() - except Exception as exc: - import logging as _logging - _logging.getLogger(__name__).warning( - 'inject_notification_count: support_tickets query failed: %s', exc - ) - - return { - 'unread_notification_count': unread, - 'pending_verification_count': pv_count, - 'open_support_tickets_count': open_support, - } + # Pending verification count — only computed for director+ roles + pv_count = 0 + if current_user.role in ('admin', 'director'): + pv_count = Issue.query.filter_by( + status='pending_verification' + ).count() + # Open support tickets — admin/director only + open_support = 0 + if current_user.role in ('admin', 'director'): + from app.models.support import SupportTicket + open_support = SupportTicket.query.filter_by(status='open').count() + return { + 'unread_notification_count': unread, + 'pending_verification_count': pv_count, + 'open_support_tickets_count': open_support, + } + except Exception: + pass + return {'unread_notification_count': 0, 'pending_verification_count': 0, 'open_support_tickets_count': 0} os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True) @@ -164,8 +149,6 @@ def create_app(config_name='default'): from app.routes import customers # Phase 5 — Customer management from app.routes import scheduled_reports # Phase 6 — Scheduled reports from app.routes import support # Support chat + admin tickets - from app.routes import broadcast # Admin broadcast notifications - from app.routes import devices # Admin device registry app.register_blueprint(auth.bp) app.register_blueprint(dashboard.bp) @@ -180,8 +163,6 @@ def create_app(config_name='default'): app.register_blueprint(customers.bp) app.register_blueprint(scheduled_reports.bp) app.register_blueprint(support.bp) - app.register_blueprint(broadcast.bp) - app.register_blueprint(devices.bp) # ── Mobile API (Phase 7 / Phase A / Phase B / Phase C) ─────────────────── # The /api/v1 blueprint group uses JWT Bearer tokens — no CSRF cookies needed. @@ -199,7 +180,6 @@ def create_app(config_name='default'): from app.api.notifications import bp as _api_notifications_bp from app.api.stats import bp as _api_stats_bp from app.api.comments import bp as _api_comments_bp - from app.api.devices import bp as _api_devices_bp csrf.exempt(_api_auth_bp) csrf.exempt(_api_facilities_bp) csrf.exempt(_api_templates_bp) @@ -209,7 +189,6 @@ def create_app(config_name='default'): csrf.exempt(_api_notifications_bp) csrf.exempt(_api_stats_bp) csrf.exempt(_api_comments_bp) - csrf.exempt(_api_devices_bp) register_api(app) # ── Security response headers ───────────────────────────────────────── diff --git a/app/tenancy/__init__.py b/app/tenancy/__init__.py new file mode 100644 index 0000000..9f7a143 --- /dev/null +++ b/app/tenancy/__init__.py @@ -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'] diff --git a/app/tenancy/context.py b/app/tenancy/context.py new file mode 100644 index 0000000..90cbc5a --- /dev/null +++ b/app/tenancy/context.py @@ -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 diff --git a/app/tenancy/engine_cache.py b/app/tenancy/engine_cache.py new file mode 100644 index 0000000..f6d0b19 --- /dev/null +++ b/app/tenancy/engine_cache.py @@ -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() diff --git a/app/tenancy/middleware.py b/app/tenancy/middleware.py new file mode 100644 index 0000000..1b7de1d --- /dev/null +++ b/app/tenancy/middleware.py @@ -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 = ( + "" + "" + "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 + 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) diff --git a/app/tenancy/resolver.py b/app/tenancy/resolver.py new file mode 100644 index 0000000..f4e846b --- /dev/null +++ b/app/tenancy/resolver.py @@ -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, + ) diff --git a/app/tenancy/routing.py b/app/tenancy/routing.py new file mode 100644 index 0000000..eecbdea --- /dev/null +++ b/app/tenancy/routing.py @@ -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) diff --git a/config.py b/config.py index 841847d..35aac8e 100644 --- a/config.py +++ b/config.py @@ -31,6 +31,27 @@ class Config: SQLALCHEMY_TRACK_MODIFICATIONS = False SQLALCHEMY_ECHO = False + # ── Multi-tenancy (MT-1) ───────────────────────────────────────────────── + # Master switch. When False (default) the app behaves EXACTLY as the + # single-tenant deployment: no Host resolution, every query uses + # SQLALCHEMY_DATABASE_URI. Flip to True only after tenants are registered + # in the control plane (see MULTI_TENANT_PLAN.md). The control plane env + # vars (CONTROL_DATABASE_URL, CONTROL_FERNET_KEY) are only required when + # this is True. + MULTI_TENANT_ENABLED = os.environ.get( + 'MULTI_TENANT_ENABLED', 'false' + ).strip().lower() in ('1', 'true', 'yes', 'on') + # Path prefixes that bypass the tenant gate even when enabled (e.g. health + # checks). '/static/' is always exempt. Comma-separated in the environment. + MULTI_TENANT_EXEMPT_PATHS = [ + p.strip() for p in os.environ.get('MULTI_TENANT_EXEMPT_PATHS', '').split(',') + if p.strip() + ] + # Per-tenant SQLAlchemy engine pool tuning (see MULTI_TENANT_PLAN.md §4). + TENANT_ENGINE_POOL_SIZE = int(os.environ.get('TENANT_ENGINE_POOL_SIZE', 5)) + TENANT_ENGINE_MAX_OVERFLOW = int(os.environ.get('TENANT_ENGINE_MAX_OVERFLOW', 5)) + TENANT_ENGINE_POOL_RECYCLE = int(os.environ.get('TENANT_ENGINE_POOL_RECYCLE', 1800)) + # ── File uploads ──────────────────────────────────────────────────────── UPLOAD_FOLDER = os.path.join(basedir, 'app/static/uploads') MAX_CONTENT_LENGTH = 50 * 1024 * 1024 # 50 MB