Aller au contenu

PD-172 — Plan d'implémentation

0. Préambule — Cadrage et décisions structurantes

Stack cible (réf. spec §10.1) : TypeScript / NestJS / ioredis@5.x. Le backend dispose déjà :

  • d'un GlobalRateLimitMiddleware (PD-19, IP-only, fail-open silencieux) — src/common/middleware/global-rate-limit.middleware.ts
  • d'un RateLimitService couplé au module auth (PD-23/24/32) — src/modules/auth/services/rate-limit.service.ts
  • d'un rate-limit.config.ts mono-clé (global.max=100) — src/config/rate-limit.config.ts

PD-172 remplace ces trois éléments par un module dédié RateLimitModule global, multi-dimensions, multi-fenêtres, atomique, observable et conforme RGPD. La migration des appelants existants (PD-19/23/24/32) est in scope (re-routage, suppression du middleware obsolète, conservation de la compat header Retry-After).

Décisions tranchées dans la spec V2 (corrige tous les bloquants V1) :

Décision V2 Conséquence plan
route_key = base64url(route) (résout C-01) Pas d'injection brute de route dans la clé Redis
identifier_hmac = hex(HMAC-SHA256(secret, raw)) (résout C-03) Secret HMAC obligatoire en boot config (Vault)
Fail-closed = 503 + Retry-After (résout C-04) Branchement HTTP normalisé, headers stables
Profils chiffrés auth/costly/read/health (résout C-08) Plus de paramètre NON FOURNI — config-schema strict
Atomicité Lua (INV-172-10) Une seule EVALSHA par requête, multi-clés
Garde-fou tenant 500 req/min global Clé spéciale route_key="__global__" évaluée dans le même Lua
TTL plafonné 3600s + budget mémoire 256 MB sur db=2 Garde-fou cardinalité + circuit-breaker mémoire

1. Découpage en composants

ID Composant Responsabilité unique Fichier(s) Owner agent
C1 RateLimitConfigSchema Valide la config boot (Zod strict). Charge profils + bornes + secret HMAC. Refuse démarrage si invalide (ERR-172-05). src/modules/rate-limit/config/rate-limit.config.schema.ts, src/modules/rate-limit/config/rate-limit.config.ts agent-A
C2 RouteProfileResolver Résout route → route_profile → route_family. Map figée au boot (table immuable). Garantit qu'aucune route servie n'est sans profil (deny-by-default au démarrage si oubli). src/modules/rate-limit/profiles/route-profile.resolver.ts, src/modules/rate-limit/profiles/route-profile.types.ts agent-A
C3 IdentifierNormalizer Calcule identifier_raw (par dimension), identifier_hmac (HMAC-SHA256), route_key (base64url sans padding). Délègue la résolution IP à Express req.ip (config trust proxy) — fail si non résolu (ERR-172-07). src/modules/rate-limit/normalizer/identifier-normalizer.ts agent-B
C4 LuaScriptManager Charge rate_limit_v1.lua au boot via SCRIPT LOAD, garde le SHA en mémoire, retente LOAD si NOSCRIPT. src/modules/rate-limit/redis/lua-script-manager.ts, src/modules/rate-limit/redis/lua/rate_limit_v1.lua agent-B
C5 RedisAtomicEvaluator Compose les clés (4 dim × 2 fenêtres + tenant global), appelle EVALSHA, parse la réponse {decision, remaining, retry_after}. Timeout strict 50 ms (ERR-172-06). src/modules/rate-limit/redis/redis-atomic-evaluator.ts agent-C
C6 CircuitBreaker États OPEN/HALF_OPEN/CLOSED. Ouvre sur N échecs consécutifs (timeout, conn refused) ou dépassement budget mémoire INFO memory used_memory > 256 MB. Probe sortie : degraded_clearing_cycles=3 succès. src/modules/rate-limit/health/circuit-breaker.ts agent-C
C7 HealthProbeScheduler Sondes toutes les degraded_probe_interval_seconds=10s via PING + INFO memory. Pilote system_state (HEALTHY/DEGRADED/RECOVERING) — machine d'états §5.4 bis. src/modules/rate-limit/health/health-probe.scheduler.ts, src/modules/rate-limit/health/system-state.ts agent-C
C8 RateLimitService Orchestrateur : reçoit le contexte HTTP, appelle C2 + C3 + C5, applique la matrice fail-open/fail-closed selon route_family. Retourne RateLimitDecision typé. src/modules/rate-limit/services/rate-limit.service.ts, src/modules/rate-limit/services/rate-limit.types.ts agent-D
C9 RateLimitGuard NestJS CanActivate global enregistré via APP_GUARD. Construit le contexte (route template, IP, user, tenant), appelle C8, mappe la décision sur la réponse HTTP : 200/4xx (métier), 429 + headers RL, 503 + Retry-After, 400 ERR-172-03/07, 500 ERR-172-04. src/modules/rate-limit/guards/rate-limit.guard.ts agent-E
C10 RateLimitObservability Émet log structuré (request_id, route, route_family, decision_state, cause, system_state, instance_id) + métriques Prometheus (probatio_rate_limit_decisions_total{route_profile, decision}, probatio_rate_limit_check_duration_seconds). Audit IP brute = canal séparé security-audit (rétention 7j, INV-172-12). src/modules/rate-limit/observability/rate-limit.logger.ts, src/modules/rate-limit/observability/rate-limit.metrics.ts agent-E
C11 RateLimitModule Module NestJS global. Exporte RateLimitService. Enregistre APP_GUARD = RateLimitGuard. Initialise C4 + C7 au boot (OnModuleInit). Wiring HMAC secret depuis ConfigService (Vault path kv/app/rate-limit-hmac-secret). src/modules/rate-limit/rate-limit.module.ts agent-F
C12 Migration legacy Supprime GlobalRateLimitMiddleware, supprime RateLimitService du module auth, redirige PD-23/PD-24/PD-32 vers RateLimitService PD-172, met à jour app.module.ts (suppression de consumer.apply(GlobalRateLimitMiddleware)). src/app.module.ts, src/modules/auth/auth.module.ts, src/modules/auth/services/rate-limit.service.ts (delete) agent-F
C13 Tests intégration + perf + sécurité Conteneur Redis (testcontainers), supertest 2 instances Nest (forks de processus distincts pointant la même DB Redis), simulation Redis-down (disconnect), perf k6 minimal P99, fuzzing route/dimension/identifier. src/modules/rate-limit/__tests__/**/*.spec.ts, test/perf/rate-limit.k6.js agent-G

Hiérarchie de dossiers cible :

src/modules/rate-limit/
├─ config/
│   ├─ rate-limit.config.ts
│   └─ rate-limit.config.schema.ts
├─ profiles/
│   ├─ route-profile.resolver.ts
│   ├─ route-profile.table.ts
│   └─ route-profile.types.ts
├─ normalizer/
│   └─ identifier-normalizer.ts
├─ redis/
│   ├─ lua/rate_limit_v1.lua
│   ├─ lua-script-manager.ts
│   └─ redis-atomic-evaluator.ts
├─ health/
│   ├─ circuit-breaker.ts
│   ├─ health-probe.scheduler.ts
│   └─ system-state.ts
├─ services/
│   ├─ rate-limit.service.ts
│   └─ rate-limit.types.ts
├─ guards/
│   └─ rate-limit.guard.ts
├─ observability/
│   ├─ rate-limit.logger.ts
│   └─ rate-limit.metrics.ts
├─ rate-limit.module.ts
└─ __tests__/
    ├─ unit/
    └─ integration/

2. Flux techniques

2.1 Flux nominal (decision pipeline)

  1. RateLimitGuard.canActivate(ExecutionContext) est invoqué par NestJS avant tout handler (registre APP_GUARD global).
  2. Extraction du contexte : route_template = request.route.path (ex. /api/v1/users/:id), ip = request.ip (Express trust proxy), user_sub = request.user?.sub ?? 'anonymous', tenant = request.user?.tenant ?? 'public'.
  3. Validation route contre regex ^/[A-Za-z0-9._~!$&'()*+,;=:@%/-]{1,255}$. Si KO → 400 ERR-172-03 + log decision_state=REJECTED_INVALID_CONTEXT.
  4. RouteProfileResolver.resolve(route_template){ route_profile, route_family, burst, sustained, dimensions }.
  5. IdentifierNormalizer.normalize(...) calcule pour chaque dimension identifier_raw → identifier_hmac et route_key. Construit la liste de 9 clés Redis (4 dim × 2 fenêtres + 1 tenant global).
  6. Vérif CircuitBreaker.state :
  7. CLOSED → appel RedisAtomicEvaluator.evaluate(keys, args) (timeout 50 ms).
  8. OPEN → appliquer la matrice fail-open/fail-closed sans appel Redis.
  9. RedisAtomicEvaluator appelle redisClient.evalsha(SHA, 9, ...keys, ...args). Si NOSCRIPTLuaScriptManager.reload() puis retry une fois.
  10. Lua retourne [decision, remaining_min, retry_after, limiting_window_kind] :
  11. decision=0ALLOWED : ajouter headers X-RateLimit-Limit/Remaining, next().
  12. decision=1THROTTLED : 429 + headers + Retry-After ; pas d'appel handler (INV-172-02).
  13. Si timeout / conn error / system_state=DEGRADED :
  14. route_family=readBYPASS_DEGRADED : next() métier exécuté + métrique decisions_total{decision="bypass_degraded"}. Compteur in-process local (token bucket par instance, plafond burst.max × 2 sur 60s) pour limiter le réplay-mode-dégradé (mitige R-08, H-05).
  15. route_family ∈ {auth, costly}DENIED_DEGRADED : 503 + Retry-After=retry_after_fail_closed_seconds (1s).
  16. RateLimitObservability.record(decision, context) est appelé dans un finally async (INV-172-03 garanti même si exception aval).

2.2 Flux de boot

  1. RateLimitModule.onModuleInit() :
  2. Charge config via Zod → ERR-172-05 au démarrage si KO.
  3. Charge HMAC secret depuis ConfigService.get('rateLimit.hmacSecret') (Vault kv/app/rate-limit-hmac-secret). Pas de fallback.
  4. RouteProfileResolver valide qu'aucune route NestJS exposée n'est sans profil (introspection via Reflector + DiscoveryModule). Si une route est trouvée sans profil → ERR-172-05 au boot (mitige H-172-02 / R-03).
  5. LuaScriptManager.load()SCRIPT LOAD + cache du SHA.
  6. HealthProbeScheduler.start() (intervalle 10s).
  7. Vérif Redis CONFIG GET maxmemoryredis_memory_budget_mb_db2 × 1024 × 1024 (ou warn si la DB n'est pas isolée par Redis ACL).

2.3 Flux dégradation / récupération (état système)

  1. HealthProbeScheduler exécute PING + INFO memory toutes les 10s.
  2. Si KO → system_state passe à DEGRADED ; CircuitBreaker.open(). Notification métriques system_state_changes_total{from,to}.
  3. Première sonde OK → RECOVERING.
  4. degraded_clearing_cycles=3 sondes consécutives OK → HEALTHY ; CircuitBreaker.close().
  5. Échec en RECOVERING → retour DEGRADED, compteur de cycles réinitialisé.

2bis. Diagramme de dépendances agents (step 6b)

graph LR
    %% Wave 1 — fondation, parallélisable
    subgraph "Wave 1 — Fondation"
        A[agent-A: Config + RouteProfileResolver]
        B[agent-B: IdentifierNormalizer + LuaScriptManager]
    end

    %% Wave 2 — infra Redis et santé
    subgraph "Wave 2 — Infra Redis + Health"
        C[agent-C: RedisAtomicEvaluator + CircuitBreaker + HealthProbe]
    end

    %% Wave 3 — service orchestrateur
    subgraph "Wave 3 — Orchestrateur"
        D[agent-D: RateLimitService]
    end

    %% Wave 4 — intégration HTTP et observabilité
    subgraph "Wave 4 — Intégration + Observabilité"
        E[agent-E: RateLimitGuard + Observability]
    end

    %% Wave 5 — wiring + migration legacy
    subgraph "Wave 5 — Module + Migration"
        F[agent-F: RateLimitModule + Migration PD-19/23/24/32]
    end

    %% Wave 6 — tests
    subgraph "Wave 6 — Tests"
        G[agent-G: Tests integration + perf + sécurité]
    end

    A --> C
    B --> C
    A --> D
    B --> D
    C --> D
    D --> E
    E --> F
    A --> F
    F --> G

Ordonnancement : 6 vagues d'exécution. Vague 1 parallélisable (A et B indépendants). Vague 6 (tests) après le merge complet pour valider la chaîne de bout en bout.

2.4 Couverture de la machine d'états (§5.4)

Transition spec Mécanisme
RECEIVED → ALLOWED Lua decision=0, RateLimitGuard.allow()
RECEIVED → THROTTLED Lua decision=1, RateLimitGuard.throttle() (429)
RECEIVED → BYPASS_DEGRADED Breaker OPEN + route_family=read (RateLimitService.bypass())
RECEIVED → DENIED_DEGRADED Breaker OPEN + route_family ∈ {auth,costly} (RateLimitService.denyDegraded())
RECEIVED → REJECTED_INVALID_CONTEXT Validation regex KO en début de RateLimitGuard (ERR-172-03/07)

Aucune transition non listée n'est implémentée (INV-172-06). Les états sont des terminaux retournés par RateLimitService ; aucune méthode n'autorise un changement d'état post-décision.


3. Mapping invariants → mécanismes

Invariant ID Exigence Mécanisme Composant Observable Risque
INV-172-01 Décision identique inter-instance pour même tuple normalisé Clés Redis déterministes (HMAC + base64url) + Lua atomique partagé C3, C4, C5 TC-NOM-01 : 2 instances atteignent la limite ; logs instance_id distincts pour mêmes request_id séries Désynchro horloge (mitigée par TTL, pas de timestamp dans Lua)
INV-172-02 Aucun handler exécuté si refus NestJS Guard.canActivate retourne false ou throw → handler skippé par framework C9 Sonde métier (compteur d'invocation) inchangée ; absence d'événement d'audit métier Guard mal enregistré (mitigé par test APP_GUARD)
INV-172-03 Tout refus produit log + métrique try/finally autour de la décision dans RateLimitGuard ; Observability.record() dans le finally C10 Log JSON structuré + compteur Prometheus decisions_total{decision="throttled\|denied_degraded"} Crash post-décision pré-log (cf. R-04, mitigé par WAL en mémoire avec flush 1s)
INV-172-04 Endpoints sensibles plus restrictifs Profils chargés au boot avec contrainte d'ordre auth.burst < read_standard.burst validée par RateLimitConfigSchema C1, C2 Test boot : config auth.burst.max > read_standard.burst.max → refus démarrage Profil oublié → mitigé par C2 introspection routes (boot-time deny)
INV-172-05 Matrice fail-open/fail-closed respectée RateLimitService.applyDegradationStrategy(route_family, breaker_state) C8 TC-NOM-09/10/11 ; logs cause="redis_unavailable" + decision_state « Redis indisponible » défini par C6 (timeout 50ms OU conn refused/reset OU breaker OPEN) — cf. spec §3
INV-172-06 Transitions limitées à celles listées §5.4 enum DecisionState immuable + factory typée + assertion exhaustive never dans RateLimitGuard.mapDecision C8, C9 Test : tentative de mutation d'un terminal → erreur compilation TS ; log decision_state ∈ enum stricte Drift enum (mitigé par revue + forbidden dans code-contracts)
INV-172-07 Aucune whitelist/bypass Pas d'option de config bypass, pas de header de bypass parsé. Test TC-NOM-14 injectant headers internes → quota normal C9, C11 Aucune config bypass.* n'existe ; recherche grep -r "bypass" src/modules/rate-limit/ retourne uniquement BYPASS_DEGRADED (mode dégradé strict) Drift via PR future (mitigé par forbidden code-contracts + revue)
INV-172-08 Config figée au boot Config injectée immutable via Object.freeze ; pas de hot-reload exposé C1, C11 TC-NOM-13 : changement env var sans redémarrage = comportement inchangé Reload involontaire via ConfigModule.reload() (interdit par forbidden)
INV-172-09 Source de vérité unique §5.1 Toutes les regex et enums sont importées depuis rate-limit.config.schema.ts (Zod). Aucune duplication. C1 Recherche grep -r "rate_limit:v1:" src/ retourne uniquement le schema Drift textuel (mitigé par TC-INV-09 + lint custom interdisant les regex hardcodées)
INV-172-10 Évaluation atomique Lua EVALSHA rate_limit_v1.lua unique par requête, 9 clés en argument C4, C5 Métrique redis_calls_per_decision = 1.00 attendu ; aucune chaîne MULTI/EXEC dans le code Régression vers INCR séquentiel (mitigé par forbidden code-contracts)
INV-172-11 Aucun identifiant brut en Redis IdentifierNormalizer.normalize() est le seul point d'écriture de clé. Calcule HMAC avant tout. C3 TC-NOM-12 + scan post-test des clés db=2 (regex ^rate_limit:v1:.*:[a-f0-9]{64}:.*$ strict) HMAC secret leak (mitigé par chargement Vault)
INV-172-12 IP brute autorisée en logs sécurité 7j max Canal de log dédié security-audit avec policy de rétention infra (Loki retention=168h) ; champ client_ip_raw uniquement dans ce canal C10 Log standard decisions ne contient PAS client_ip_raw ; canal security-audit oui ; alerte si rétention > 7j Drift retention (mitigé par CI infra check)
INV-172-13 /health/* /ready/* → profil dédié health_monitoring (10000/60s) Map figée dans route-profile.table.ts ; pas de whitelist C2 TC-NOM-13 : 9000 req/min OK, 11000 req/min throttled Drift profil (mitigé par test)
INV-172-14 TTL ≤ 3600s + budget 256 MB sur db=2 TTL via ARGV[ttl_burst, ttl_sustained] calculés en TS (window + padding) avec Math.min(ttl, key_ttl_max_seconds=3600) ; HealthProbe lit INFO memory.used_memory et trip le breaker à 256 MB C5, C6, C7 Métriques redis_memory_used_bytes ; alerte Prom > 200 MB ; aucun TTL > 3600s détecté par scan Surconsommation par identifiers spammés (mitigé par breaker + alertes — voir R-02)

4. Mapping critères d'acceptation → mécanismes

Critère ID Mécanisme(s) Composant Observable Risque
CA-172-01 Lua atomique + clés déterministes inter-instance C3, C4, C5 TC-NOM-01 — décision identique sur instances A/B Hash collision HMAC (cryptographiquement négligeable)
CA-172-02 RateLimitGuard produit 429 + X-RateLimit-Limit/Remaining + Retry-After formats §5.1 C9 TC-NOM-02, TC-ERR-01 — regex ^[0-9]{1,10}$ validée Header masqué par middleware aval (mitigé par test e2e)
CA-172-03 Profils chargés au boot ; valeurs §5.2 (auth=5/10s, 20/60s ; costly=3/10s, 10/60s ; read=30/10s, 120/60s) C1, C2 TC-NOM-03 — campagnes mesurées contre seuils Drift config env (mitigé par schema + immuable)
CA-172-04 4 dimensions encodées dans la clé Redis ; variation = nouvelle clé C3, C5 TC-NOM-04 — 4 essais, 4 décisions distinctes Normalisation faillie (mitigé par tests unitaires C3 par dimension)
CA-172-05 2 fenêtres burst+sustained évaluées dans le même Lua C5, Lua TC-NOM-05/06 — refus attribué à la fenêtre dépassée via limiting_window_kind
CA-172-06 Log + métrique systématiques par refus C10 TC-NOM-07 — corrélation request_id log↔métrique Crash post-décision (cf. R-04)
CA-172-07 Métriques Prom labels route_profile, route_family, decision_state, cause (cardinalité bornée par enums + nb profils) C10 TC-NOM-08 — somme métrique = volume injecté Cardinalité route brute interdite (mitigée par usage de route_profile comme label, pas route) — voir C-06 review
CA-172-08 RedisAtomicEvaluator exécute 1 RTT EVALSHA + horodatage micro process.hrtime.bigint() ; budget P99 5ms C5 Métrique check_duration_seconds_bucket ; benchmark k6 Latence GC Node (mitigée par warm-up bench + cible env de test conforme prod)
CA-172-09 Une seule EVALSHA par requête (compteur sortie evaluator) C5 Compteur redis_calls_per_decision exposé en test
CA-172-10 route_key = base64url(route) produit clé conforme redis_key regex C3 TC-NOM-11 : /api/v1/:idL2FwaS92MS86aWQ → clé valide
CA-172-11 Toutes les clés écrites contiennent [a-f0-9]{64} (HMAC) C3 TC-NOM-12 : SCAN db=2 après campagne — assertion regex stricte
CA-172-12 Logs sécurité avec client_ip_raw ; rétention infra 7j C10 + infra TC-NOM-13 (extension) : assert présence client_ip_raw dans canal security-audit uniquement Rétention infra non appliquée (hors plan code)
CA-172-13 Profil health_monitoring (10000/60s) appliqué à /health/* /ready/* C2 TC-NOM-13 : pas de throttle avant 10000 req/min
CA-172-14 TTL ≤ 3600s, budget mémoire 256 MB monitoré C5, C7 TC-NOM-14, TC-NEG-09 : breaker OPEN si > 256 MB
CA-172-15 Garde-fou tenant 500/60s clé spéciale route_key="__global__" évaluée dans le même Lua C5, Lua TC-NOM-14 : tenant multi-routes total > 500/min → throttle

5. Mapping tests (TC-*) → mécanismes + observables

Test ID Référence spec Mécanisme(s) Point(s) d'observation Niveau de test visé
TC-NOM-01 INV-172-01 / CA-172-01 C5 (Lua) + Redis partagé entre 2 instances Nest Réponses HTTP, métrique decisions_total agrégée Integration (testcontainers Redis + 2 forks)
TC-NOM-02 INV-172-02 / CA-172-02 C9 + handler stub Code 429, headers regex, sonde métier non incrémentée Integration
TC-NOM-03 INV-172-04 / CA-172-03 C2 + C5 Différentiel temporel d'arrivée à throttle entre profils Integration
TC-NOM-04 INV-172-01 / CA-172-04 C3 (4 dimensions) Compteurs distincts dans Redis (SCAN par préfixe HMAC) Integration
TC-NOM-05 INV-172-01 / CA-172-05 C5 + Lua (limiting_window_kind=burst) Réponse + log cause=burst Integration
TC-NOM-06 INV-172-01 / CA-172-05 C5 + Lua (limiting_window_kind=sustained) Réponse + log cause=sustained Integration
TC-NOM-07 INV-172-03 / CA-172-06 C10 Présence log JSON + métrique pour chaque request_id refusé Integration
TC-NOM-08 INV-172-03 / CA-172-07 C10 (labels) prometheus.scrape() ; somme = volume injecté Integration
TC-NOM-09 INV-172-05 / CA-172-08 C6 + C8 (read fail-open) Décision BYPASS_DEGRADED, handler exécuté Integration (Redis disconnect)
TC-NOM-10 INV-172-05 / INV-172-02 / CA-172-08 C6 + C8 (auth fail-closed) Décision DENIED_DEGRADED, handler skip, 503 Integration
TC-NOM-11 INV-172-05 / INV-172-02 / CA-172-08 C6 + C8 (costly fail-closed) Idem ci-dessus Integration
TC-NOM-12 CA-172-09 C5 + bench k6 Histogramme check_duration_seconds, P99 ≤ 5ms Perf
TC-NOM-13 INV-172-08 / CA-172-10 C1 + C11 Changement env sans restart = inchangé ; après restart = changé Integration
TC-NOM-14 INV-172-07 C9 (no parsing bypass) Headers X-Internal-* ignorés ; quota normal appliqué Integration
TC-NOM-15 INV-172-06 C8 (5 contextes → 5 terminaux uniques) decision_state ∈ enum strict Unit (DecisionState mapping)
TC-NOM-16 INV-172-06 C8 + C9 Aucune mutation post-terminal observée dans le log d'état Unit
TC-NOM-17 INV-172-10 C5 + C8 Décision identique avec/sans métadonnées crypto en payload Integration
TC-INV-09 INV-172-09 C1 (schema) + lint Vérif statique : 1 source pour chaque regex/enum Static (lint/CI)
TC-ERR-01 ERR-172-01 C9 + C5 429 + headers + sonde métier non incrémentée Integration
TC-ERR-02 ERR-172-02 C6 + C8 503 + Retry-After (1s) Integration
TC-ERR-03 ERR-172-03 C9 (validation regex) 400 + decision_state=REJECTED_INVALID_CONTEXT Unit + Integration
TC-ERR-04 ERR-172-04 C3 (assertion redis_key regex avant envoi à Redis) — pas via hook de test (cf. S-01 review : on remplace l'injection de faute par un test unitaire expect(() => buildKey('?')).toThrow(ERR_172_04)) Unit
TC-ERR-05 ERR-172-05 C1 + C11 Boot avec config invalide → exception ConfigError, app refuse de démarrer Integration (NestJS testing module avec config invalide)
TC-ERR-06 ERR-172-06 C5 (timeout 50ms) + C6 + C8 read=BYPASS_DEGRADED ; auth/costly=DENIED_DEGRADED ; cause timeout dans log Integration (latence simulée via redis-mock proxy)
TC-ERR-07 ERR-172-07 C3 (req.ip null/inconnue) 400 + cause=ip_unresolved Unit + Integration
TC-ERR-08 ERR-172-08 C7 (lecture INFO memory) + C6 Breaker OPEN + alerte (log warning + métrique breaker_state_changes) Integration (Redis avec maxmemory bas pour forcer dépassement)
TC-NEG-01..05 ERR-172-03/07 C3 + C9 400 + log invalid_context Unit
TC-NEG-06 INV-172-01 Lua + Redis partagé Cohérence cross-instance Integration
TC-NEG-07 INV-172-07 C9 (aucun parsing bypass) Quota normal malgré headers internes Integration
TC-NEG-08 INV-172-05 C6 + C7 Dégradation par famille respectée Integration (Redis flapping)
TC-NEG-09 ERR-172-08 / R-02 C7 (INFO memory) + C6 Breaker OPEN + alerte ; service stable Integration
TC-NR-01..07 Régression Suite Integration ré-exécutée à chaque PR CI GitLab Regression

6. Gestion des erreurs

Code Cas Composant traitant Réponse Observable
ERR-172-01 Quota dépassé C5 (Lua) + C9 429 + X-RateLimit-Limit + X-RateLimit-Remaining=0 + Retry-After Log decision_state=THROTTLED, cause=burst|sustained|tenant_global ; métrique decisions_total{decision="throttled"}
ERR-172-02 Redis indisponible + route fail-closed C6 + C8 + C9 503 + Retry-After=1s Log decision_state=DENIED_DEGRADED, cause=redis_unavailable, system_state=DEGRADED ; métrique decisions_total{decision="denied_degraded"}
ERR-172-03 Contexte invalide (regex route/dimension/identifier_raw) C3 + C9 400 JSON {"error":"invalid_context"} (message neutre — anti-énumération R-05) Log decision_state=REJECTED_INVALID_CONTEXT, cause=route_invalid|dimension_invalid|identifier_invalid
ERR-172-04 redis_key non conforme regex redis_key C3 (assertion préalable, jamais envoyée à Redis) 500 JSON {"error":"internal"} Log error + métrique decisions_total{decision="error", cause="redis_key_malformed"} ; alerte Sentry
ERR-172-05 Config boot invalide C1 + C11 App refuse de démarrer (ConfigError thrown dans OnModuleInit) Stderr + exit code 1
ERR-172-06 Timeout > 50ms sur EVALSHA C5 (Promise.race avec timer) Comportement = ERR-172-02 ou bypass selon famille Log cause=timeout ; métrique redis_timeouts_total
ERR-172-07 IP non résolue de manière fiable C3 (req.ip == null ou unknown) 400 JSON {"error":"invalid_context"} (mêmes message qu'ERR-172-03 pour anti-énum) Log cause=ip_unresolved (canal interne uniquement, pas exposé client)
ERR-172-08 Saturation cardinalité / mémoire > 256 MB C7 (INFO memory.used_memory) + C6 Breaker OPEN → matrice fail-open/fail-closed appliquée ; alerte Métrique redis_memory_used_bytes + alerte Prom

Anti-énumération (R-05) : tous les 400 retournent un body identique {"error":"invalid_context"}. Le détail (route_invalid vs ip_unresolved vs dimension_invalid) reste exclusivement dans les logs internes et les métriques (label cause), jamais dans la réponse client.

Anti-catch-absorb (learnings universal §Anti-catch-absorb 2026-03-08) : RateLimitObservability.record() est appelé dans un finally synchronisé, jamais dans un .catch(...) qui absorberait l'échec d'observabilité. Si l'émission métrique throw, l'erreur est propagée — INV-172-03 = fail-closed sur l'observabilité.


7. Impacts sécurité

7.1 Risques identifiés (réf. review §6) et mitigations plan

Risque review Gravité Mitigation dans le plan
R-01 — IP brute en Redis (RGPD) Majeur INV-172-11 implémenté par C3 : aucune valeur brute n'est jamais écrite dans Redis. Audit possible : SCAN db=2 retourne uniquement des ^[a-f0-9]{64}$
R-02 — DoS par spray cardinalité Majeur C7 (lecture INFO memory) + C6 (breaker à 256 MB) + alerte Prom > 200 MB (warning) et > 240 MB (critical). TTL plafonné 3600s limite l'accumulation.
R-03 — Routes oubliées sans protection Majeur C2 introspection au boot via DiscoveryModule : toute route NestJS exposée sans entrée dans route-profile.table.tsERR-172-05 au démarrage. Pas de fallback silencieux.
R-04 — Trou audit crash post-décision Majeur RateLimitObservability écrit dans un buffer mémoire flushé toutes les 1s ET sur chaque arrêt graceful (OnModuleDestroy). Pas de garantie absolue (limite physique), documentée comme dette §9.
R-05 — Énumération via messages 400 Mineur Body JSON unique {"error":"invalid_context"} pour tous les cas ERR-172-03/07. Détail dans logs internes.
R-06 — Redis non isolé (TLS/ACL) Majeur Hors périmètre code, mais le plan exige : (a) Redis ACL avec user dédié rate_limit_v1 (READ/WRITE limité à db=2), (b) TLS via ioredis tls:{}. Documenté dans §10 hors périmètre infra avec ticket de suivi à créer.
R-07 — Vérification formelle Article VIII Mineur INV-172-06 (machine d'états) est exprimable en TLA+/Alloy. Le scope formal PD-172 est attendu : à la fin du plan, ./scripts/formal-check.sh --story PD-172 --step 4 --scope contracts doit retourner GO ou SKIP documenté.
R-08 — Bruteforce illimité en BYPASS_DEGRADED Majeur C8 ajoute un token bucket local (per-process, in-memory) en mode dégradé : burst.max × 2 requêtes/min sur les routes read même si Redis down. Pas un substitut au rate-limit distribué, mais une cap d'urgence. Documenté en log cause=local_bucket_throttle.

7.2 Conformité

Article CONSTITUTIONAL Mécanisme
Article I — Quality Gates Tests unitaires + integration + perf + lint TS strict ; coverage cible ≥ 85% sur src/modules/rate-limit/**
Article II — Validation croisée Plan généré par Claude, review Gate 5 par ChatGPT (Codex), confrontation, dossier conformité
Article III — Traçabilité Logs structurés JSON + audit request_id + Jira sync
Article IV — Non-régression Suite TC-NR-01..07 + tests existants PD-19/23/24/32 ré-exécutés
Article V — Acceptabilité Phase 1 (Sonar QG) + Phase 2 (LLM reviews) en step 7
Article VI — Responsabilité totale Tous les bugs trouvés en step 7 corrigés avant Gate 8
Article VII — Skills /gov-impl, /gov-accept, /gov-gate, /formal-verify
Article VIII — Vérification formelle INV-172-06 (états) + INV-172-01 (cohérence inter-instance) candidats Alloy/TLA+ ; à valider/SKIP par formal-check.sh

7.3 Secret HMAC

  • Source unique : Vault kv/app/rate-limit-hmac-secret
  • Format : 32 bytes random hex (256 bits)
  • Rotation : possible mais nécessite redémarrage coordonné — invalide tous les compteurs (acceptable, fenêtres ≤ 60s). Documenté en runbook ops.
  • Aucune valeur par défaut — boot refusé si absent.

7.4 Logs sensibles (INV-172-12)

  • Canal application (Loki retention 30j) : pas de client_ip_raw
  • Canal security-audit (Loki retention 7j, accès SOC/SRE uniquement) : client_ip_raw présent uniquement sur événements decision_state ∈ {THROTTLED, DENIED_DEGRADED}
  • Configuration retention au niveau infra (Loki rules) — hors code, documenté comme dépendance

8. Hypothèses techniques

ID Hypothèse Impact si faux Mitigation
H-PLAN-01 Express trust proxy est correctement configuré dans main.ts (app.set('trust proxy', ['loopback', '10.0.0.0/8', ...])) req.ip retourne IP du LB au lieu du client → rate-limit IP cassé Vérification au boot dans C11 + log warning si trust proxy === false
H-PLAN-02 Vault est accessible au boot pour récupérer le HMAC secret Boot refuse de démarrer (ERR-172-05) Health check K8s detecte échec ; alerte ops
H-PLAN-03 Redis dispose d'une db=2 isolée, taille ≥ 256 MB allouée, eviction allkeys-lru configurée Saturation prématurée OU eviction des clés actives Documenté en runbook infra ; vérifié au boot par C11 (CONFIG GET maxmemory)
H-PLAN-04 NestJS Reflector permet d'introspecter toutes les routes au boot via DiscoveryService C2 ne peut pas valider l'exhaustivité au boot → R-03 résurge Test unitaire C2 ; fallback : whitelist explicite dans route-profile.table.ts
H-PLAN-05 Le timeout redis_timeout_unavailable_ms=50ms est mesurable côté client ioredis via commandTimeout ou Promise.race Pas de détection de timeout fine → fail-open silencieux Promise.race explicite dans C5 ; metric redis_timeouts_total
H-PLAN-06 EVALSHA + 9 clés sur db=2 tient en < 5 ms P99 sur runner CI partagé TC-NOM-12 flaky Bench k6 exécuté en environnement isolé (runner ovh-shell-test) ; cible CI = P99 < 10ms (relaxé) ; cible prod = P99 ≤ 5ms
H-PLAN-07 Le canal security-audit peut être configuré au niveau Loki avec retention 7j INV-172-12 violé Documenté comme dépendance infra ; plan inclut un ticket à créer si non disponible
H-PLAN-08 NestJS APP_GUARD global s'exécute avant tout middleware Express enregistré (incl. helmet, CORS) sur les routes Nest Middleware s'exécute avant le guard → légère latence ajoutée mais comportement correct (pas de bypass) Tests d'intégration confirmant l'ordre

9. Points de vigilance (risques, dette, pièges)

  1. Migration legacy non triviale : la suppression du GlobalRateLimitMiddleware PD-19 (clé IP-only) doit être coordonnée — pendant un déploiement rolling, les deux peuvent coexister brièvement, et le nouveau Guard peut compter ce que l'ancien middleware a déjà compté (double-throttle transitoire). Mitigation : feature flag RATE_LIMIT_V2_ENABLED=true géré par ConfigService, désactivation du middleware legacy quand le flag est ON. Le flag est temporaire (à supprimer après 2 semaines de stabilité — voir offre /schedule à la fin).

  2. Réplay-mode-dégradé R-08 : le token bucket local en BYPASS_DEGRADED est une mitigation, pas une garantie. Un attaquant qui a accès à plusieurs IP peut multiplier les buckets. Documenté comme dette acceptable V1.

  3. Crash post-décision pré-observabilité (R-04) : non éliminable en absolu sans WAL transactionnel. Buffer mémoire 1s = dette acceptable, documentée. Une amélioration V2 serait Kafka audit avec ack synchrone (hors périmètre).

  4. Hot-reload config (INV-172-08) : NestJS ConfigModule propose un reload() — il est explicitement non câblé sur RateLimitModule. Une PR future pourrait l'introduire par erreur ; protégé par forbidden dans code-contracts.

  5. Cardinalité métriques (CA-172-07, C-06 review) : le label route brut est interdit (cardinalité non bornée → explosion Prometheus). Seuls route_profile, route_family, decision_state, cause, instance_id sont labels. Documenté comme garde-fou.

  6. Ordre Guard vs Middleware : si un middleware Express modifie req.user (ex: JWT decoder), il doit s'exécuter avant le Guard. Vérifié par H-PLAN-08 + tests d'intégration.

  7. Lua maintenance : le script rate_limit_v1.lua est versionné (suffix _v1) et immuable. Toute modification nécessite un _v2 + migration progressive (les deux SHA coexistent transitoirement). Documenté en runbook.

  8. Tenant global (route_key="__global__") : la clé spéciale est calculée dans le même Lua. Attention : tenant=public (utilisateurs anonymes mutualisés) peut exhaust le quota global rapidement. Documenté ; pas de mitigation V1 (V2 pourrait splitter public par IP).

  9. Tests perf en CI partagé : la cible P99 ≤ 5ms est tenue sur env de prod-like uniquement. CI partagé = relaxation à 10ms (documenté en package.json script test:perf). Critère contractuel vérifié en pré-prod.

  10. HMAC secret rotation = invalidation totale : pendant la rotation, tous les compteurs repartent à zéro (changement de clé → nouvelles clés Redis). Acceptable car fenêtres ≤ 60s ; à documenter dans le runbook ops.


10. Hors périmètre

10.1 Hors périmètre code (rappel spec §2)

  • Anti-DDoS réseau / CDN / WAF (couche infra OVH/Cloudflare).
  • CAPTCHA (story dédiée).
  • Device fingerprinting (V2).
  • Quotas billing/pricing (story PD-XX hors backend-core).
  • Rate-limit BullMQ/jobs (couvert par redis-health.service, scope distinct).
  • API key en dimension V1 (prévu V2 — pas de support header X-API-Key).
  • Reload dynamique config (V2).
  • Whitelist/bypass ops (interdit par INV-172-07).

10.2 Hors périmètre code mais dépendances infra

Dépendance Owner Bloquant ?
Redis ACL user rate_limit_v1 + TLS infra/SRE Pas bloquant pour V1 fonctionnel ; bloquant pour go-prod
Loki canal security-audit retention 7j infra/SRE Bloquant INV-172-12 → ticket infra à créer
Vault path kv/app/rate-limit-hmac-secret initialisé infra/SRE Bloquant boot
Alertes Prometheus redis_memory_used_bytes > 200 MB SRE Pas bloquant code ; recommandé go-prod

11. Périmètre de test

Niveau de test In scope Hors scope (justification)
Unitaire Tous les composants C1..C12 (logique pure, pas de Redis live) — coverage cible ≥ 90%
Intégration C5 + C6 + C7 + C8 + C9 contre Redis réel (testcontainers redis:7-alpine) ; suite TC-NOM-* + TC-ERR-* + TC-NEG-* + TC-NR-*
E2E 2 instances NestJS forks distincts → même Redis testcontainer ; supertest pour TC-NOM-01, TC-NEG-06
Performance k6 minimal sur /health/live (profil health_monitoring) + /auth/login (profil auth) ; P99 latency check ≤ 5ms en env pré-prod, ≤ 10ms en CI Bench prod-like complet → ticket SRE séparé (charge réaliste sur env pré-prod isolé)
Sécurité TC-NEG-01..09 (fuzzing inputs, tentatives bypass headers, spray cardinalité) ; scan SCAN db=2 post-tests pour valider INV-172-11 Pentest externe → hors story (couvert par audit annuel)
Statique tsc --noEmit strict après chaque agent step 6b (cf. learning PD-254) ; ESLint ; Sonar QG
Formel INV-172-06 (machine d'états) en TLA+ ou Alloy — scope contracts du formal-check.sh INV-172-01 (cohérence inter-instance) → SKIP documenté si ProbatioVault-formal n'a pas le scope distribué

Couverture minimale attendue : 85% lignes / 80% branches sur src/modules/rate-limit/**. Aucun stub inter-PD : tout est implémentable dans cette story.

Test perimeter — exclusions justifiées contractuellement :

  • Bench prod-like exhaustif : nécessite env isolé non disponible en CI ; ticket SRE séparé à ouvrir post-Gate 8.
  • Pentest externe : couvert par audit annuel ProbatioVault, pas par story.
  • Tests de chaos engineering (kill-9 instance pendant THROTTLED) : V2.

12. Mécanismes cross-module

PD-172 ajoute un Guard global (APP_GUARD = RateLimitGuard) enregistré dans RateLimitModule (importé dans app.module.ts). Ce Guard s'applique à toutes les routes de tous les modules (auth, documents, exports, proof, profile, etc.).

Élément Détail
Routes affectées TOUTES les routes Nest exposées (controllers décorés @Controller)
Modules impactés AuthModule, DocumentsModule, ExportsModule, ProofModule, ProfileModule, HealthModule, etc.
Effet du Guard Bloque l'exécution du handler si quota dépassé ou Redis-down sur famille fail-closed
Mécanisme cross-schéma Aucun (pas de jointure DB) — pure logique HTTP/Redis
Scope d'enregistrement Global via APP_GUARD provider dans RateLimitModule (NOT sur des modules spécifiques)
Routes nécessitant un profil TOUTES — contrainte boot-time : aucune route sans profil ou ERR-172-05 (cf. R-03 mitigation)
Migration de modules existants auth.module.ts : retirer RateLimitService legacy, importer du nouveau module via RateLimitModule.forRoot(). app.module.ts : retirer consumer.apply(GlobalRateLimitMiddleware).
Exceptions d'accès Aucune (INV-172-07) — pas de whitelist, pas d'header de bypass parsé

Point d'attention migration : les services PD-23/24/32 utilisaient RateLimitService.checkLoginLimit(email) etc. → API à conserver via méthodes wrapper sur le nouveau service ou re-câblage explicite. Décision : ré-écriture des appelants plutôt que wrapper (préserve la signature normative evaluate(context) et évite l'éparpillement). Liste des fichiers à modifier : auth.controller.ts, mfa.controller.ts, account-purge.service.ts. Tests existants ré-exécutés en non-régression.


13. Code contracts similaires (réutilisation décisions)

Les décisions architecturales ré-utilisées des stories antérieures :

  • PD-19 (rate-limit IP global) : pattern req.ip via Express trust proxy, hash SHA256 de l'IP. PD-172 conserve la résolution via Express mais remplace SHA256 par HMAC-SHA256 pour empêcher la corrélation cross-environment.
  • PD-23 (rate-limit auth) : structure {allowed, retryAfter, count} retournée par le service. PD-172 étend en RateLimitDecision = { state, remaining, retry_after, system_state, cause? }.
  • PD-24 (anti-bruteforce login) : cible email (user_id-equivalent). PD-172 généralise en dimension user_id.
  • PD-251 (stubs inter-PD) : aucun stub dans PD-172 (tout est implémentable).
  • PD-298 (Zod allowlist stricte metadata telemetry) : appliqué au schéma RateLimitConfigSchemaz.strictObject partout, pas de Record<string, unknown>.
  • Anti-catch-absorb (learnings universal 2026-03-08) : appliqué dans RateLimitObservabilityrecord() dans finally, pas .catch().
  • Anti-enumeration messages 404/400 (learnings universal 2026-03-08) : body unique {"error":"invalid_context"} pour tous les 400.
  • Refined types pour UUID semantiques (learnings universal 2026-03-04) : appliqué pour IdentifierHmac, RouteKey, RedisKey (branded types TS).

14. Critères de DONE pour le step 6 (implémentation)

  • Tous les composants C1..C13 implémentés et testés unitairement
  • tsc --noEmit PASS sur ProbatioVault-backend après chaque agent step 6b
  • Tests d'intégration TC-NOM-* + TC-ERR-* + TC-NEG-* + TC-NR-* PASS
  • Coverage ≥ 85% lignes / 80% branches sur src/modules/rate-limit/**
  • Migration legacy : GlobalRateLimitMiddleware supprimé, callers PD-23/24/32 migrés
  • Bench k6 P99 ≤ 10ms en CI (≤ 5ms en pré-prod)
  • formal-check.sh --story PD-172 --step 6 --scope code retourne GO ou SKIP documenté
  • Sonar QG = PASS (Gate 5 ne valide pas Sonar mais préparation step 7)
  • Documentation runbook ops : rotation HMAC, escalade Redis-down, alertes

Fin du plan d'implémentation PD-172.