""" HSEQ SaaS Multi-Tenant — Tenant Resolution Middleware Flask middleware voor automatische tenant-resolutie per request. Ondersteunt session-based, header-based, en subdomain-based resolutie. Architectuur: Defense-in-depth met drie lagen isolatie [1]: L1: Applicatie (deze middleware) L2: Database (PostgreSQL RLS) L3: API (rate limiting, quota) Bronnen: [1] Phase 2 Systeemarchitectuur — phase2_system_architecture_v1.0.md [2] Phase 2 Database Architectuur — phase2_database_architecture_v1.0.md """ from __future__ import annotations import uuid from functools import wraps from typing import Callable, Optional from flask import Flask, Response, g, jsonify, redirect, request, session, url_for from sqlalchemy import text from sqlalchemy.orm import Session, sessionmaker # --------------------------------------------------------------------------- # Tenant context — opgeslagen in Flask g-object # --------------------------------------------------------------------------- class TenantContext: """ Tenant-context object, beschikbaar via g.tenant tijdens request-afhandeling. Attributes: company_id: UUID van de actieve tenant. company_name: Bedrijfsnaam. slug: URL-safe identifier. plan: Abonnementstype (starter, professional, enterprise). status: Bedrijfsstatus (active, suspended, churned, trial). settings: Tenant-specifieke configuratie dict. modules: Set van ingeschakelde module keys. role: Rol van de huidige gebruiker binnen deze tenant. """ def __init__( self, *, company_id: uuid.UUID, company_name: str, slug: str, plan: str, status: str, settings: dict, modules: set[str], role: str, ) -> None: self.company_id = company_id self.company_name = company_name self.slug = slug self.plan = plan self.status = status self.settings = settings self.modules = modules self.role = role def is_module_enabled(self, module_key: str) -> bool: """Controleert of een module actief is voor deze tenant.""" return module_key in self.modules def to_dict(self) -> dict: """Serialiseert context voor API-responses.""" return { "company_id": str(self.company_id), "company_name": self.company_name, "slug": self.slug, "plan": self.plan, "status": self.status, "modules": sorted(self.modules), "role": self.role, } # --------------------------------------------------------------------------- # Middleware setup # --------------------------------------------------------------------------- def init_tenant_middleware( app: Flask, session_factory: sessionmaker, *, login_url: str = "auth.login", rls_enabled: bool = True, ) -> None: """ Registreert tenant resolution middleware op de Flask applicatie. Before-request flow: 1. Haal company_id uit session, header, of subdomain 2. Valideer dat bedrijf bestaat en actief is 3. Laad tenant-configuratie (modules, settings, branding) 4. Stel PostgreSQL RLS context in (SET app.company_id) 5. Sla TenantContext op in g.tenant After-request flow: 1. Reset PostgreSQL sessie-variabele Args: app: Flask applicatie-instantie. session_factory: SQLAlchemy sessionmaker. login_url: Endpoint naam voor login-redirect. rls_enabled: Of PostgreSQL RLS context moet worden ingesteld. """ @app.before_request def _resolve_tenant() -> Optional[Response]: # Skip voor health checks en static files if request.endpoint in (None, "health", "static"): return None # Whitelist endpoints die geen tenant-context vereisen _no_tenant_endpoints = { "auth.login", "auth.logout", "auth.register", "health", "static", "api.v2.auth.login", } if request.endpoint in _no_tenant_endpoints: return None db_session: Session = session_factory() try: company_id = _extract_company_id() if company_id is None: return _unauthorized("Geen tenant-context gevonden.") # Haal bedrijf op from models_v1_0 import Company, CompanyModule company = db_session.query(Company).filter_by(id=company_id).first() if company is None: return _unauthorized("Bedrijf niet gevonden.") if company.status == "suspended": return _forbidden("Bedrijf is gesuspendeerd. Neem contact op met support.") if company.status == "churned": return _forbidden("Bedrijfsaccount is beëindigd.") # Laad ingeschakelde modules enabled_modules = ( db_session.query(CompanyModule) .filter_by(company_id=company.id, enabled=True) .all() ) module_keys = {m.module_key for m in enabled_modules} # Bepaal gebruikersrol role = session.get("role", "viewer") # Stel tenant-context in g.tenant = TenantContext( company_id=company.id, company_name=company.name, slug=company.slug, plan=company.plan, status=company.status, settings=company.settings or {}, modules=module_keys, role=role, ) # Stel RLS context in op PostgreSQL if rls_enabled: db_session.execute( text("SET app.company_id = :cid"), {"cid": str(company.id)}, ) # Sla DB session op voor hergebruik in route handlers g.db_session = db_session except Exception as exc: db_session.close() return _unauthorized(f"Tenant-resolutie mislukt: {exc}") return None @app.teardown_app_request def _cleanup_tenant(exception: Optional[Exception] = None) -> None: """Sluit database session na request afloop.""" db_session = getattr(g, "db_session", None) if db_session is not None: if exception: db_session.rollback() else: db_session.commit() db_session.close() def _extract_company_id() -> Optional[uuid.UUID]: """ Extraheert company_id uit beschikbare bronnen (prioriteitsvolgorde): 1. Flask session (session-based auth) 2. X-Company-Id header (API-gebruik) 3. X-Tenant-Slug header (slug-based resolutie) Returns: UUID van het bedrijf of None. """ # Optie 1: Session-based (browser flow) cid = session.get("company_id") if cid is not None: try: return uuid.UUID(str(cid)) except (ValueError, AttributeError): pass # Optie 2: Header-based (API flow) header_cid = request.headers.get("X-Company-Id") if header_cid: try: return uuid.UUID(header_cid) except ValueError: pass # Optie 3: Slug-based (alternatief) header_slug = request.headers.get("X-Tenant-Slug") if header_slug: # Slug-resolutie vereist database lookup — wordt afgehandeld in middleware # Hier returnen we None; slug-resolutie gebeurt in _resolve_tenant pass return None # --------------------------------------------------------------------------- # Decorators # --------------------------------------------------------------------------- def require_tenant(f: Callable) -> Callable: """ Decorator die afdwingt dat een geldige tenant-context aanwezig is. Retourneert 401 indien g.tenant niet beschikbaar. """ @wraps(f) def wrapper(*args, **kwargs): if not hasattr(g, "tenant") or g.tenant is None: return jsonify({ "error": "Unauthorized", "message": "Tenant-context vereist. Log in opnieuw.", }), 401 return f(*args, **kwargs) return wrapper def require_module(module_key: str) -> Callable: """ Decorator die module-toegang controleert per tenant. Retourneert 403 indien de module niet is ingeschakeld. Gebruik: @app.route('/api/v2/incidents/dashboard') @require_tenant @require_module('incidents') def incidents_dashboard(): ... """ def decorator(f: Callable) -> Callable: @wraps(f) def wrapper(*args, **kwargs): tenant: Optional[TenantContext] = getattr(g, "tenant", None) if tenant is None: return jsonify({ "error": "Unauthorized", "message": "Tenant-context vereist.", }), 401 if not tenant.is_module_enabled(module_key): return jsonify({ "error": "Forbidden", "module": module_key, "message": ( f"Module '{module_key}' is niet beschikbaar voor uw abonnement " f"({tenant.plan}). Neem contact op voor een upgrade." ), }), 403 return f(*args, **kwargs) return wrapper return decorator def require_role(*roles: str) -> Callable: """ Decorator die afdwingt dat de huidige gebruiker een van de opgegeven rollen heeft. Gebruik: @app.route('/api/v2/admin/users') @require_tenant @require_role('owner', 'admin') def admin_users(): ... """ def decorator(f: Callable) -> Callable: @wraps(f) def wrapper(*args, **kwargs): tenant: Optional[TenantContext] = getattr(g, "tenant", None) if tenant is None: return jsonify({"error": "Unauthorized"}), 401 if tenant.role not in roles: return jsonify({ "error": "Forbidden", "message": ( f"Rol '{tenant.role}' heeft geen toegang. " f"Vereist: {', '.join(roles)}." ), }), 403 return f(*args, **kwargs) return wrapper return decorator # --------------------------------------------------------------------------- # Response helpers # --------------------------------------------------------------------------- def _unauthorized(message: str) -> tuple[Response, int]: return jsonify({"error": "Unauthorized", "message": message}), 401 def _forbidden(message: str) -> tuple[Response, int]: return jsonify({"error": "Forbidden", "message": message}), 403