PD-46 — Plan d'implémentation
Story : Implémenter download sécurisé avec pre-signed URLs Date : 2026-02-11 Auteur : Claude Opus 4.5
1. Découpage en composants
| Composant | Responsabilité | Projet | Agent |
| download-controller | Endpoint GET /documents/:id/download | backend | agent-developer |
| download-service | Orchestration vérification droits + génération URL | backend | agent-developer |
| document-access-guard | Guard unifié pour vérification droits (owner/share/B2B) | backend | agent-developer |
| s3-presign-service | Génération pre-signed URLs OVH S3 | backend | agent-sre |
| audit-download-service | Journalisation DOWNLOAD_SUCCESS/DENIED | backend | agent-developer |
| share-repository | Accès aux partages (is_active, expires_at, revoked_at) | backend | agent-developer |
| download.dto | DTOs pour requête/réponse | backend | agent-developer |
| download.errors | Codes d'erreur ERR-46-* | backend | agent-developer |
| download.tests | Tests unitaires et d'intégration | backend | agent-qa-unit-integration |
2. Flux techniques
Flux 1 — Coffre Personnel (Owner)
sequenceDiagram
participant Client
participant Controller
participant Guard
participant Service
participant S3
participant Audit
Client->>Controller: GET /documents/:id/download (JWT)
Controller->>Guard: canActivate(request)
Guard->>Guard: extractUserId(JWT)
Guard->>Guard: findDocument(id)
Guard->>Guard: checkOwnership(userId, document.ownerId)
Guard-->>Controller: true
Controller->>Service: generateDownloadUrl(documentId, userId)
Service->>S3: getSignedUrl(bucket, key, {expiresIn: 300})
S3-->>Service: presignedUrl
Service-->>Controller: {url, expiresAt}
Controller-->>Client: 200 {downloadUrl, expiresAt}
Client->>S3: GET presignedUrl
S3-->>Client: document (stream)
S3->>Audit: S3 Event (objectAccessed)
Audit->>Audit: logDownloadSuccess(userId, documentId)
Flux 2 — Coffre Personnel (Partage actif)
sequenceDiagram
participant Client
participant Guard
participant ShareRepo
participant Service
Client->>Guard: GET /documents/:id/download (JWT)
Guard->>Guard: checkOwnership() -> false
Guard->>ShareRepo: findActiveShare(userId, documentId)
ShareRepo-->>Guard: Share {is_active, expires_at, revoked_at}
Guard->>Guard: validateShare(share)
alt Share invalide
Guard-->>Client: 403 SHARE_REVOKED
else Share valide
Guard-->>Service: proceed
end
Flux 3 — Coffre Entreprise (B2B OIDC)
sequenceDiagram
participant Partner
participant Guard
participant Service
Partner->>Guard: GET /documents/:id/download (OIDC)
Guard->>Guard: extractOIDCClaims(token)
Guard->>Guard: validateTenant(token.tenant_id, document.tenant_id)
Guard->>Guard: validateRole(token.roles, 'document:download')
alt Tenant/Role invalide
Guard-->>Partner: 403 TENANT_MISMATCH / INSUFFICIENT_ROLE
else Valide
Guard-->>Service: proceed with tenant_id
end
3. Mapping invariants → mécanismes
| Invariant ID | Exigence | Mécanisme | Composant | Observable | Risque |
| INV-46-01 | TTL + droits au moment requête | TTL=300s dans getSignedUrl + Guard avant génération | s3-presign-service, document-access-guard | X-Amz-Expires=300, Guard rejette si droits révoqués | Faible |
| INV-46-02 | Révocation bloque nouvelles requêtes | Guard vérifie share.revoked_at IS NULL | document-access-guard | HTTP 403 SHARE_REVOKED | Faible |
| INV-46-03 | Téléchargement en cours non interrompu | Pre-signed URL valide indépendamment du backend | s3-presign-service | URL reste valide si obtenue avant révocation | Accepté |
| INV-46-04 | Journalisation append-only | S3 Events → Lambda → AuditService | audit-download-service | Event DOWNLOAD_SUCCESS/DENIED dans registre | Moyen (dépend S3 Events) |
| INV-46-05 | Attribution acteur + PM | userId + tenant_id dans event | audit-download-service | Champs obligatoires dans event audit | Faible |
| INV-46-06 | Zero-Knowledge, aucun GetObject backend | getSignedUrl() ne lit pas le fichier | s3-presign-service | Aucun GetObject dans CloudTrail backend | Faible |
4. Mapping critères d'acceptation → mécanismes
| Critère ID | Mécanisme(s) | Composant | Observable | Risque |
| CA-46-01 | Guard owner check + presign | document-access-guard, s3-presign-service | HTTP 200 + URL valide | Faible |
| CA-46-02 | Guard share check + presign | document-access-guard, share-repository | HTTP 200 + URL valide | Faible |
| CA-46-03 | Guard reject + error code | document-access-guard | HTTP 403 FORBIDDEN | Faible |
| CA-46-04 | Guard check before presign | document-access-guard | Rejet si droits révoqués entre génération et download | Moyen |
| CA-46-05 | S3 Events → AuditService | audit-download-service | Event avec user_id, document_id, timestamp | Moyen |
| CA-46-06 | S3 Events → AuditService (denied) | audit-download-service | Event DOWNLOAD_DENIED avec raison | Moyen |
| CA-46-07 | expiresIn: 300 dans getSignedUrl | s3-presign-service | X-Amz-Expires=300 | Faible |
| CA-46-08 | URL indépendante après génération | Architecture pre-signed | Download continue | Accepté |
| CA-46-09 | getSignedUrl() sans GetObject | s3-presign-service | Aucun proxy/buffering | Faible |
5. Mapping tests (TC-*) → mécanismes + observables
| Test ID | Référence spec | Mécanisme(s) | Point(s) d'observation | Niveau test |
| TC-NOM-01 | CA-46-01 | Guard + presign | HTTP 200, URL format | Integration |
| TC-NOM-02 | CA-46-02 | Guard + ShareRepo | HTTP 200, share check | Integration |
| TC-NOM-03 | CA-46-01 (B2B) | Guard + OIDC claims | HTTP 200, tenant_id log | Integration |
| TC-ERR-01 | HTTP 401 | JwtAuthGuard | HTTP 401 UNAUTHORIZED | Unit |
| TC-ERR-02 | HTTP 403 | Guard ownership | HTTP 403 FORBIDDEN | Unit |
| TC-ERR-03 | HTTP 403 | Guard share.revoked_at | HTTP 403 SHARE_REVOKED | Unit |
| TC-ERR-04 | HTTP 403 | Guard tenant mismatch | HTTP 403 TENANT_MISMATCH | Unit |
| TC-ERR-05 | HTTP 403 | Guard role check | HTTP 403 INSUFFICIENT_ROLE | Unit |
| TC-ERR-06 | HTTP 404 | Document not found | HTTP 404 NOT_FOUND | Unit |
| TC-ERR-07 | HTTP 410 | S3 URL expiration | S3 AccessDenied | E2E |
| TC-INV-01 | INV-46-01 | Guard timing | Rejet après révocation | Integration |
| TC-INV-02 | INV-46-02 | Guard share check | Rejet immédiat | Unit |
| TC-INV-03 | INV-46-03 | URL independance | Download continue | E2E |
| TC-INV-04 | INV-46-04 | AuditService | Event dans registre | Integration |
| TC-INV-05 | INV-46-05 | AuditService fields | user_id + tenant_id | Unit |
| TC-ZK-01 | INV-46-06 | presign sans GetObject | CloudTrail analysis | E2E/Sec |
6. Gestion des erreurs
| Code | Erreur | Condition | Observable |
| ERR-46-001 | UNAUTHORIZED | Token JWT invalide/expiré | HTTP 401 |
| ERR-46-002 | FORBIDDEN | Non owner + pas de partage | HTTP 403 |
| ERR-46-003 | SHARE_REVOKED | Partage révoqué ou expiré | HTTP 403 |
| ERR-46-004 | TENANT_MISMATCH | Tenant OIDC != document tenant | HTTP 403 |
| ERR-46-005 | INSUFFICIENT_ROLE | Rôle sans permission download | HTTP 403 |
| ERR-46-006 | NOT_FOUND | Document inexistant | HTTP 404 |
| ERR-46-007 | S3_ERROR | Erreur génération URL | HTTP 500 |
| ERR-46-008 | AUDIT_ERROR | Erreur journalisation (non bloquant) | Log warning |
7. Impacts sécurité
| Risque | Mitigation | Observabilité |
| URL partagée publiquement | TTL 5 minutes limite l'exposition | CloudTrail S3 access logs |
| Révocation non immédiate | Fenêtre résiduelle documentée et acceptée | Audit log de révocation |
| Contournement Zero-Knowledge | Backend utilise uniquement getSignedUrl, jamais GetObject | CloudTrail analysis |
| Token OIDC falsifié | Validation signature Keycloak | Logs auth |
| Escalade de privilèges B2B | Vérification tenant_id strict | Audit tenant mismatch |
Conformité : - RGPD : Minimisation (pas de log du contenu, seulement métadonnées) - NF Z42-013 : Journalisation append-only - Zero-Knowledge : Aucune lecture serveur
8. Hypothèses techniques
| ID | Hypothèse | Impact si faux |
| H-IMPL-01 | OVH Object Storage supporte getSignedUrl avec TTL | Vérifier SDK AWS S3 compatible |
| H-IMPL-02 | S3 Events disponibles sur OVH (ou polling CloudTrail) | Journalisation retardée si événements non disponibles |
| H-IMPL-03 | Table documents existe avec owner_id, s3_key, tenant_id | Adapter le schéma |
| H-IMPL-04 | Table shares existe avec is_active, expires_at, revoked_at | Adapter le schéma |
| H-IMPL-05 | Service AuditService existant avec méthode logEvent() | Créer si inexistant |
| H-IMPL-06 | Keycloak expose tenant_id et roles dans token OIDC | Configurer mapper Keycloak |
9. Points de vigilance (risques, dette, pièges)
| Point | Description | Action |
| S3 Events OVH | OVH Object Storage peut ne pas supporter tous les événements S3 natifs | Vérifier documentation OVH, prévoir fallback CloudTrail polling |
| Timing révocation | Fenêtre résiduelle entre génération URL et téléchargement | Documenté et accepté par PO |
| Tests E2E | Nécessitent bucket S3 réel | Prévoir environnement de test avec bucket dédié |
| Performance | getSignedUrl est synchrone et rapide (<10ms) | Pas de cache nécessaire |
| Rate limiting | Non inclus dans cette story | Prévoir story dédiée si abus détectés |
10. Hors périmètre
- Resume partiel (Range requests)
- Download progress tracking
- Quota de téléchargement
- Notification de téléchargement
- Téléchargement batch (ZIP)
- Single-use URLs
- Restriction IP
- Support Glacier (restauration asynchrone)
Architecture cible
src/modules/documents/
├── download/
│ ├── download.controller.ts # Endpoint GET /documents/:id/download
│ ├── download.service.ts # Orchestration
│ ├── download.dto.ts # DownloadResponseDto
│ ├── download.errors.ts # ERR-46-* codes
│ ├── guards/
│ │ └── document-access.guard.ts # Vérification owner/share/tenant/role
│ └── __tests__/
│ ├── download.controller.spec.ts
│ ├── download.service.spec.ts
│ └── document-access.guard.spec.ts
├── storage/
│ └── s3-presign.service.ts # Génération pre-signed URLs
└── audit/
└── audit-download.service.ts # Journalisation événements
# Extensions modules existants
src/modules/shares/
└── share.repository.ts # findActiveShare(userId, documentId)
Généré par : Claude Opus 4.5 Date : 2026-02-11 Statut : En attente de review (Gate 5)