PD-251 — Plan d'implémentation¶
1. Phase Go/No-Go¶
| Hypothèse | Vérification | Résultat |
|---|---|---|
| MerkleTreeService (PD-237) | src/modules/merkle/services/merkle-tree.service.ts | GO |
| IntegrityVerifierService (PD-60) | src/modules/upload/services/integrity-verifier.service.ts | GO |
| TsaClientService (PD-39) | src/modules/dual-validation/services/tsa-client.service.ts | GO |
| BlockchainAnchorProcessor (PD-55) | src/modules/anchor/processors/blockchain-anchor.processor.ts | GO |
| HsmService (PD-36/37) | src/modules/crypto/hsm/hsm.service.ts | GO |
| S3Service + CRR (PD-6) | src/modules/storage/s3.service.ts + src/config/storage.config.ts | GO |
| BullMQ/Redis (PD-3/PD-21) | src/modules/jobs/jobs.module.ts | GO |
| Tables integrity | Aucune table dédiée existante | À CRÉER |
Décision : GO — Toutes les dépendances fonctionnelles sont implémentées. Seules les tables de persistance d'intégrité sont à créer via migration TypeORM.
2. Architecture technique¶
2.1 Nouveau module : src/modules/integrity/¶
src/modules/integrity/
├── integrity.module.ts # Module NestJS
├── config/
│ └── integrity.config.ts # Configuration validée (bornes contractuelles)
├── entities/
│ ├── integrity-run.entity.ts # IntegrityRun
│ ├── integrity-check-attempt.entity.ts # IntegrityCheckAttempt
│ ├── forensic-snapshot.entity.ts # ForensicSnapshot
│ ├── restoration-attempt.entity.ts # RestorationAttempt
│ ├── incident-report.entity.ts # IncidentReport
│ ├── archive-version-link.entity.ts # ArchiveVersionLink
│ └── integrity-journal-entry.entity.ts # IntegrityJournalEntry (append-only)
├── enums/
│ ├── archive-integrity-state.enum.ts # HEALTHY|SUSPECT|RESTORE_PENDING|RESTORED|CORRUPTED_CONFIRMED|CORRUPTED_ARCHIVED
│ ├── check-result.enum.ts # OK|KO|INDETERMINATE
│ ├── check-method.enum.ts # PRIMARY_GET|RECONNECT_GET|ALT_HEAD_GET
│ ├── run-status.enum.ts # SUCCESS|PARTIAL|FAILED
│ └── journal-event-type.enum.ts # Tous les types d'événements
├── services/
│ ├── integrity-run-orchestrator.service.ts # Orchestration d'un run complet
│ ├── archive-chain-verifier.service.ts # Vérification 4 maillons
│ ├── double-verification.service.ts # Double vérification avant gel
│ ├── archive-state-machine.service.ts # Machine à états contractuelle
│ ├── forensic-snapshot.service.ts # Phase 2 — snapshot signé HSM
│ ├── restoration.service.ts # Phase 3 — restauration CRR
│ ├── incident-report.service.ts # Phase 4 — rapport JSON+PDF
│ ├── integrity-journal.service.ts # Journal append-only signé
│ ├── priority-scorer.service.ts # Calcul priorityScore
│ ├── integrity-config-validator.service.ts # Validation bornes contractuelles
│ ├── integrity-metrics.service.ts # Métriques Prometheus
│ └── integrity-notifier.service.ts # Abstraction alerting ops + propriétaire
├── processors/
│ ├── periodic-run.processor.ts # Queue integrity.periodic.run
│ ├── archive-verify.processor.ts # Queue integrity.archive.verify
│ ├── archive-restore.processor.ts # Queue integrity.archive.restore
│ ├── incident-report.processor.ts # Queue integrity.incident.report
│ └── reconciliation.processor.ts # Queue integrity.reconciliation
├── controllers/
│ └── integrity.controller.ts # 5 endpoints API
├── dto/
│ ├── create-run.dto.ts
│ ├── run-response.dto.ts
│ ├── run-report.dto.ts
│ ├── incident-response.dto.ts
│ └── latest-report.dto.ts
├── guards/
│ └── archive-access.guard.ts # Blocage accès archives SUSPECT/CORRUPTED
├── interfaces/
│ ├── notifier.interface.ts # Contrat abstraction notifier
│ └── chain-link-result.interface.ts # Résultat vérification par maillon
└── exceptions/
└── integrity.exception.ts # Codes d'erreur métier
2.2 Modifications à d'autres modules¶
| Module existant | Modification | Raison |
|---|---|---|
jobs | Ajouter 5 queues integrity.* dans QUEUE_NAMES | Enregistrement BullMQ |
documents | Intégrer ArchiveAccessGuard sur routes de lecture/export (détail ci-dessous) | INV-251-06 gel effectif |
storage | Ajouter méthode CRR Frankfurt pour restauration | INV-251-08 |
2.2.1 Guard cross-module : routes protégées et jointure inter-schéma (COV-01)¶
Routes du module documents à protéger :
| Route | Controller | Méthode | Effet du guard |
|---|---|---|---|
GET /documents/:id/download | DocumentsController | download() | HTTP 403 si integrity_state ∈ {SUSPECT, CORRUPTED_CONFIRMED, CORRUPTED_ARCHIVED} |
GET /documents/:id/export | DocumentsController | export() | HTTP 403 idem |
GET /documents/:id/content | DocumentsController | getContent() | HTTP 403 idem |
GET /documents/:id/preview | DocumentsController | getPreview() | HTTP 403 idem |
Exception investigation : Les routes GET /integrity/archives/:archiveId/metadata et GET /integrity/incidents/:archiveId du module integrity restent accessibles avec rôle INTEGRITY_INVESTIGATOR ou ADMIN (résolution SEC-01 de Gate 3).
Mécanisme de jointure inter-schéma :
Le guard ArchiveAccessGuard accède à integrity_state via une jointure cross-schéma :
// archive-access.guard.ts
@Injectable()
export class ArchiveAccessGuard implements CanActivate {
constructor(
@InjectRepository(Archive, 'vault_secure')
private readonly archiveRepo: Repository<Archive>,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const archiveId = this.extractArchiveId(request);
if (!archiveId) return true; // pas de document → pas de blocage
// Requête cross-schéma : vault_secure.archives.integrity_state
// La colonne integrity_state est ajoutée sur vault_secure.archives (migration §4.1)
const archive = await this.archiveRepo.findOne({
where: { id: archiveId },
select: ['id', 'integrityState'],
});
if (!archive) return true; // archive non trouvée → laisser le controller gérer 404
const blockedStates = ['SUSPECT', 'CORRUPTED_CONFIRMED', 'CORRUPTED_ARCHIVED'];
if (blockedStates.includes(archive.integrityState)) {
// Vérifier si l'utilisateur a un rôle d'investigation
const user = request.user;
const isInvestigator = user?.roles?.includes('INTEGRITY_INVESTIGATOR') || user?.roles?.includes('ADMIN');
if (!isInvestigator) {
throw new ForbiddenException('ERR_INTEG_ACCESS_BLOCKED');
}
}
return true;
}
}
Résolution de l'archiveId : Le guard extrait archiveId depuis le paramètre de route :id du module documents. La correspondance document.id → archive.id se fait via la table vault_secure.documents (FK archive_id). Si le document n'a pas d'archive associée, le guard laisse passer (pas de vérification d'intégrité).
Enregistrement : Le guard est déclaré comme APP_GUARD dans le DocumentsModule (pas globalement) pour éviter d'impacter les autres modules :
// Dans documents.module.ts — providers à ajouter
{ provide: APP_GUARD, useClass: ArchiveAccessGuard }
2.3 Flux d'un run nominal¶
[Scheduler BullMQ]
│
▼
[periodic-run.processor]
│ 1. Valider config (bornes)
│ 2. Sélectionner scope (rotation/partition)
│ 3. Calculer priorityScore pour chaque archive
│ 4. Trier par priorité décroissante
│ 5. Pour chaque batch de maxParallelChecks :
│ └── Dispatcher jobs → integrity.archive.verify
│ 6. Agréger résultats
│ 7. Produire IntegrityRun + rapport
▼
[archive-verify.processor]
│ 1. Vérifier 4 maillons (SHA3, Merkle, TSA, Blockchain)
│ 2. Si mismatch → double-verification.service
│ └── Tentative 1 : reconnect S3
│ └── Tentative 2 : alt endpoint HEAD+GET
│ 3. Si confirmé → archive-state-machine.service (HEALTHY→SUSPECT)
│ 4. Si SUSPECT → dispatcher jobs phases 2-4
│ 5. Journal append-only chaque tentative
▼
[Si SUSPECT détecté]
│
├── Phase 1 (synchrone dans verify) : SUSPECT + blocage accès
│
├── Phase 2 [forensic-snapshot.service]
│ └── Hash attendu/observé, Merkle proof, TSA, blockchain
│ └── Signature HSM obligatoire
│ └── → RESTORE_PENDING
│
├── Phase 3 [archive-restore.processor]
│ └── Fetch depuis CRR Frankfurt
│ └── Recalcul hash post-restauration
│ └── Si OK → RESTORED + ancienne version CORRUPTED_ARCHIVED
│ └── Si KO après retries → CORRUPTED_CONFIRMED
│
└── Phase 4 [incident-report.processor]
└── JSON RFC 8785 canonicalisé + signé HSM
└── PDF signé
└── Notification ops + propriétaire (conditionnel)
3. Résolution des écarts résiduels Gate 3¶
3.1 AMB-02 — Formule priorityScore¶
Décision : formule linéaire pondérée, configurable.
| Critère | Poids par défaut | Calcul |
|---|---|---|
legalScore (statut juridique) | 0.40 | PROBATORY_SEALED = 1.0, SIGNED = 0.8, CERTIFIED = 0.6, STANDARD = 0.3 |
ageScore (âge de l'archive) | 0.25 | min(1.0, daysSinceLastVerification / scopeRotationWindow_days) |
criticalityScore (classe) | 0.35 | HIGH = 1.0, LOW = 0.4 |
Contraintes : - w_legal + w_age + w_crit = 1.0 (validé au chargement config) - Résultat clampé [0.00, 1.00] - Formule et poids loggés dans le journal du run pour auditabilité
Observable : TC-251-18 vérifie l'ordre de traitement. Le journal du run expose les scores calculés.
3.2 SEC-01 — Opérations d'investigation sur archives SUSPECT¶
Décision : whitelist explicite d'opérations autorisées.
| Opération | Autorisée sur SUSPECT ? | Rôle requis |
|---|---|---|
| GET contenu document (lecture publique) | NON — bloqué | — |
| Export document | NON — bloqué | — |
| GET metadata d'archive (état, dates, hashes) | OUI | INTEGRITY_INVESTIGATOR ou ADMIN |
| GET incidents d'une archive | OUI | INTEGRITY_INVESTIGATOR ou ADMIN |
| GET forensic snapshot | OUI | INTEGRITY_INVESTIGATOR ou ADMIN |
| GET journal d'audit entries pour cette archive | OUI | INTEGRITY_INVESTIGATOR ou ADMIN |
| POST restauration manuelle | OUI | ADMIN uniquement |
Mécanisme : ArchiveAccessGuard vérifie currentState de l'archive. Pour les archives SUSPECT ou CORRUPTED_*, seules les routes d'investigation avec le rôle approprié sont autorisées.
Observable : TC-251-07 vérifie le blocage lecture/export. Le guard est testable unitairement.
3.3 HYP-01 — Batch HSM en incident de masse¶
Décision : file d'attente HSM avec batching et rate limiting.
Mécanisme : 1. Les opérations de signature HSM transitent par une file interne (integrity.hsm.signatureQueue), pas d'appel direct 2. Traitement séquentiel avec maxHsmOpsPerMinute (défaut 30, configurable [10, 60]) 3. En cas de >10 snapshots simultanés : les signatures sont sérialisées, pas rejetées 4. Timeout par opération HSM : 10s (configurable [5, 30]) 5. Si HSM indisponible : les snapshots sont créés sans signature et marqués PENDING_SIGNATURE, puis un job de réconciliation signe le batch quand le HSM revient
Guard de transition SEC-01 (v2) : La transition SUSPECT → RESTORE_PENDING dans archive-state-machine.service vérifie obligatoirement que le snapshot forensique associé a signature_status = 'SIGNED'. Un snapshot PENDING_SIGNATURE bloque la restauration :
// Dans archive-state-machine.service.ts — guard de transition SUSPECT → RESTORE_PENDING
async transitionToRestorePending(archiveId: UUID): Promise<void> {
const snapshot = await this.forensicSnapshotRepo.findOne({
where: { archiveId },
order: { createdAt: 'DESC' },
});
// Guard SEC-01 : snapshot signé obligatoire
if (!snapshot) {
throw new IntegrityException('ERR_INTEG_NO_SNAPSHOT', 'Forensic snapshot required before restoration');
}
if (snapshot.signatureStatus !== 'SIGNED' || !snapshot.hsmSignatureRef) {
throw new IntegrityException('ERR_INTEG_UNSIGNED_SNAPSHOT',
`Snapshot ${snapshot.snapshotId} has signature_status=${snapshot.signatureStatus} — restoration blocked until HSM signature completed`);
}
// Transition autorisée
await this.transition(archiveId, 'SUSPECT', 'RESTORE_PENDING');
}
Conséquence : En cas d'indisponibilité HSM, l'archive reste en SUSPECT (bloquée en lecture) jusqu'à ce que le job de réconciliation HSM signe le snapshot. La restauration ne démarre jamais avec un snapshot non signé, conformément à INV-251-07.
Observable : La file HSM expose des métriques (queue depth, latency). TC-251-08 teste le cas nominal. NR-251-06 teste la dégradation.
4. Migration de données¶
4.1 Nouveau schéma : vault_integrity¶
-- Migration: CreateIntegritySchema
CREATE SCHEMA IF NOT EXISTS vault_integrity;
-- IntegrityRun
CREATE TABLE vault_integrity.integrity_runs (
run_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ended_at TIMESTAMPTZ,
scope_mode VARCHAR(20) NOT NULL, -- 'FULL' | 'PARTITIONED' | 'ROTATIVE'
scope_size INTEGER NOT NULL,
scope_filter JSONB,
status VARCHAR(20) NOT NULL DEFAULT 'RUNNING', -- SUCCESS | PARTIAL | FAILED | RUNNING
total_checked INTEGER NOT NULL DEFAULT 0,
total_ok INTEGER NOT NULL DEFAULT 0,
total_ko INTEGER NOT NULL DEFAULT 0,
total_indeterminate INTEGER NOT NULL DEFAULT 0,
total_suspect INTEGER NOT NULL DEFAULT 0,
triggered_by VARCHAR(20) NOT NULL DEFAULT 'scheduler', -- scheduler | manual
report_json JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- IntegrityCheckAttempt
CREATE TABLE vault_integrity.integrity_check_attempts (
attempt_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
archive_id UUID NOT NULL,
run_id UUID NOT NULL REFERENCES vault_integrity.integrity_runs(run_id),
attempt_no SMALLINT NOT NULL CHECK (attempt_no BETWEEN 1 AND 3),
method VARCHAR(30) NOT NULL, -- PRIMARY_GET | RECONNECT_GET | ALT_HEAD_GET
computed_hash_sha3 VARCHAR(128),
expected_hash_sha3 VARCHAR(128) NOT NULL,
result VARCHAR(20) NOT NULL, -- OK | KO | INDETERMINATE | ERROR
chain_document VARCHAR(20) NOT NULL DEFAULT 'PENDING',
chain_merkle VARCHAR(20) NOT NULL DEFAULT 'PENDING',
chain_tsa VARCHAR(20) NOT NULL DEFAULT 'PENDING',
chain_blockchain VARCHAR(20) NOT NULL DEFAULT 'PENDING',
error_reason TEXT,
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (archive_id, run_id, attempt_no)
);
CREATE INDEX idx_check_attempts_archive ON vault_integrity.integrity_check_attempts(archive_id);
CREATE INDEX idx_check_attempts_run ON vault_integrity.integrity_check_attempts(run_id);
-- ForensicSnapshot
CREATE TABLE vault_integrity.forensic_snapshots (
snapshot_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
archive_id UUID NOT NULL,
expected_hash_sha3 VARCHAR(128) NOT NULL,
observed_hash_sha3 VARCHAR(128) NOT NULL,
merkle_proof_ref JSONB,
tsa_status VARCHAR(20) NOT NULL,
blockchain_status VARCHAR(20) NOT NULL,
hsm_signature_ref VARCHAR(256),
signature_status VARCHAR(20) NOT NULL DEFAULT 'SIGNED', -- SIGNED | PENDING_SIGNATURE
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_forensic_archive ON vault_integrity.forensic_snapshots(archive_id);
-- RestorationAttempt
CREATE TABLE vault_integrity.restoration_attempts (
restoration_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
archive_id UUID NOT NULL,
source VARCHAR(30) NOT NULL DEFAULT 'CRR_FRANKFURT',
attempt_no SMALLINT NOT NULL,
result VARCHAR(20) NOT NULL, -- SUCCESS | FAILED | PENDING
post_restore_hash VARCHAR(128),
error_reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_restoration_archive ON vault_integrity.restoration_attempts(archive_id);
-- IncidentReport
CREATE TABLE vault_integrity.incident_reports (
report_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
archive_id UUID NOT NULL,
json_canonical_ref TEXT NOT NULL, -- path/URL to RFC 8785 JSON
pdf_signed_ref TEXT NOT NULL, -- path/URL to signed PDF
hsm_signature_ref VARCHAR(256) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_incident_archive ON vault_integrity.incident_reports(archive_id);
-- ArchiveVersionLink
CREATE TABLE vault_integrity.archive_version_links (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
from_archive_version_id UUID NOT NULL, -- corrupted version
to_archive_version_id UUID NOT NULL, -- restored version
link_hash VARCHAR(128) NOT NULL, -- SHA3(from || to || timestamp)
anchored_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (from_archive_version_id, to_archive_version_id)
);
-- IntegrityJournalEntry (append-only)
CREATE TABLE vault_integrity.integrity_journal_entries (
entry_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
archive_id UUID NOT NULL,
event_type VARCHAR(50) NOT NULL,
payload JSONB NOT NULL,
payload_digest VARCHAR(128) NOT NULL, -- SHA3 du payload canonicalisé
signature_ref VARCHAR(256),
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_journal_archive ON vault_integrity.integrity_journal_entries(archive_id);
CREATE INDEX idx_journal_timestamp ON vault_integrity.integrity_journal_entries(timestamp);
-- Trigger append-only : interdire UPDATE et DELETE
CREATE OR REPLACE FUNCTION vault_integrity.prevent_modify_journal()
RETURNS TRIGGER AS $$
BEGIN
RAISE EXCEPTION 'integrity_journal_entries is append-only: % not allowed', TG_OP;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_journal_no_update
BEFORE UPDATE ON vault_integrity.integrity_journal_entries
FOR EACH ROW EXECUTE FUNCTION vault_integrity.prevent_modify_journal();
CREATE TRIGGER trg_journal_no_delete
BEFORE DELETE ON vault_integrity.integrity_journal_entries
FOR EACH ROW EXECUTE FUNCTION vault_integrity.prevent_modify_journal();
-- Extension de la table archives existante
-- Ajouter colonne integrity_state si absente
-- ALTER TABLE vault_secure.archives ADD COLUMN IF NOT EXISTS integrity_state VARCHAR(30) NOT NULL DEFAULT 'HEALTHY';
-- ALTER TABLE vault_secure.archives ADD COLUMN IF NOT EXISTS last_integrity_check_at TIMESTAMPTZ;
-- Note: l'existence exacte de la table archives doit être vérifiée en implémentation
4.2 Rétention¶
La table integrity_runs a une rétention de runReportRetentionDays (défaut 3650 jours). Un job de purge (hors périmètre PD-251 sauf création du placeholder) nettoie les runs anciens.
5. Découpage en composants (tâches)¶
TASK-1 : Module Integrity + Config + Entities (fondation)¶
- Créer
integrity.module.tsavec imports nécessaires - Implémenter
integrity.config.tsavec validation des bornes contractuelles - Créer les 7 entités TypeORM
- Créer les enums
- Créer la migration SQL
- INV couverts : INV-251-04 (bornes), INV-251-10 (états terminaux via enum)
- Tests : TC-251-05 (bornes config), TC-251-19 (fréquence/périmètre)
- Fichiers :
src/modules/integrity/integrity.module.ts,entities/,config/,enums/, migration
TASK-2 : Machine à états + Journal append-only (noyau)¶
- Implémenter
archive-state-machine.service.ts— transitions contractuelles strictes - Implémenter
integrity-journal.service.ts— append-only signé, RFC 8785 - INV couverts : INV-251-05, INV-251-09, INV-251-10
- CA couverts : CA-251-08, CA-251-14, CA-251-16
- Tests : TC-251-06 (journal), TC-251-11 (terminaux), TC-251-20 (transitions), TC-251-21 (non-suppression)
- Fichiers :
services/archive-state-machine.service.ts,services/integrity-journal.service.ts
TASK-3 : Vérification 4 maillons + Double vérification (coeur métier)¶
- Implémenter
archive-chain-verifier.service.ts— SHA3, Merkle, TSA, Blockchain - Implémenter
double-verification.service.ts— reconnect S3, alt endpoint, tentatives bornées - Intégrer MerkleTreeService, TsaClientService, BlockchainAdapterService, S3Service
- INV couverts : INV-251-02, INV-251-03, INV-251-04, INV-251-16
- CA couverts : CA-251-02, CA-251-03, CA-251-04
- Tests : TC-251-02, TC-251-03, TC-251-04, TC-251-05, TC-251-17
- Fichiers :
services/archive-chain-verifier.service.ts,services/double-verification.service.ts
TASK-4 : Snapshot forensique + Restauration (phase 2-3)¶
- Implémenter
forensic-snapshot.service.ts— Phase 2, signature HSM obligatoire - Implémenter
restoration.service.ts— Phase 3, CRR Frankfurt, retries, backoff - Implémenter batch HSM (résolution HYP-01)
- INV couverts : INV-251-07, INV-251-08, INV-251-17
- CA couverts : CA-251-05, CA-251-06, CA-251-07, CA-251-11
- Tests : TC-251-08, TC-251-09, TC-251-10, TC-251-15
- Fichiers :
services/forensic-snapshot.service.ts,services/restoration.service.ts
TASK-5 : Rapport incident + Notification (phase 4)¶
- Implémenter
incident-report.service.ts— JSON RFC 8785 + PDF signé HSM - Implémenter
integrity-notifier.service.ts— abstraction ops + propriétaire - INV couverts : INV-251-11, INV-251-12
- CA couverts : CA-251-09, CA-251-10, CA-251-12
- Tests : TC-251-12, TC-251-13
- Fichiers :
services/incident-report.service.ts,services/integrity-notifier.service.ts,interfaces/notifier.interface.ts
TASK-6 : Orchestrateur de run + Priorisation + Processors BullMQ¶
- Implémenter
integrity-run-orchestrator.service.ts— orchestration run complet - Implémenter
priority-scorer.service.ts— formule AMB-02 résolue - Implémenter les 5 processors BullMQ
- Enregistrer les 5 queues dans JobsModule
- INV couverts : INV-251-01, INV-251-13, INV-251-14, INV-251-15
- CA couverts : CA-251-01, CA-251-12, CA-251-13, CA-251-16
- Tests : TC-251-01, TC-251-14, TC-251-16, TC-251-18, TC-251-19
- Fichiers :
services/integrity-run-orchestrator.service.ts,services/priority-scorer.service.ts,processors/
TASK-7 : API Controller + Guard accès + Métriques Prometheus¶
- Implémenter
integrity.controller.ts— 5 endpoints - Implémenter
archive-access.guard.ts— blocage SUSPECT/CORRUPTED (résolution SEC-01) - Implémenter
integrity-metrics.service.ts— Prometheus counters/histograms - Intégrer le guard sur les routes de download/export du module documents
- INV couverts : INV-251-06
- CA couverts : CA-251-15
- Tests : TC-251-07, TC-251-22
- Fichiers :
controllers/,guards/,services/integrity-metrics.service.ts
TASK-8 : Réconciliation Merkle + Tests d'intégration¶
8.1 Mécanisme de réconciliation Merkle (COV-02)¶
Problème résolu : Après un crash pré-commit ou post-commit, certaines opérations synchrones (transition d'état, journal append-only) ont réussi mais les opérations asynchrones (agrégation Merkle, ancrage blockchain) n'ont pas été déclenchées.
Détection des écarts post-crash :
Le ReconciliationProcessor est déclenché de 2 manières : 1. Au démarrage du module (onModuleInit) — rattrapage des événements orphelins au boot 2. Via la queue integrity.reconciliation — déclenché après chaque run complet ou manuellement
Algorithme de réconciliation :
1. SCAN: Lire integrity_journal_entries WHERE event_type IN (
'STATE_TRANSITION', 'FORENSIC_SNAPSHOT_CREATED',
'RESTORATION_COMPLETED', 'VERSION_LINK_CREATED'
) AND timestamp > lastReconciliationTimestamp
2. POUR CHAQUE journal_entry:
a. Extraire archive_id et event_type du payload
b. Vérifier si un leaf Merkle correspondant existe:
- Appeler MerkleTreeService.getLeafByReference(archive_id, entry_id)
- Si leaf absent → événement ORPHELIN
3. POUR CHAQUE événement ORPHELIN:
a. Recalculer le hash SHA3 du payload canonicalisé (RFC 8785)
b. Appeler MerkleTreeService.addLeaf(hash, { archiveId, entryId })
c. Logger dans le journal: MERKLE_RECONCILIATION_APPLIED
d. Si l'événement est un STATE_TRANSITION vers RESTORED:
- Vérifier que l'ancrage blockchain a été déclenché
- Si absent → dispatcher job vers integrity.archive.anchor
4. VÉRIFICATION D'INTÉGRITÉ POST-RÉCONCILIATION:
a. Pour chaque leaf réconcilié, vérifier MerkleTreeService.verifyProof()
b. Si proof invalide → log CRITICAL + alerte ops
c. Mettre à jour lastReconciliationTimestamp
5. RAPPORT: Émettre un événement RECONCILIATION_COMPLETED avec:
- orphans_found: nombre d'événements orphelins détectés
- orphans_reconciled: nombre d'événements rattrapés
- orphans_failed: nombre d'échecs de réconciliation
- duration_ms: durée de la réconciliation
Persistance du checkpoint : lastReconciliationTimestamp est stocké dans la table vault_integrity.integrity_runs (champ last_reconciliation_at ajouté à la migration). Au boot, le processor lit le timestamp du dernier run réussi pour ne scanner que les événements postérieurs.
Gestion de concurrence : Le processor acquiert un lock Redis integrity:reconciliation:lock (TTL 5 min, SETNX simple) avant de scanner. Si le lock est déjà pris, le job est replanifié avec backoff de 30s.
- Implémenter
reconciliation.processor.tsselon l'algorithme ci-dessus - Tests d'intégration end-to-end (run complet)
- Tests de non-régression (NR-251-01 à NR-251-06)
- INV couverts : INV-251-15
- CA couverts : CA-251-16
- Tests : TC-251-16, NR-251-01 à NR-251-06
- Fichiers :
processors/reconciliation.processor.ts, tests e2e
5bis. Diagrammes Mermaid¶
5bis.1 Graphe de dépendances inter-composants¶
graph TD
subgraph "Processors BullMQ"
PRP[periodic-run.processor]
AVP[archive-verify.processor]
ARP[archive-restore.processor]
IRP[incident-report.processor]
RCP[reconciliation.processor]
end
subgraph "Services métier"
IRO[integrity-run-orchestrator.service]
ACV[archive-chain-verifier.service]
DVS[double-verification.service]
ASM[archive-state-machine.service]
FSS[forensic-snapshot.service]
RES[restoration.service]
IRS[incident-report.service]
IJS[integrity-journal.service]
PSS[priority-scorer.service]
ICV[integrity-config-validator.service]
IMS[integrity-metrics.service]
INS[integrity-notifier.service]
end
subgraph "API"
CTL[integrity.controller]
GRD[archive-access.guard]
end
subgraph "Dépendances externes (PD existants)"
MTS[MerkleTreeService<br/>PD-237]
TSA[TsaClientService<br/>PD-39]
HSM[HsmService<br/>PD-36/37]
S3S[S3Service + CRR<br/>PD-6]
BLK[BlockchainAnchorProcessor<br/>PD-55]
end
PRP --> IRO
IRO --> PSS
IRO --> ICV
IRO --> IMS
PRP -->|dispatch| AVP
AVP --> ACV
ACV --> MTS
ACV --> TSA
ACV --> BLK
ACV --> S3S
AVP --> DVS
DVS --> S3S
AVP --> ASM
AVP --> IJS
ASM --> IJS
ASM --> FSS
FSS --> HSM
FSS --> IJS
ARP --> RES
RES --> S3S
RES --> ASM
RES --> IJS
IRP --> IRS
IRS --> HSM
IRP --> INS
RCP --> MTS
RCP --> IJS
CTL --> IRO
CTL --> RES
CTL --> IRS
GRD -.->|cross-module| CTL
style PRP fill:#4a90d9,color:#fff
style AVP fill:#4a90d9,color:#fff
style ARP fill:#4a90d9,color:#fff
style IRP fill:#4a90d9,color:#fff
style RCP fill:#4a90d9,color:#fff
style MTS fill:#7b8d8e,color:#fff
style TSA fill:#7b8d8e,color:#fff
style HSM fill:#7b8d8e,color:#fff
style S3S fill:#7b8d8e,color:#fff
style BLK fill:#7b8d8e,color:#fff 5bis.2 Diagramme de séquence — Run périodique avec détection de corruption¶
sequenceDiagram
participant SCH as BullMQ Scheduler
participant PRP as periodic-run.processor
participant IRO as integrity-run-orchestrator
participant PSS as priority-scorer
participant AVP as archive-verify.processor
participant ACV as archive-chain-verifier
participant DVS as double-verification
participant ASM as archive-state-machine
participant IJS as integrity-journal
participant FSS as forensic-snapshot
participant HSM as HsmService (PD-36)
participant S3 as S3Service (PD-6)
participant MTS as MerkleTreeService (PD-237)
participant TSA as TsaClientService (PD-39)
participant BLK as BlockchainAnchor (PD-55)
SCH->>PRP: trigger (cron)
PRP->>IRO: startRun(config)
IRO->>IRO: acquérir lock Redis
IRO->>PSS: scoreArchives(scope)
PSS-->>IRO: archives triées par priorité
loop Pour chaque batch (maxParallelChecks)
IRO->>AVP: dispatch job(archiveId)
AVP->>ACV: verifyChain(archiveId)
par Vérification 4 maillons
ACV->>S3: getObject(archiveId)
S3-->>ACV: contenu fichier
ACV->>ACV: SHA3-512(contenu) vs hash attendu
ACV->>MTS: getProofByLeaf(archiveId)
MTS-->>ACV: merkle proof
ACV->>TSA: verifyTimestamp(archiveId)
TSA-->>ACV: TSA status
ACV->>BLK: verifyAnchor(archiveId)
BLK-->>ACV: blockchain status
end
ACV-->>AVP: chainResult{doc, merkle, tsa, blockchain}
alt Mismatch détecté (hash KO)
AVP->>DVS: doubleVerify(archiveId)
DVS->>S3: reconnect + GET (tentative 2)
S3-->>DVS: contenu fichier
DVS->>DVS: SHA3-512 recompute
alt Confirmé KO après double vérification
DVS-->>AVP: confirmed SUSPECT
AVP->>ASM: transition(HEALTHY → SUSPECT)
ASM->>IJS: appendEntry(STATE_TRANSITION)
IJS->>IJS: SHA3(payload) + sign HSM
AVP->>FSS: createSnapshot(archiveId)
FSS->>HSM: sign(snapshotData)
HSM-->>FSS: signature
FSS->>IJS: appendEntry(FORENSIC_SNAPSHOT_CREATED)
FSS-->>AVP: snapshot signé
Note over AVP: Dispatch phases 2-3-4 (async)
else Faux positif transitoire
DVS-->>AVP: resolved OK
AVP->>IJS: appendEntry(FALSE_POSITIVE)
end
else Tous maillons OK
AVP->>IJS: appendEntry(CHECK_OK)
end
AVP-->>IRO: checkResult
end
IRO->>IRO: agréger résultats
IRO->>IRO: produire IntegrityRun + rapport
IRO->>IRO: libérer lock Redis
IRO-->>PRP: runReport 5bis.3 Machine à états — Transitions integrity_state¶
stateDiagram-v2
[*] --> HEALTHY
HEALTHY --> SUSPECT : double vérification confirmée<br/>(archive-state-machine)
SUSPECT --> RESTORE_PENDING : snapshot forensique signé HSM<br/>(guard SEC-01 v2)
RESTORE_PENDING --> RESTORED : restauration CRR réussie<br/>(restoration.service)
RESTORE_PENDING --> SUSPECT : CRR indisponible / échec transitoire<br/>(retry ultérieur dans SLA)
RESTORE_PENDING --> CORRUPTED_CONFIRMED : retries max épuisés<br/>(restoration.service)
SUSPECT --> CORRUPTED_CONFIRMED : SLA dépassé sans restauration
CORRUPTED_CONFIRMED --> [*] : état terminal — aucune transition sortante
CORRUPTED_ARCHIVED --> [*] : état terminal — aucune transition sortante
RESTORED --> HEALTHY : prochain run périodique OK<br/>(archive-chain-verifier)
note right of SUSPECT : Accès bloqué (ArchiveAccessGuard)<br/>sauf INTEGRITY_INVESTIGATOR/ADMIN
note right of CORRUPTED_CONFIRMED : Notification propriétaire<br/>+ rapport incident signé HSM
note left of CORRUPTED_ARCHIVED : Ancienne version archivée<br/>après restauration réussie 6. Mapping INV → Mécanismes techniques¶
| Invariant | Mécanisme | Service | Observable |
|---|---|---|---|
| INV-251-01 | BullMQ job scheduler avec getJobSchedulers() | periodic-run.processor | Cron job actif, logs de déclenchement |
| INV-251-02 | archive-chain-verifier.service vérifie 4 maillons, chaque résultat stocké dans IntegrityCheckAttempt | archive-chain-verifier.service | 4 colonnes chain_* dans attempt, statut INDETERMINATE si absent |
| INV-251-03 | double-verification.service : reconnect S3 + alt endpoint, min 2 tentatives avant SUSPECT | double-verification.service | Entries journal avec chaque tentative, transition refusée si attempts < 2 |
| INV-251-04 | Config validée au démarrage : verificationAttemptsMax ∈ [2,3], rejet hors bornes | integrity-config-validator.service | Exception au démarrage si hors bornes, log de validation |
| INV-251-05 | Table integrity_journal_entries avec trigger BEFORE UPDATE/DELETE → RAISE EXCEPTION + signature HSM | integrity-journal.service | Trigger PostgreSQL, signature vérifiable |
| INV-251-06 | ArchiveAccessGuard sur routes GET/export, vérifie integrity_state != SUSPECT/CORRUPTED_* | archive-access.guard | HTTP 403 sur accès interdit, log d'audit |
| INV-251-07 | archive-state-machine.service : précondition snapshot.signature_status = 'SIGNED' ET snapshot.hsm_signature_ref IS NOT NULL avant transition SUSPECT→RESTORE_PENDING (SEC-01 v2) | archive-state-machine.service | Guard dans la machine à états, snapshot signé vérifiable |
| INV-251-08 | restoration.service : 3 issues possibles (RESTORED, SUSPECT, CORRUPTED_CONFIRMED), aucune autre | archive-state-machine.service | Transitions contraintes dans la FSM |
| INV-251-09 | archive-state-machine.service : DELETE interdit sur CORRUPTED_*, guard + trigger SQL | archive-state-machine.service | Tentative DELETE → exception métier ERR_INTEG_DELETE_FORBIDDEN |
| INV-251-10 | FSM : aucune transition sortante depuis CORRUPTED_CONFIRMED/CORRUPTED_ARCHIVED | archive-state-machine.service | Exception TERMINAL_STATE_VIOLATION |
| INV-251-11 | integrity-notifier.service : conditions state === CORRUPTED_CONFIRMED \|\| slaExceeded | integrity-notifier.service | Log de décision notify/skip, mock testable |
| INV-251-12 | incident-report.service : JSON canonicalisé RFC 8785 via json-canonicalize + signature HSM + PDF | incident-report.service | Artefacts en stockage, signature vérifiable |
| INV-251-13 | integrity-run-orchestrator.service : calcul durée run, comparaison SLA, alerte si dépassement | integrity-run-orchestrator.service | Métriques Prometheus, alerte ops tracée |
| INV-251-14 | integrity-run-orchestrator.service : rapport systématique même sans anomalie | integrity-run-orchestrator.service | IntegrityRun.report_json toujours non-null en fin de run |
| INV-251-15 | Transaction PostgreSQL pour les écritures synchrones, journal et Merkle en async post-commit, réconciliation | reconciliation.processor | Run status PARTIAL si async en retard, réconciliation loggée |
| INV-251-16 | Tout catch produit un IntegrityRun.status = PARTIAL/FAILED, alerte ops | integrity-run-orchestrator.service | Aucun run sans statut, métriques Prometheus |
| INV-251-17 | restoration.service crée ArchiveVersionLink + nouvelle archive + ancre blockchain | restoration.service | archive_version_links en base, ancrage vérifiable |
7. Mapping CA → Mécanismes + Tests¶
| CA | Mécanisme | Tâche | Tests |
|---|---|---|---|
| CA-251-01 | BullMQ scheduler + scope rotation | TASK-6 | TC-251-01, TC-251-19 |
| CA-251-02 | archive-chain-verifier.service : 4 résultats explicites | TASK-3 | TC-251-02, TC-251-17 |
| CA-251-03 | double-verification.service : faux positif transitoire → pas de gel | TASK-3 | TC-251-03 |
| CA-251-04 | double-verification.service + archive-state-machine.service : → SUSPECT + blocage | TASK-3 | TC-251-04, TC-251-05, TC-251-07 |
| CA-251-05 | forensic-snapshot.service : précondition snapshot signé HSM | TASK-4 | TC-251-08 |
| CA-251-06 | restoration.service : RESTORED + CORRUPTED_ARCHIVED + ArchiveVersionLink | TASK-4 | TC-251-08, TC-251-09 |
| CA-251-07 | restoration.service : retries max → CORRUPTED_CONFIRMED | TASK-4 | TC-251-10 |
| CA-251-08 | archive-state-machine.service : transitions interdites depuis terminaux | TASK-2 | TC-251-11, TC-251-20 |
| CA-251-09 | incident-report.service : JSON RFC 8785 + PDF signés | TASK-5 | TC-251-13 |
| CA-251-10 | integrity-notifier.service : alerte ops incident + SLA | TASK-5 | TC-251-14 |
| CA-251-11 | integrity-notifier.service : pas de notif propriétaire en SUSPECT/RESTORE_PENDING/RESTORED | TASK-5 | TC-251-15 |
| CA-251-12 | integrity-notifier.service : notif propriétaire si CORRUPTED_CONFIRMED ou > restoreSla | TASK-5 | TC-251-12, TC-251-18 |
| CA-251-13 | integrity-config-validator.service : bornes batch/parallel/timeout/window | TASK-6 | TC-251-19 |
| CA-251-14 | archive-state-machine.service + trigger SQL : suppression refusée | TASK-2 | TC-251-21 |
| CA-251-15 | integrity-metrics.service : 4 familles Prometheus | TASK-7 | TC-251-22 |
| CA-251-16 | Transaction ACID + réconciliation async + FSM stricte | TASK-2, TASK-8 | TC-251-11, TC-251-16, TC-251-20 |
8. Gestion d'erreurs¶
| Erreur | Code | Réaction | Statut run |
|---|---|---|---|
| S3 timeout (lecture document) | ERR_INTEG_S3_TIMEOUT | Retry dans double vérification, INDETERMINATE si tous KO | PARTIAL |
| HSM indisponible | ERR_INTEG_HSM_UNAVAIL | Snapshot créé PENDING_SIGNATURE, job réconciliation | PARTIAL |
| CRR Frankfurt indisponible | ERR_INTEG_CRR_UNAVAIL | RESTORE_PENDING → SUSPECT (retry ultérieur dans SLA) | PARTIAL |
| Merkle proof introuvable | ERR_INTEG_MERKLE_MISS | chain_merkle = INDETERMINATE, alerte ops | PARTIAL |
| TSA timeout | ERR_INTEG_TSA_TIMEOUT | chain_tsa = INDETERMINATE | PARTIAL |
| Blockchain indisponible | ERR_INTEG_CHAIN_DOWN | chain_blockchain = INDETERMINATE | PARTIAL |
| Config hors bornes (rejet) | ERR_INTEG_CONFIG_INVALID | Exception au démarrage, run non lancé | FAILED |
| Config hors bornes (clamp) | — | Clamp silencieux + log warning | — |
| Transition d'état invalide | ERR_INTEG_FSM_VIOLATION | Exception + journal, aucune modification | — |
| Suppression archive corrompue | ERR_INTEG_DELETE_FORBIDDEN | Exception métier + journal audit | — |
| Run timeout dépassé | ERR_INTEG_RUN_TIMEOUT | Arrêt graceful, statut PARTIAL, alerte ops | PARTIAL |
| Accès archive SUSPECT | ERR_INTEG_ACCESS_BLOCKED | HTTP 403 + log audit | — |
9. Contraintes techniques¶
9.1 Dépendances inter-PD¶
| Story | Statut | Nature |
|---|---|---|
| PD-237 (MerkleTreeService) | DONE | Import getProofByLeaf() |
| PD-60 (IntegrityVerifierService) | DONE | Pattern de référence (hash verification) |
| PD-39 (TsaClientService) | DONE | Import verifyTimestamp() |
| PD-55 (BlockchainAnchorProcessor) | DONE | Pattern BullMQ processor, AnchorBatchService |
| PD-36/37 (HsmService) | DONE | Import sign(), verify() |
| PD-6 (CRR Frankfurt) | DONE | Extension S3Service (config CRR region) |
| PD-3/21 (BullMQ) | DONE | Extension QUEUE_NAMES + patterns |
9.2 Framework de test¶
- Runner : Jest (cohérent avec le projet existant)
- Tests unitaires : mocks des services dépendants (HsmService, S3Service, MerkleTreeService, TsaClientService)
- Tests d'intégration : PostgreSQL réel (via
testcontainersou DB de test), Redis réel pour BullMQ - Variables CI :
DATABASE_URL,REDIS_URL,CI=true
9.3 Compatibilité ESM/CJS¶
- Dépendances ESM-only identifiées :
json-canonicalize(RFC 8785) — vérifier si ESM-only - Si ESM-only : utiliser
import()dynamique ou wrapper CJS - Runner : Jest avec
ts-jest(configuration existante du projet)
9.4 Dépendance npm additionnelle¶
json-canonicalize: canonicalisation RFC 8785 (si pas déjà installé)pdfkitou@react-pdf/renderer: génération PDF (vérifier existant)prom-client: métriques Prometheus (vérifier existant)
10. Sécurité¶
10.1 Rate limiting (SEC-02 v2)¶
opsRateLimit= 60 op/min [10, 300] — appliqué sur les endpoints suivants :POST /integrity/runs— déclenchement run manuelPOST /integrity/archives/:id/restore— restauration manuellePOST /integrity/reconciliation— déclenchement réconciliation manuelle- Mécanisme :
ThrottlerGuardNestJS avec décorateur@Throttle(opsRateLimit, 60)sur chaque endpoint ops - Scope : par utilisateur (IP + userId) — un admin ne peut pas saturer le système même avec un token valide
- Refus au-delà du seuil : HTTP 429 + log audit
10.2 Canonicalisation RFC 8785¶
- Toute donnée JSON signée via HSM DOIT être canonicalisée avec
json-canonicalizeavant signature - Points d'application : journal entries, incident reports, forensic snapshots
- Learning PD-40 : ne jamais signer du JSON non canonicalisé
10.3 HSM¶
- Labels de test :
pv-test-integrity-*(prefixepv-test-obligatoire) - Labels de production :
pv-master-integrity-signing-YYYY - Utiliser
crypto.randomUUID()pour tous les identifiants (learning PD-63)
10.4 RLS¶
- Les tables
vault_integrity.*n'ont pas de RLS car elles sont accessibles uniquement via le service NestJS (pas d'accès direct utilisateur) - Le guard applicatif (
ArchiveAccessGuard) assure le contrôle d'accès
11. Hypothèses¶
- La table
archivesexiste dansvault_secureavec un champ identifiant (UUID) utilisable comme foreign key. Si absente, un stub avec TODO tracé sera créé. - Le service
S3Servicepeut être configuré pour accéder à la région CRR Frankfurt (OVH GRA + FRA). - Le module
crypto/hsmexpose une méthodesign(data: Buffer, keyLabel: string): Promise<Buffer>utilisable directement. prom-clientest déjà installé dans le projet (utilisé par d'autres modules de monitoring).- La génération PDF utilise une librairie existante du projet (sinon
pdfkitsera ajouté). - Les archives ont un champ
owner_idpermettant de résoudre le propriétaire pour notification.
12. Points de vigilance¶
- BullMQ v5 : utiliser
getJobSchedulers()etremoveJobScheduler(), pas les méthodes deprecated. - PostgreSQL index partiels : pas de subquery dans la clause WHERE (learning PD-55).
- Trigger append-only : le trigger SQL sur
integrity_journal_entriesbloque UPDATE/DELETE mais pas les requêtes ORM — vérifier que TypeORM ne fait pas de soft-delete. - Crash post-commit : le processor
reconciliationdoit être lancé au démarrage du module pour rattraper les événements orphelins. - SLA mixité unités : stocker les SLA en millisecondes en interne, convertir à l'affichage uniquement.
- Concurrence runs : un seul run actif à la fois via lock Redis distribué (
integrity:run:lock). Mécanisme :SETNXsimple avec TTL derunTimeoutMinutes + 5min(marge de sécurité). Si le lock est déjà acquis, le nouveau run est rejeté (pas d'attente) avec statutFAILEDet codeERR_INTEG_CONCURRENT_RUN. Le lock est libéré explicitement en fin de run (SUCCESS/PARTIAL/FAILED) et expire automatiquement par TTL en cas de crash. - Idempotence : chaque processor BullMQ doit être idempotent (reprocesser un job ne doit pas créer de doublons).