Aller au contenu

PD-180 — Plan d'implementation

1. Decoupe en composants

C1 — Entites & Migration DDL

Responsabilite : schema de donnees PostgreSQL pour webhooks, intentions de livraison et tentatives.

Entite Table Schema Description
Webhook webhooks vault_secure Abonnement webhook par organisation
WebhookDelivery webhook_deliveries vault_secure Intention de livraison (append-only metier)
WebhookDeliveryAttempt webhook_delivery_attempts vault_secure Tentative HTTP individuelle (append-only metier)

Migration TypeORM reversible (up/down).

C2 — DTOs & Validation

Responsabilite : validation d'entree (Pipes NestJS), serialisation de sortie.

DTO Usage
CreateWebhookDto Creation webhook (target_url, event_types)
UpdateWebhookDto Mise a jour (target_url, event_types, status)
WebhookResponseDto Reponse API (secret masque)
WebhookListResponseDto Reponse liste paginee
ReplayEventDto Replay (event_id)
DeliveryLogResponseDto Journal de tentatives

C3 — Service CRUD Webhooks

Responsabilite : logique metier CRUD, quota, machine d'etats webhook, rotation secret.

Methodes : - create(tenantId, dto) — creation + generation secret CSPRNG + stockage hash - findAll(tenantId, pagination) — liste paginee avec secret masque - findOne(tenantId, webhookId) — lecture unitaire - update(tenantId, webhookId, dto) — mise a jour attributs modifiables + revalidation - delete(tenantId, webhookId) — transition vers DELETED - rotateSecret(tenantId, webhookId) — generation nouveau secret, invalidation immediate - ping(tenantId, webhookId, userId) — creation intention de livraison webhook.ping

C4 — Service Signature HMAC

Responsabilite : construction payload canonique, calcul signature HMAC-SHA256.

Methodes : - buildCanonicalPayload(eventType, data) — construction objet dans l'ordre contractuel selon variante A/B/C - sign(secretSha256, timestamp, payload)HMAC-SHA256(secretSha256, t + "." + JSON.stringify(payload)) - buildSignatureHeader(timestamp, signature)t=<unix>,v1=<hex64>

C5 — Service SSRF

Responsabilite : validation URL + resolution DNS + blocage IP privees + mitigation DNS rebinding.

Methodes : - validateUrl(url) — schema https uniquement, longueur <= 2048, RFC 3986 - resolveAndValidate(url) — resolution DNS A/AAAA, validation IP contre ranges interdits - isPrivateIp(ip) — blocage 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8, ::1, 169.254.0.0/16, fe80::/10, 169.254.169.254

C6 — Service de livraison

Responsabilite : creation intentions, envoi HTTP, gestion retry, journalisation append-only.

Methodes : - createDeliveryIntentions(eventType, eventData, sourceEventId) — trouve webhooks ACTIVE abonnes, cree intentions PENDING - executeDelivery(deliveryId) — relit secret_sha256 en DB, signe, envoie HTTP (timeout 5s, no-follow redirects), journalise tentative - scheduleRetry(deliveryId, attemptNumber) — planifie retry selon sequence contractuelle - replay(tenantId, eventId, userId) — verifie eligibilite, cree nouvelle intention avec original_event_id

C7 — Rate Limiter

Responsabilite : sliding window 100 intentions/min/org via Redis.

Methodes : - checkAndIncrementN(tenantId, count) — reserve N unites de quota (1 par intention), retourne { allowed: number, delayed: number }. Les intentions au-dela de la limite sont marquees pour enqueue avec delay progressif (pas de rejet, pas de perte). - getUsage(tenantId) — consultation quota courant

Implementation : Redis ZSET avec score = timestamp Unix ms, ZRANGEBYSCORE pour fenetre glissante 60s, ZCARD pour comptage. Le check est fait APRES le calcul du nombre d'intentions (N webhooks abonnes) et AVANT l'enqueue BullMQ. Les intentions excedentaires sont enqueuees avec un delay calcule pour respecter le debit (pas de 429).

C8 — Worker BullMQ

Responsabilite : traitement asynchrone des livraisons.

Queue Job type Description
webhook-delivery deliver Execution d'une tentative de livraison
webhook-delivery retry Retry planifie avec delay
webhook-delivery purge Purge technique >30j des tentatives

C9 — Controller REST

Responsabilite : endpoints API, guards, validation d'entree.

Methode Route Description
POST /webhooks Creation webhook
GET /webhooks Liste paginee
GET /webhooks/:id Detail webhook
PATCH /webhooks/:id Mise a jour
DELETE /webhooks/:id Suppression
POST /webhooks/:id/rotate-secret Rotation secret
POST /webhooks/:id/ping Test ping
POST /webhooks/replay/:eventId Replay evenement (broadcast vers tous webhooks ACTIVE abonnes)
GET /webhooks/:id/deliveries Journal livraisons

C10 — Module NestJS

Responsabilite : assemblage DI, registration BullMQ queue, imports.


2. Flux techniques

Flux A — Creation webhook

Client → Controller.create()
  → Guard: JWT validation + tenant extraction
  → Pipe: CreateWebhookDto validation
  → WebhooksService.create(tenantId, dto)
    → SsrfService.validateUrl(dto.target_url)
    → Verifie quota (COUNT WHERE org_id = tenantId AND status != DELETED) < 5
    → crypto.randomBytes(32) → secret brut
    → secret_sha256 = SHA-256(secret_brut)
    → INSERT webhook (status = ACTIVE)
    → Retourne secret brut UNE SEULE FOIS
  → Response 201 avec secret en clair

Flux D — Emission evenement metier

Module source emet EventEmitter2 event (avec tenantId dans le payload)
  → WebhookEventListener.onEvent(eventType, data, tenantId)
    → DeliveryService.createDeliveryIntentions(eventType, data, tenantId)
      → SELECT webhooks WHERE org_id = tenantId AND status = ACTIVE AND eventType IN event_types
      → N = nombre de webhooks matches
      → RateLimiterService.checkAndIncrementN(tenantId, N)
        → Retourne { allowed: M, delayed: N-M } (M <= 100 restants dans la fenetre)
      → BEGIN TRANSACTION (DB uniquement, pas BullMQ)
        → Pour chaque webhook : INSERT webhook_delivery (status = PENDING)
      → COMMIT
      → Pour chaque delivery (post-commit, pattern outbox) :
        → Si dans les M premiers : Enqueue BullMQ job "deliver" (deliveryId, delay=0)
        → Si excedentaire : Enqueue BullMQ job "deliver" (deliveryId, delay=calcule)
    → Worker recoit job "deliver"
      → DeliveryService.executeDelivery(deliveryId)
        → SELECT webhook.secret_sha256 FROM DB (relecture obligatoire)
        → SsrfService.resolveAndValidate(target_url) — DNS A/AAAA + IP check
        → SignatureService.buildCanonicalPayload(eventType, data)
        → SignatureService.sign(secret_sha256, now(), payload)
        → HTTP POST target_url (timeout 5s, maxRedirects: 0)
        → INSERT webhook_delivery_attempt (status, code, duration)
        → Si 2xx → UPDATE delivery status = DELIVERED
        → Si non-2xx/timeout/erreur → scheduleRetry(deliveryId, attemptNum)
          → Si attemptNum >= 10 → UPDATE delivery status = FAILED
          → Sinon → enqueue BullMQ "retry" avec delay selon sequence

Flux G — Rotation secret

Client → Controller.rotateSecret(webhookId)
  → WebhooksService.rotateSecret(tenantId, webhookId)
    → SELECT webhook WHERE id = webhookId AND org_id = tenantId
    → crypto.randomBytes(32) → nouveau secret brut
    → new_sha256 = SHA-256(nouveau_secret)
    → UPDATE webhook SET secret_sha256 = new_sha256, updated_at = NOW()
    → Les tentatives futures relisent secret_sha256 en DB → nouveau secret automatiquement
    → Retourne nouveau secret brut UNE SEULE FOIS

Flux F — Replay

Client → Controller.replay(eventId)  // pas de webhookId — broadcast
  → DeliveryService.replay(tenantId, eventId)
    → SELECT delivery WHERE event_id = eventId
    → Verifie : meme org, status IN (DELIVERED, FAILED), age < 30j
    → Si PENDING/IN_PROGRESS/RETRY_SCHEDULED → 409 Conflict
    → Si autre org → 403
    → Si introuvable/purge → 404
    → Cree nouvelle intention avec :
      → Nouveau event_id (crypto.randomUUID())
      → Nouveau timestamp
      → original_event_id = eventId (trace interne)
    → Emission vers webhooks ACTIVE abonnes AU MOMENT DU REPLAY

2b. Diagrammes Mermaid

Graphe de dependances des composants

graph TD
    C9[C9 — Controller REST] --> C3[C3 — Service CRUD]
    C9 --> C2[C2 — DTOs & Validation]
    C3 --> C1[C1 — Entites & Migration DDL]
    C3 --> C5[C5 — Service SSRF]
    C3 --> C4[C4 — Service Signature HMAC]
    C6[C6 — Service de livraison] --> C4
    C6 --> C5
    C6 --> C1
    C7[C7 — Rate Limiter] -.->|Redis ZSET| C6
    C8[C8 — Worker BullMQ] --> C6
    C10[C10 — Module NestJS] --> C9
    C10 --> C3
    C10 --> C6
    C10 --> C7
    C10 --> C8

    style C9 fill:#4a90d9,color:#fff
    style C3 fill:#4a90d9,color:#fff
    style C6 fill:#d94a4a,color:#fff
    style C8 fill:#d94a4a,color:#fff
    style C5 fill:#d9a54a,color:#fff
    style C4 fill:#d9a54a,color:#fff
    style C7 fill:#d9a54a,color:#fff
    style C1 fill:#5cb85c,color:#fff
    style C2 fill:#5cb85c,color:#fff
    style C10 fill:#888,color:#fff

Legende : bleu = API, rouge = traitement asynchrone, orange = services transversaux, vert = donnees, gris = assemblage.

Sequence — Emission evenement metier (Flux D)

sequenceDiagram
    participant Src as Module source
    participant EE as EventEmitter2
    participant Lst as WebhookEventListener
    participant DS as C6 DeliveryService
    participant RL as C7 RateLimiter
    participant DB as PostgreSQL
    participant BM as BullMQ
    participant Wk as C8 Worker
    participant SS as C5 SsrfService
    participant Sig as C4 SignatureService
    participant Ext as Endpoint partenaire

    Src->>EE: emit(eventType, data, tenantId)
    EE->>Lst: onEvent(eventType, data, tenantId)
    Lst->>DS: createDeliveryIntentions(eventType, data, tenantId)
    DS->>DB: SELECT webhooks WHERE org_id=tenantId AND ACTIVE AND event_type match
    DB-->>DS: N webhooks
    DS->>RL: checkAndIncrementN(tenantId, N)
    RL-->>DS: {allowed: M, delayed: N-M}
    DS->>DB: BEGIN TX — INSERT N webhook_deliveries (PENDING)
    DS->>DB: COMMIT
    loop Pour chaque delivery
        DS->>BM: enqueue "deliver" (delay=0 si allowed, delay=calcule si excedentaire)
    end
    BM->>Wk: job "deliver" (deliveryId)
    Wk->>DS: executeDelivery(deliveryId)
    DS->>DB: SELECT webhook.secret_sha256
    DS->>SS: resolveAndValidate(target_url)
    SS-->>DS: IP validee
    DS->>Sig: buildCanonicalPayload(eventType, data)
    Sig-->>DS: payload canonique
    DS->>Sig: sign(secret_sha256, timestamp, payload)
    Sig-->>DS: signature HMAC
    DS->>Ext: POST target_url (timeout 5s, maxRedirects:0)
    alt 2xx
        Ext-->>DS: 200 OK
        DS->>DB: INSERT attempt (SUCCESS) + UPDATE delivery DELIVERED
    else non-2xx / timeout
        Ext-->>DS: erreur
        DS->>DB: INSERT attempt (FAILED)
        alt attemptNum < 10
            DS->>BM: enqueue "retry" (delay selon sequence)
        else attemptNum >= 10
            DS->>DB: UPDATE delivery FAILED
        end
    end

Sequence — Creation webhook (Flux A)

sequenceDiagram
    participant Client
    participant C9 as C9 Controller
    participant C2 as C2 DTO Pipe
    participant C3 as C3 WebhooksService
    participant C5 as C5 SsrfService
    participant DB as PostgreSQL

    Client->>C9: POST /webhooks (JWT)
    C9->>C9: Guard JWT — extract tenantId
    C9->>C2: validate CreateWebhookDto
    C2-->>C9: dto valide
    C9->>C3: create(tenantId, dto)
    C3->>C5: validateUrl(dto.target_url)
    C5-->>C3: URL valide (HTTPS)
    C3->>DB: COUNT webhooks WHERE org_id=tenantId AND status!=DELETED
    DB-->>C3: count < 5
    C3->>C3: crypto.randomBytes(32) → secret brut
    C3->>C3: SHA-256(secret_brut) → secret_sha256
    C3->>DB: INSERT webhook (ACTIVE, secret_sha256)
    C3-->>C9: webhook + secret brut (UNE SEULE FOIS)
    C9-->>Client: 201 Created (secret en clair)

3. Mapping invariants → mecanismes

Invariant ID Exigence Mecanisme Composant Observable Risque
INV-01 Payload sans donnees sensibles Schema JSON whitelist strict (variantes A/B/C) avec additionalProperties: false. Construction programmatique du payload par SignatureService.buildCanonicalPayload(). C4 Payload serialise ne contient que les champs whitelistes Ajout accidentel de champ dans une variante
INV-02 Signature HMAC-SHA256 systematique crypto.createHmac('sha256', secretSha256).update(message).digest('hex') dans SignatureService.sign() C4 Header X-ProbatioVault-Signature present sur chaque requete
INV-03 Anti-rejeu ±5 min (timestamp t) Timestamp Unix inclus dans le header de signature. Documentation partenaire pour rejet hors fenetre. C4 Champ t dans header, format t=<unix>,v1=<hex64> Horloge non synchronisee NTP (H-05)
INV-04 URL strictement HTTPS SsrfService.validateUrl() : regex schema + rejet explicite de http, ftp, file, data, javascript. Applique a la creation ET avant chaque envoi. C5 Rejet 400 si schema non-https URL valide a l'enregistrement mais DNS change
INV-05 Redirections non suivies Client HTTP Axios configure maxRedirects: 0. Code 3xx traite comme echec. C6 Tentative en echec sur 3xx Axios suit par defaut — config obligatoire
INV-06 Intention journalisee avant tentative INSERT webhook_delivery (PENDING) dans transaction AVANT enqueue BullMQ job. C6 Timestamp intention < timestamp premiere tentative Crash entre insert et enqueue (rattrapage async)
INV-07 Journal append-only metier + purge >30j Aucun UPDATE/DELETE applicatif sur webhook_delivery_attempts. Purge via BullMQ cron job "purge" (DELETE WHERE created_at < NOW() - 30d sur tentatives uniquement). Intentions conservees sans limite. C6, C8 Absence de mutation metier, tentatives >30j purgees Purge mal configuree efface les intentions
INV-08 Isolation tenant stricte RLS PostgreSQL sur org_id. Toutes les requetes filtrent par tenantId extrait du JWT. C1, C3, C9 Aucun acces cross-tenant RLS policy manquante sur nouvelle table
INV-09 Secret non expose apres creation/rotation WebhookResponseDto masque : secret_hint = secret_brut.slice(-4). Secret brut retourne uniquement dans la reponse de creation/rotation. C2, C3 GET/LIST ne retournent que le masque 4 chars Serialisation accidentelle du champ secret_sha256
INV-10 Org derivee du JWT uniquement @CurrentUser('tenant') decorator. Aucun parametre client pour org_id. C9 Parametre org_id forge → scope effectif = JWT tenant Decorateur oublie sur un endpoint
INV-11 Non-mutation probatoire Module webhooks n'importe aucun repository de documents/preuves/ancrages. Aucune injection de services probatoires. C10 Absence d'import de modules probatoires Couplage accidentel via EventEmitter
INV-12 Emission apres journal source EventEmitter2 ecoute les evenements APRES commit de l'operation source. Le listener webhook ne cree l'intention que si l'evenement source existe deja en DB. C6 Requete de verification existence evenement source Module source n'emet pas l'evenement
INV-13 Transitions non listees interdites Methode validateTransition(from, to) avec matrice explicite. Throw HttpException(409) si transition non autorisee. C3 Rejet explicite des transitions illegales Oubli d'un cas dans la matrice
INV-14 Crash pre-commit rollback / post-commit rattrapage Pre-commit : transaction PostgreSQL standard (rollback automatique). Post-commit : BullMQ job persistant en Redis, worker reprend au redemarrage. C6, C8 Aucun artefact orphelin post-crash pre-commit ; livraison rattrapee post-crash post-commit Redis flush entre commit et job pickup
INV-15 Protection SSRF complete SsrfService.resolveAndValidate() : resolution DNS, validation IP, IP pinning (connexion vers IP resolue, pas re-resolution). Double resolution si pinning non supporte. C5 Blocage IP privees/loopback/link-local/metadata Nouvelle range IP non couverte

4. Mapping criteres d'acceptation → mecanismes

Critere ID Mecanisme(s) Composant Observable Risque
CA-01 CRUD complet via Controller REST + WebhooksService C3, C9 Endpoints POST/GET/PATCH/DELETE fonctionnels
CA-02 COUNT(*) WHERE org_id = tenantId AND status != 'DELETED' avant INSERT, rejet 409 si >= 5 C3 6e creation rejetee 409 Race condition si creations concurrentes (mitige par SERIALIZABLE ou advisory lock)
CA-03 SignatureService.sign() + buildSignatureHeader() C4 Header t=<unix>,v1=<hex64> valide et verificable
CA-04 Timestamp inclus dans signature, documentation partenaire C4 Champ t parseable et dans fenetre ±5 min
CA-05 SsrfService.validateUrl() — regex ^https:// C5 Rejet 400 pour tout schema non-https
CA-06 Axios maxRedirects: 0 C6 3xx classe echec
CA-07 Sequence de delays codee en constante : [60, 300, 900, 3600, 3600, 3600, 3600, 3600, 3600, 3600] secondes. BullMQ delay option sur retry job. C6, C8 Timing exact des retries, max 10 tentatives Drift BullMQ delay non garanti a la seconde
CA-08 INSERT-only sur webhook_delivery_attempts. Cron job BullMQ purge DELETE WHERE created_at < NOW() - 30d sur tentatives. Intentions (webhook_deliveries) conservees sans limite. C6, C8 Aucune mutation metier. Purge infra >30j sur tentatives uniquement.
CA-09 Replay cree nouvelle intention avec event_id = crypto.randomUUID(), original_event_id stocke en colonne sur webhook_deliveries. Consultable via GET deliveries. C6, C9 Correlation event original visible en API
CA-10 rotateSecret() : UPDATE atomique secret_sha256 en DB. Worker relit en DB a chaque tentative. C3, C6 Tentative post-rotation utilise nouveau secret
CA-11 RLS PostgreSQL + filtre org_id = tenantId dans chaque requete. Guard NestJS verifie JWT. C1, C9 Tests cross-tenant refuses 403
CA-12 Aucun import de repository/service probatoire dans WebhooksModule C10 Etats probatoires inchanges apres CRUD/ping/replay
CA-13 Redis ZSET sliding window 60s. Cle : webhook:rate:{tenantId}. ZADD score=timestamp, ZREMRANGEBYSCORE pour nettoyer, ZCARD pour compter. Si >= 100 → enqueue avec delay (pas de perte). C7 101e intention mise en attente, traitee dans l'ordre Redis indisponible → fail-closed (503)
CA-14 SsrfService : validation a la creation (Controller) ET avant envoi (Worker). DNS resolve + IP check + IP pinning. C5, C6 IP privee/metadata cloud bloquee
CA-15 crypto.randomBytes(32) minimum. Stockage SHA-256(secret).toString('hex'). Relecture DB par tentative. C3, C6 Secret >= 32 bytes, DB contient hash hex64, pas de cache worker

5. Mapping tests (TC-*) → mecanismes + observables

Test ID Reference spec Mecanisme(s) Point(s) d'observation Niveau de test vise
TC-NOM-01 INV-09, CA-01 WebhooksService.create + WebhookResponseDto masquage Secret visible 1 fois, masque 4 chars ensuite Integration
TC-NOM-02 INV-08, CA-01, CA-11 RLS policy + filtre org_id dans queries Liste webhooks isolee par tenant Integration
TC-NOM-03 Flux B, INV-13 validateTransition() dans WebhooksService Transitions ACTIVE↔INACTIVE autorisees Unit
TC-NOM-04 Flux C, INV-13 WebhooksService.delete() → status = DELETED DELETED terminal, transitions refusees Unit
TC-NOM-05 INV-02, CA-03 SignatureService.sign() + buildSignatureHeader() Header format t=<unix>,v1=<hex64>, HMAC verificable Unit
TC-NOM-06 Flux E WebhooksService.ping() + variante C payload Ping suit schema variante C Integration
TC-NOM-07 Flux F, CA-09 DeliveryService.replay() Nouvel event_id, original_event_id en DB Integration
TC-NOM-08 Flux G, CA-10, CA-15 WebhooksService.rotateSecret() + relecture DB worker Nouveau secret actif immediatement Integration
TC-NOM-09 CA-07 Sequence de delays constants + BullMQ delay Sequence exacte 1m,5m,15m,1h×7 Integration
TC-NOM-10 CA-13 RateLimiterService ZSET Redis 100 passent, 20 en attente, aucune perte Integration
TC-NOM-11 INV-14 (pre-commit) Transaction PostgreSQL + rollback Aucun artefact persiste apres crash Integration
TC-NOM-12 INV-14 (post-commit) BullMQ persistence Redis + worker restart Livraison rattrapee post-restart Integration
TC-NOM-13 INV-11, CA-12 Snapshot etats probatoires avant/apres Aucun changement probatoire Integration
TC-NOM-14 CA-08 Purge cron job + requete paginee Tentatives >30j purgees, intentions conservees Integration
TC-NOM-15 Variante B buildCanonicalPayload('account.device.revoked') Payload : device_id, user_id, metadata (pas doc_id/hash) Unit
TC-NOM-16 Variante C buildCanonicalPayload('webhook.ping') Payload : user_id, metadata uniquement Unit
TC-NOM-17 §5.2, §5.3 Roundtrip : sign cote serveur, verify cote partenaire simule HMAC(SHA-256(secret_brut), message) == v1 Unit
TC-ERR-01 ERR-01, INV-04 SsrfService.validateUrl() Rejet 400 pour http/ftp/file/data/javascript Unit
TC-ERR-02 ERR-02, CA-02 WebhooksService.create() count check Rejet 409 au 6e webhook Integration
TC-ERR-03 ERR-03 DTO validation IsEnum(EventType) Rejet 400 pour event_type inconnu Unit
TC-ERR-04 ERR-04, INV-08 RLS + guard JWT tenant Rejet 403 cross-tenant Integration
TC-ERR-05 ERR-05 DeliveryService.executeDelivery() 401 partenaire → echec + retry Unit
TC-ERR-06 ERR-06, INV-03 DeliveryService.executeDelivery() 401 anti-rejeu → echec + retry Unit
TC-ERR-07 ERR-07, INV-05 Axios maxRedirects:0 3xx → echec, pas de follow Unit
TC-ERR-08 ERR-08 Axios timeout: 5000 Timeout apres 5s exactes Unit
TC-ERR-09 ERR-09, CA-07 DeliveryService retry counter 10e echec → FAILED terminal Unit
TC-ERR-10 ERR-10, CA-10 Relecture DB secret_sha256 par tentative Post-rotation : nouveau secret utilise Integration
TC-ERR-11 ERR-11, Flux F DeliveryService.replay() checks 404/403/409 selon cas Unit + Integration
TC-ERR-12 ERR-12 buildCanonicalPayload() validation FAILED + PAYLOAD_VALIDATION_ERROR, 0 tentative HTTP Unit
TC-INV-01 INV-01 Schema whitelist strict Aucun champ non-whiteliste dans payload Unit
TC-INV-06 INV-06 Ordre INSERT delivery → enqueue job Timestamp intention < timestamp tentative Integration
TC-INV-07 INV-07 Absence de methodes UPDATE/DELETE sur repository tentatives Mutation refusee Unit
TC-INV-10 INV-10 @CurrentUser('tenant') + absence param client orgId Scope = JWT meme si param forge Integration
TC-INV-12 INV-12 Verification existence evenement source avant intention Rejet si source non journalisee Integration
TC-INV-13 INV-13 validateTransition() matrice Transitions illegales refusees Unit
TC-INV-15 INV-15 SsrfService.resolveAndValidate() IP privees/rebinding bloques Unit
TC-SEC-01 CA-15 crypto.randomBytes(32) + SHA-256 stockage >= 32 bytes, DB = hex64 hash Unit
TC-SEC-02 CA-15 Relecture DB par tentative Pas de cache secret worker Integration
TC-SEC-03 §5.2 SignatureService.sign() determinisme Meme payload + meme t → meme v1 Unit
TC-NR-01 7 event_types + ping Emission payload whiteliste + signature Tous les types couverts Integration
TC-NR-02 Quota 5/org Count + rejet 409 6e rejete Integration
TC-NR-03 Retry 10 tentatives Sequence delays exacte Inchangee Integration
TC-NR-04 RLS inter-tenant Filtre org_id Aucun acces croise Integration
TC-NR-05 Rotation secret UPDATE atomique Ancien invalide immediatement Integration
TC-NR-06 Append-only INSERT-only repository Aucune mutation metier Unit
TC-NR-07 Non-mutation probatoire Isolation module Etats inchanges Integration
TC-NR-08 SSRF protection SsrfService complet Ranges + rebinding bloques Unit
TC-NR-09 Regle HTTP succes Code 2xx → DELIVERED Seuls 2xx reussissent Unit

6. Gestion des erreurs

Code HTTP Cas Mecanisme Observable
400 URL non-https, event_type invalide, format invalide DTO validation pipe + SsrfService.validateUrl() Message d'erreur explicite avec champ en cause
401 JWT absent/invalide OidcJwtAuthGuard (existant) Rejection standard auth
403 Acces cross-tenant RLS + filtre org_id error_code: FORBIDDEN
404 Webhook/evenement introuvable ou purge Service throw NotFoundException error_code: NOT_FOUND
409 Quota depasse (6e webhook), replay sur etat non-eligible (PENDING/IN_PROGRESS/RETRY_SCHEDULED), transition illegale Service throw ConflictException error_code: QUOTA_EXCEEDED ou REPLAY_NOT_ELIGIBLE ou INVALID_TRANSITION
Rate limit depasse (>100 intentions/min/org) RateLimiterService — pas de rejet, mise en attente BullMQ avec delay progressif Aucun 429, CA-13 respecte
500 Erreur interne NestJS exception filter Log complet sans exposition de details internes
503 Redis indisponible (rate limiter fail-closed) RateLimiterService catch Redis error error_code: SERVICE_UNAVAILABLE

Erreurs internes (non-API)

ID Cas Mecanisme Observable
ERR-12 Donnees source incohérentes (doc_id manquant, hash non hex64) buildCanonicalPayload() validation → intention cree en FAILED avec motif PAYLOAD_VALIDATION_ERROR Log ERROR avec contexte, 0 tentative HTTP
ERR-13 SSRF detectee a l'envoi (IP/DNS rebinding) SsrfService.resolveAndValidate() → tentative en echec Log WARN avec IP resolue et motif de blocage

7. Impacts securite

7.1 Surface d'attaque

Risque Mitigation INV/CA
SSRF via callback URL Validation schema + DNS resolution + IP pinning + blocage ranges prives + mitigation DNS rebinding INV-15, CA-14
Exposition secret HMAC Secret brut affiche 1 fois. Stockage SHA-256 uniquement. Masque 4 chars en lecture. INV-09, CA-15
Usurpation tenant org_id derive exclusivement du JWT. Aucun parametre client accepte. INV-10, CA-11
Replay attack reseau Timestamp dans signature. Documentation partenaire pour fenetre ±5 min. INV-03, CA-04
Fuite donnees sensibles dans payload Schema whitelist strict. additionalProperties: false. Construction programmatique. INV-01
Redirection SSRF indirect maxRedirects: 0 sur client HTTP INV-05, CA-06

7.2 Journalisation securite

  • Toute tentative de livraison : URL, code HTTP, duree, resultat SSRF
  • Rotation secret : timestamp, webhook_id, tenant_id (jamais la valeur)
  • Blocage SSRF : IP resolue, motif, URL cible
  • Acces cross-tenant : JWT sub, tenant, ressource ciblee

7.3 Dependances

Dependance Version Usage
axios existant Client HTTP pour livraison (configure no-redirect, timeout)
@nestjs/bull / bullmq existant Queue asynchrone pour livraison et retry
node:crypto runtime CSPRNG, HMAC-SHA256, SHA-256, randomUUID
node:dns/promises runtime Resolution DNS A/AAAA pour SSRF
node:net runtime net.isIP(), validation ranges IP

8. Hypotheses techniques

ID Hypothese Impact si faux
HT-01 Le JWT contient le claim tenant (champ standard du projet) qui correspond au orgId de la spec. Le mapping orgIdtenant est transparent. Rupture INV-10/INV-08. Corriger le claim JWT ou le mapping.
HT-02 Les modules sources (documents, preuves, ancrages) emettent des evenements via EventEmitter2 APRES commit de leur transaction. Violation INV-12. Ajouter l'emission post-commit dans chaque module source.
HT-03 BullMQ/Redis est deja configure dans le projet (module jobs). La queue webhook-delivery sera ajoutee au module existant. Si non disponible, necessaire de configurer Redis + BullMQ from scratch.
HT-04 node:dns/promises est disponible dans le runtime Node.js du projet (>=16). Si non disponible, utiliser dns.resolve4/dns.resolve6 avec callback.
HT-05 Axios est deja disponible comme dependance du projet (utilise par d'autres modules HTTP). Si non installe, ajouter @nestjs/axios + axios.
HT-06 La purge technique >30j est geree par un BullMQ repeatable job (cron) dans le module webhooks. Aucun mecanisme d'infrastructure externe n'est requis. Si PostgreSQL TTL/partitioning est prefere, adapter la purge.
HT-07 Les evenements source (document.created, etc.) sont deja emis par les modules existants. Si non emis, ajouter EventEmitter2.emit() dans chaque module source — hors perimetre PD-180 mais prerequis. Voir H-01 de la spec.

9. Points de vigilance (risques, dette, pieges)

9.1 Race conditions

  • Quota webhook : Deux creations concurrentes pourraient depasser le quota de 5. Mitigation : SELECT ... FOR UPDATE ou UNIQUE INDEX partiel + gestion conflit.
  • Rotation secret pendant livraison : Le worker relit secret_sha256 en DB a chaque tentative. Pas de race condition car chaque tentative est atomique.

9.2 BullMQ

  • Nommage queue : Utiliser webhook-delivery (pas de : — interdit BullMQ v5, cf. learning PD-3).
  • Deprecated API : Utiliser getJobSchedulers() et removeJobScheduler() (pas getRepeatableJobs()/removeRepeatableByKey() — deprecated BullMQ v5).
  • Idempotence worker : Le worker DOIT etre idempotent. Si un job est execute 2 fois (restart), la deuxieme execution ne doit pas creer de doublon de tentative.

9.3 DNS rebinding

  • IP pinning : resoudre le DNS, valider l'IP, puis connecter directement a l'IP resolue (pas de re-resolution par Axios). Si Axios ne supporte pas IP pinning natif, utiliser http.Agent custom avec lookup override.

9.4 Performance

  • Fanout : Un evenement declenche N intentions (1 par webhook abonne). Avec le rate limit 100/min/org, le fanout est borne a 5 webhooks × 20 evenements/min = 100 intentions/min maximum.
  • Redis ZSET : La fenetre glissante ZSET necessite un ZREMRANGEBYSCORE regulier pour eviter la croissance memoire.

9.5 Observabilite (points de verification tests)

  • Exposer les metriques : webhooks actifs par org, tentatives en cours, taux de succes/echec, latence moyenne de livraison.
  • Log structure JSON pour integration avec le systeme d'audit existant (PD-31).
  • Payload exact signe : la colonne signed_payload (ou log structure) sur webhook_delivery_attempts stocke le message exact <t>.<JSON.stringify(payload)> avant signature. Observable pour TC-NOM-05.
  • Headers signes : la colonne signature_header stocke la valeur X-ProbatioVault-Signature envoyee. Observable pour TC-NOM-05.
  • Preuve de relecture DB : chaque tentative log le timestamp de lecture secret_sha256 en DB (colonne secret_read_at ou log). Observable pour TC-SEC-02.
  • SSRF validation : la colonne resolved_ip sur webhook_delivery_attempts stocke l'IP resolue. Observable pour TC-INV-15.
  • Framework de test : Jest (conforme au projet backend existant ProbatioVault-backend).
  • Compatibilite module : CommonJS (coherent avec le setup NestJS existant, tsconfig.json module=commonjs).

9.6 Client HTTP — configuration critique

  • maxRedirects: 0 — indispensable (INV-05)
  • timeout: 5000 — 5 secondes exactes
  • validateStatus: () => true — ne pas throw sur non-2xx, gerer manuellement
  • Pas de retry automatique cote Axios — le retry est gere par BullMQ

9.8 Contraintes techniques

  • Transport tenantId : les evenements EventEmitter2 DOIVENT inclure tenantId (orgId du JWT) dans le payload. Format : { eventType, tenantId, data: { doc_id, hash, user_id, ... } }. Le listener webhook filtre par tenantId pour les requetes DB.
  • Dependances inter-PD : PD-13 (NestJS init) DONE, PD-14 (TypeORM) DONE, PD-15 (schema users) DONE, PD-3 (Redis/BullMQ) DONE, PD-28 (session) DONE, PD-19 (CORS) DONE, PD-31 (audit log) DONE, PD-60 (document upload) DONE, PD-55 (worker ancrage) DONE, PD-41 (PRE) DONE.
  • Variables CI : DATABASE_URL, REDIS_URL, NODE_ENV=test. Pas de variables specifiques additionnelles — les tests d'integration utilisent un PostgreSQL et Redis de test.
  • Limite metadata : validation data.metadata <= 4096 bytes UTF-8 dans le DTO (class-validator @MaxLength sur JSON.stringify).

10. Hors perimetre

  • Webhooks entrants (inbound) : pas de reception de webhooks externes.
  • UI d'administration : gestion API uniquement, pas d'interface graphique.
  • Filtrage avance par metadata : les abonnements filtrent par event_type uniquement.
  • IP allowlist administrable : pas de restriction IP cote partenaire administrable en v1.
  • DLQ visualisation UI : pas de dashboard pour les evenements en echec.
  • Webhooks B2C individuels : uniquement organisationnels (B2B).
  • Transformation de payload : payload envoye tel quel, pas de mapping custom.
  • Evenements batch/bulk : chaque evenement est traite individuellement.
  • Emission des evenements source : les modules existants (documents, preuves, etc.) sont supposes emettre les evenements via EventEmitter2. Si ce n'est pas le cas, l'ajout des emetteurs est un prerequis hors perimetre PD-180 (H-01 spec).

Aucune modification d'autres modules n'est requise pour le CRUD/livraison/retry/journalisation webhook. L'integration avec les modules sources se fait par EventEmitter2 (ecoute passive).


11. Perimetre de test

Niveau de test In scope Hors scope (justification)
Unitaire Tous les composants (C1-C10) : services, DTOs, guards, signature, SSRF, rate limiter, machine d'etats
Integration CRUD webhook + DB, livraison + BullMQ + Redis, RLS cross-tenant, rotation secret + relecture worker, replay + eligibilite, purge >30j
E2E Flux complet creation → evenement → livraison → retry → DELIVERED/FAILED

Tous les niveaux de test sont couverts, aucune exclusion.