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 orgId → tenant 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 UPDATEouUNIQUE INDEXpartiel + gestion conflit. - Rotation secret pendant livraison : Le worker relit
secret_sha256en 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()etremoveJobScheduler()(pasgetRepeatableJobs()/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.Agentcustom aveclookupoverride.
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
ZREMRANGEBYSCOREregulier 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) surwebhook_delivery_attemptsstocke le message exact<t>.<JSON.stringify(payload)>avant signature. Observable pour TC-NOM-05. - Headers signes : la colonne
signature_headerstocke la valeurX-ProbatioVault-Signatureenvoyee. Observable pour TC-NOM-05. - Preuve de relecture DB : chaque tentative log le timestamp de lecture
secret_sha256en DB (colonnesecret_read_atou log). Observable pour TC-SEC-02. - SSRF validation : la colonne
resolved_ipsurwebhook_delivery_attemptsstocke 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.jsonmodule=commonjs).
9.6 Client HTTP — configuration critique¶
maxRedirects: 0— indispensable (INV-05)timeout: 5000— 5 secondes exactesvalidateStatus: () => 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 partenantIdpour 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@MaxLengthsur 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_typeuniquement. - 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.