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
RateLimitServicecouplé au moduleauth(PD-23/24/32) —src/modules/auth/services/rate-limit.service.ts - d'un
rate-limit.config.tsmono-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)¶
RateLimitGuard.canActivate(ExecutionContext)est invoqué par NestJS avant tout handler (registreAPP_GUARDglobal).- Extraction du contexte :
route_template = request.route.path(ex./api/v1/users/:id),ip = request.ip(Expresstrust proxy),user_sub = request.user?.sub ?? 'anonymous',tenant = request.user?.tenant ?? 'public'. - Validation
routecontre regex^/[A-Za-z0-9._~!$&'()*+,;=:@%/-]{1,255}$. Si KO →400 ERR-172-03+ logdecision_state=REJECTED_INVALID_CONTEXT. RouteProfileResolver.resolve(route_template)→{ route_profile, route_family, burst, sustained, dimensions }.IdentifierNormalizer.normalize(...)calcule pour chaque dimensionidentifier_raw → identifier_hmacetroute_key. Construit la liste de 9 clés Redis (4 dim × 2 fenêtres + 1 tenant global).- Vérif
CircuitBreaker.state: CLOSED→ appelRedisAtomicEvaluator.evaluate(keys, args)(timeout 50 ms).OPEN→ appliquer la matrice fail-open/fail-closed sans appel Redis.RedisAtomicEvaluatorappelleredisClient.evalsha(SHA, 9, ...keys, ...args). SiNOSCRIPT→LuaScriptManager.reload()puis retry une fois.- Lua retourne
[decision, remaining_min, retry_after, limiting_window_kind]: decision=0→ALLOWED: ajouter headersX-RateLimit-Limit/Remaining,next().decision=1→THROTTLED:429+ headers +Retry-After; pas d'appel handler (INV-172-02).- Si timeout / conn error /
system_state=DEGRADED: route_family=read→BYPASS_DEGRADED:next()métier exécuté + métriquedecisions_total{decision="bypass_degraded"}. Compteur in-process local (token bucket par instance, plafondburst.max × 2sur 60s) pour limiter le réplay-mode-dégradé (mitige R-08, H-05).route_family ∈ {auth, costly}→DENIED_DEGRADED:503 + Retry-After=retry_after_fail_closed_seconds (1s).RateLimitObservability.record(decision, context)est appelé dans unfinallyasync (INV-172-03 garanti même si exception aval).
2.2 Flux de boot¶
RateLimitModule.onModuleInit():- Charge config via Zod →
ERR-172-05au démarrage si KO. - Charge HMAC secret depuis
ConfigService.get('rateLimit.hmacSecret')(Vaultkv/app/rate-limit-hmac-secret). Pas de fallback. RouteProfileResolvervalide qu'aucune route NestJS exposée n'est sans profil (introspection viaReflector+DiscoveryModule). Si une route est trouvée sans profil →ERR-172-05au boot (mitige H-172-02 / R-03).LuaScriptManager.load()→SCRIPT LOAD+ cache du SHA.HealthProbeScheduler.start()(intervalle 10s).- Vérif Redis
CONFIG GET maxmemory≥redis_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)¶
HealthProbeSchedulerexécutePING + INFO memorytoutes les 10s.- Si KO →
system_statepasse àDEGRADED;CircuitBreaker.open(). Notification métriquessystem_state_changes_total{from,to}. - Première sonde OK →
RECOVERING. degraded_clearing_cycles=3sondes consécutives OK →HEALTHY;CircuitBreaker.close().- Échec en
RECOVERING→ retourDEGRADED, 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/:id → L2FwaS92MS86aWQ → 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.ts → ERR-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 declient_ip_raw - Canal
security-audit(Loki retention 7j, accès SOC/SRE uniquement) :client_ip_rawprésent uniquement sur événementsdecision_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)¶
-
Migration legacy non triviale : la suppression du
GlobalRateLimitMiddlewarePD-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 flagRATE_LIMIT_V2_ENABLED=truegéré parConfigService, 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). -
Réplay-mode-dégradé R-08 : le token bucket local en
BYPASS_DEGRADEDest une mitigation, pas une garantie. Un attaquant qui a accès à plusieurs IP peut multiplier les buckets. Documenté comme dette acceptable V1. -
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).
-
Hot-reload config (INV-172-08) : NestJS
ConfigModulepropose unreload()— il est explicitement non câblé surRateLimitModule. Une PR future pourrait l'introduire par erreur ; protégé parforbiddendans code-contracts. -
Cardinalité métriques (CA-172-07, C-06 review) : le label
routebrut est interdit (cardinalité non bornée → explosion Prometheus). Seulsroute_profile,route_family,decision_state,cause,instance_idsont labels. Documenté comme garde-fou. -
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. -
Lua maintenance : le script
rate_limit_v1.luaest versionné (suffix_v1) et immuable. Toute modification nécessite un_v2+ migration progressive (les deux SHA coexistent transitoirement). Documenté en runbook. -
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 splitterpublicpar IP). -
Tests perf en CI partagé : la cible P99 ≤ 5ms est tenue sur env de prod-like uniquement. CI partagé = relaxation à 10ms (documenté en
package.jsonscripttest:perf). Critère contractuel vérifié en pré-prod. -
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.ipvia Expresstrust 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 enRateLimitDecision = { state, remaining, retry_after, system_state, cause? }. - PD-24 (anti-bruteforce login) : cible
email(user_id-equivalent). PD-172 généralise en dimensionuser_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
RateLimitConfigSchema—z.strictObjectpartout, pas deRecord<string, unknown>. - Anti-catch-absorb (learnings universal 2026-03-08) : appliqué dans
RateLimitObservability—record()dansfinally, pas.catch(). - Anti-enumeration messages 404/400 (learnings universal 2026-03-08) : body unique
{"error":"invalid_context"}pour tous les400. - 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 --noEmitPASS surProbatioVault-backendaprè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 :
GlobalRateLimitMiddlewaresupprimé, 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 coderetourne 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.