Tenant — Middleware

Le middleware de résolution du tenant est le point d’entrée central du système multi-tenant. Il résout le tenant, valide l’accès, et configure le contexte pour toute la durée de la requête.

Module : src.module.tenant.infrastructure.middleware

TenantMiddleware

class src.module.tenant.infrastructure.middleware.TenantMiddleware

Middleware dual WSGI/ASGI qui résout le tenant à partir du JWT ou du header HTTP et configure le contexte d’isolation.

Ordre de résolution (configurable via TENANT_RESOLUTION_ORDER) :

  1. JWT : extrait tenant_id du claim JWT

  2. Header : lit le header X-Tenant-ID

Étapes du middleware :

  1. Vérifie si le chemin est exempt (auth, invitations/accept)

  2. Résout le tenant depuis JWT ou header

  3. Valide que le tenant est actif

  4. Vérifie la membership de l’utilisateur (sauf superuser)

  5. Active le backend d’isolation (schema/RLS/FK)

  6. Configure les contextvars (tenant, user, membership, correlation_id)

  7. Traite la requête

  8. Désactive le backend et réinitialise les contextvars

Chemins exempts :

Les routes d’authentification et d’acceptation d’invitation ne nécessitent pas de contexte tenant :

  • /api/v1/auth/register/

  • /api/v1/auth/login/

  • /api/v1/auth/refresh/

  • /api/v1/invitations/accept/

  • /admin/

Configuration dans settings.py :

MIDDLEWARE = [
    ...
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'src.module.tenant.infrastructure.middleware.TenantMiddleware',  # Après AuthenticationMiddleware
    ...
]

Réponses d’erreur :

  • 401 Unauthorized : pas de tenant identifié

  • 404 Not Found : tenant introuvable

  • 403 Forbidden : tenant suspendu/archivé ou pas de membership

Exemple de requête :

GET /api/v1/products/ HTTP/1.1
Authorization: Bearer eyJ...  (contient claim tenant_id)
X-Tenant-ID: acme-corp         (fallback si pas de JWT)

Compatibilité ASGI

Le middleware détecte automatiquement le mode ASGI vs WSGI et gère les contextvars correctement dans les deux cas. Sous ASGI, les contextvars sont scopés par coroutine (conforme au ticket Django #32815).