Aller au contenu

PD-46 — Décomposition en tâches agents

Story : Implémenter download sécurisé avec pre-signed URLs Date : 2026-02-11 Branche : feature/PD-46-secure-download


Contexte existant

Module existant Statut Impact
modules/storage/s3.service.ts TODOs, méthodes stub Étendre avec getSignedUrl()
modules/audit/audit.service.ts Fonctionnel (logEvent()) Utiliser directement
modules/documents/ Existe Ajouter sous-module download/

Tâches par agent

# Agent Module Fichier(s) Contract Dépendances
1 agent-developer download-dto download.dto.ts DownloadResponseDto -
2 agent-developer download-errors download.errors.ts DownloadErrorCode, DownloadError -
3 agent-developer share-repository share.repository.ts (extension) findActiveShare() -
4 agent-sre s3-presign-service s3-presign.service.ts S3PresignService.getSignedUrl() @aws-sdk/s3-request-presigner
5 agent-developer audit-download-service audit-download.service.ts AuditDownloadService audit.service.ts
6 agent-developer document-access-guard document-access.guard.ts DocumentAccessGuard share-repository, download-errors
7 agent-developer download-service download.service.ts DownloadService s3-presign, audit-download
8 agent-developer download-controller download.controller.ts DownloadController download-service, guard
9 agent-qa-unit-integration download-tests __tests__/*.spec.ts TC-NOM-, TC-ERR-, TC-INV-* Tous modules

Détail des tâches

Tâche 1 — download-dto (agent-developer)

Fichier : src/modules/documents/download/download.dto.ts

Contract :

export class DownloadResponseDto {
  @ApiProperty({ description: 'Pre-signed URL pour téléchargement direct' })
  downloadUrl: string;

  @ApiProperty({ description: 'Date d\'expiration de l\'URL (UTC)' })
  expiresAt: Date;
}

Invariants : - Critères : CA-46-01, CA-46-07


Tâche 2 — download-errors (agent-developer)

Fichier : src/modules/documents/download/download.errors.ts

Contract :

export enum DownloadErrorCode {
  UNAUTHORIZED = 'ERR-46-001',
  FORBIDDEN = 'ERR-46-002',
  SHARE_REVOKED = 'ERR-46-003',
  TENANT_MISMATCH = 'ERR-46-004',
  INSUFFICIENT_ROLE = 'ERR-46-005',
  NOT_FOUND = 'ERR-46-006',
  S3_ERROR = 'ERR-46-007',
  AUDIT_ERROR = 'ERR-46-008',
}

export class DownloadError extends Error {
  constructor(
    public readonly code: DownloadErrorCode,
    message: string,
    public readonly httpStatus: number,
  ) {
    super(message);
  }
}

Invariants : - Critères : CA-46-03


Tâche 3 — share-repository (agent-developer)

Fichier : src/modules/shares/share.repository.ts (extension ou création)

Contract :

async findActiveShare(
  userId: string,
  documentId: string,
): Promise<Share | null> {
  // SELECT * FROM shares
  // WHERE user_id = $1 AND document_id = $2
  //   AND is_active = true
  //   AND (expires_at IS NULL OR expires_at > NOW())
  //   AND revoked_at IS NULL
}

Invariants : INV-46-02 Critères : CA-46-02, CA-46-04

Note : Vérifier si modules/shares/ existe, sinon vérifier la structure des partages dans documents.


Tâche 4 — s3-presign-service (agent-sre)

Fichier : src/modules/documents/storage/s3-presign.service.ts

Contract :

@Injectable()
export class S3PresignService {
  private readonly s3Client: S3Client;
  private readonly bucket: string = 'probatiovault-documents';

  constructor(private readonly configService: ConfigService) {
    // Initialiser S3Client avec credentials OVH (depuis ConfigService)
    // Endpoint: s3.gra.perf.cloud.ovh.net
  }

  async getSignedUrl(
    key: string,
    options: { expiresIn: number } = { expiresIn: 300 },
  ): Promise<string> {
    // Utiliser @aws-sdk/s3-request-presigner
    // getSignedUrl(s3Client, new GetObjectCommand({Bucket, Key}), {expiresIn})
    // IMPORTANT: Ne lit PAS le contenu, génère seulement l'URL
  }
}

Invariants : INV-46-01, INV-46-06 Critères : CA-46-07, CA-46-09

Dépendances npm : @aws-sdk/client-s3, @aws-sdk/s3-request-presigner


Tâche 5 — audit-download-service (agent-developer)

Fichier : src/modules/documents/audit/audit-download.service.ts

Contract :

@Injectable()
export class AuditDownloadService {
  constructor(private readonly auditService: AuditService) {}

  async logDownloadSuccess(
    userId: string,
    documentId: string,
    tenantId?: string,
  ): Promise<void> {
    // Event: DOWNLOAD_SUCCESS
    // Utiliser auditService.logEvent()
  }

  async logDownloadDenied(
    userId: string,
    documentId: string,
    reason: string,
    tenantId?: string,
  ): Promise<void> {
    // Event: DOWNLOAD_DENIED
    // Utiliser auditService.logEvent()
  }
}

Invariants : INV-46-04, INV-46-05 Critères : CA-46-05, CA-46-06


Tâche 6 — document-access-guard (agent-developer)

Fichier : src/modules/documents/download/guards/document-access.guard.ts

Contract :

@Injectable()
export class DocumentAccessGuard implements CanActivate {
  constructor(
    private readonly shareRepo: ShareRepository,
    private readonly documentRepo: DocumentRepository,
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    // 1. Extraire userId du JWT (request.user)
    // 2. Extraire documentId des params
    // 3. Vérifier ownership (document.ownerId === userId)
    // 4. Si non owner: vérifier partage actif via shareRepo.findActiveShare()
    // 5. Pour B2B (OIDC): validateTenant + validateRole
    // 6. Throw DownloadError si refusé
  }
}

Invariants : INV-46-01, INV-46-02 Critères : CA-46-01, CA-46-02, CA-46-03, CA-46-04


Tâche 7 — download-service (agent-developer)

Fichier : src/modules/documents/download/download.service.ts

Contract :

@Injectable()
export class DownloadService {
  constructor(
    private readonly s3PresignService: S3PresignService,
    private readonly auditService: AuditDownloadService,
    private readonly documentRepo: DocumentRepository,
  ) {}

  async generateDownloadUrl(
    documentId: string,
    user: UserContext,
  ): Promise<{ url: string; expiresAt: Date }> {
    // 1. Récupérer document (s3_key)
    // 2. Appeler s3PresignService.getSignedUrl(key, {expiresIn: 300})
    // 3. Logger via auditService.logDownloadSuccess()
    // 4. Retourner {url, expiresAt: now + 5min}
  }
}

Invariants : INV-46-01, INV-46-04, INV-46-05 Critères : CA-46-05, CA-46-06, CA-46-07


Tâche 8 — download-controller (agent-developer)

Fichier : src/modules/documents/download/download.controller.ts

Contract :

@Controller('documents')
@UseGuards(JwtAuthGuard, DocumentAccessGuard)
export class DownloadController {
  constructor(private readonly downloadService: DownloadService) {}

  @Get(':id/download')
  @HttpCode(HttpStatus.OK)
  async getDownloadUrl(
    @Param('id', ParseUUIDPipe) documentId: string,
    @CurrentUser() user: UserContext,
  ): Promise<DownloadResponseDto> {
    const result = await this.downloadService.generateDownloadUrl(documentId, user);
    return {
      downloadUrl: result.url,
      expiresAt: result.expiresAt,
    };
  }
}

Invariants : INV-46-01, INV-46-06 Critères : CA-46-01, CA-46-02, CA-46-03


Tâche 9 — download-tests (agent-qa-unit-integration)

Fichiers : - src/modules/documents/download/__tests__/download.controller.spec.ts - src/modules/documents/download/__tests__/download.service.spec.ts - src/modules/documents/download/__tests__/document-access.guard.spec.ts

Tests à couvrir : - TC-NOM-01, TC-NOM-02, TC-NOM-03 (flux nominaux) - TC-ERR-01 à TC-ERR-06 (erreurs) - TC-INV-01, TC-INV-02, TC-INV-04, TC-INV-05 (invariants)

Invariants : Tous (INV-46-01 à 06) Critères : Tous (CA-46-01 à 09)


Ordre d'exécution

Phase 1 (parallèle): Tâches 1, 2
Phase 2: Tâche 3
Phase 3: Tâche 4
Phase 4: Tâche 5
Phase 5: Tâche 6
Phase 6: Tâche 7
Phase 7: Tâche 8
Phase 8: Tâche 9

Points d'attention

Point Impact Action
Module shares inexistant Tâche 3 Vérifier structure actuelle, adapter
S3Service existant stub Tâche 4 Créer nouveau service dédié presign
DocumentRepository existant Tâches 6, 7 Identifier et importer
JwtAuthGuard existant Tâche 8 Identifier et importer
UserContext existant Tâches 7, 8 Identifier type/decorator

Généré par : Claude Opus 4.5 Date : 2026-02-11