feat: Audit trail pour actions sensibles

Story 1.7 - Implémente un système complet d'audit trail pour tracer
toutes les actions sensibles (authentification, modifications de données,
exports) avec immuabilité garantie par PostgreSQL.

Fonctionnalités principales:
- Table audit_log append-only avec contraintes PostgreSQL (RULE)
- AuditLogger centralisé avec injection automatique du contexte
- Correlation ID pour traçabilité distribuée (HTTP + async)
- Handlers pour événements d'authentification
- Commande d'archivage des logs anciens
- Pas de PII dans les logs (emails/IPs hashés)

Infrastructure:
- Middlewares Messenger pour propagation du Correlation ID
- HTTP middleware pour génération/propagation du Correlation ID
- Support multi-tenant avec TenantResolver
This commit is contained in:
2026-02-04 00:11:58 +01:00
parent b823479658
commit 2ed60fdcc1
38 changed files with 4179 additions and 81 deletions

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Migration pour la table audit_log append-only.
*
* @see Story 1.7 - T1: Schema Event Store
* @see FR90: Tracage actions sensibles
* @see NFR-S7: Audit trail immutable
*/
final class Version20260203100000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create audit_log table with append-only constraints (Story 1.7)';
}
public function up(Schema $schema): void
{
// T1.1 & T1.2: Create audit_log table
$this->addSql(<<<'SQL'
CREATE TABLE audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
aggregate_type VARCHAR(255) NOT NULL,
aggregate_id UUID,
event_type VARCHAR(255) NOT NULL,
payload JSONB NOT NULL,
metadata JSONB NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
sequence_number BIGSERIAL NOT NULL
)
SQL);
// T1.4: Indexes for efficient querying
$this->addSql("CREATE INDEX idx_audit_tenant ON audit_log((metadata->>'tenant_id'))");
$this->addSql("CREATE INDEX idx_audit_user ON audit_log((metadata->>'user_id'))");
$this->addSql('CREATE INDEX idx_audit_occurred ON audit_log(occurred_at)');
$this->addSql('CREATE INDEX idx_audit_aggregate ON audit_log(aggregate_type, aggregate_id)');
$this->addSql("CREATE INDEX idx_audit_correlation ON audit_log((metadata->>'correlation_id'))");
$this->addSql('CREATE INDEX idx_audit_event_type ON audit_log(event_type)');
// T1.3: Append-only constraints - prevent UPDATE and DELETE
$this->addSql('CREATE RULE audit_no_update AS ON UPDATE TO audit_log DO INSTEAD NOTHING');
$this->addSql('CREATE RULE audit_no_delete AS ON DELETE TO audit_log DO INSTEAD NOTHING');
// Add comment to document the immutability constraint
$this->addSql("COMMENT ON TABLE audit_log IS 'Append-only audit log table. UPDATE and DELETE operations are disabled via PostgreSQL rules for immutability (NFR-S7).'");
}
public function down(Schema $schema): void
{
// Drop rules first
$this->addSql('DROP RULE IF EXISTS audit_no_update ON audit_log');
$this->addSql('DROP RULE IF EXISTS audit_no_delete ON audit_log');
// Drop table (indexes are dropped automatically)
$this->addSql('DROP TABLE IF EXISTS audit_log');
}
}

View File

@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Migration pour la table audit_log_archive et la fonction d'archivage.
*
* @see Story 1.7 - T8: Archivage
* @see NFR-C5: Duree conservation 5 ans actif, puis archive
*/
final class Version20260203100001 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create audit_log_archive table and archival function (Story 1.7)';
}
public function up(Schema $schema): void
{
// T8.1: Create archive table with same structure as audit_log
$this->addSql(<<<'SQL'
CREATE TABLE audit_log_archive (
id UUID PRIMARY KEY,
aggregate_type VARCHAR(255) NOT NULL,
aggregate_id UUID,
event_type VARCHAR(255) NOT NULL,
payload JSONB NOT NULL,
metadata JSONB NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL,
sequence_number BIGINT NOT NULL,
archived_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
SQL);
// Indexes for archive queries (less aggressive than active table)
// Note: No event_type index on archive - less frequent queries, trade space for insert speed
$this->addSql("CREATE INDEX idx_audit_archive_tenant ON audit_log_archive((metadata->>'tenant_id'))");
$this->addSql('CREATE INDEX idx_audit_archive_occurred ON audit_log_archive(occurred_at)');
$this->addSql('CREATE INDEX idx_audit_archive_archived ON audit_log_archive(archived_at)');
// Archive is also append-only (immutable)
$this->addSql('CREATE RULE audit_archive_no_update AS ON UPDATE TO audit_log_archive DO INSTEAD NOTHING');
$this->addSql('CREATE RULE audit_archive_no_delete AS ON DELETE TO audit_log_archive DO INSTEAD NOTHING');
$this->addSql("COMMENT ON TABLE audit_log_archive IS 'Archived audit entries (>5 years old). Append-only, immutable. Entries are moved from audit_log, not copied.'");
// T8.2-T8.3: Create SECURITY DEFINER function to bypass audit_no_delete rule
// This is the ONLY way to delete from audit_log - for archival purposes only
$this->addSql(<<<'SQL'
CREATE OR REPLACE FUNCTION archive_audit_entries(
p_cutoff_date TIMESTAMPTZ,
p_batch_size INTEGER
)
RETURNS INTEGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_archived_count INTEGER;
v_ids UUID[];
BEGIN
-- Select IDs to archive (oldest first, up to batch_size)
SELECT ARRAY_AGG(id ORDER BY sequence_number ASC)
INTO v_ids
FROM (
SELECT id, sequence_number
FROM audit_log
WHERE occurred_at < p_cutoff_date
ORDER BY sequence_number ASC
LIMIT p_batch_size
) sub;
-- Nothing to archive
IF v_ids IS NULL OR array_length(v_ids, 1) IS NULL THEN
RETURN 0;
END IF;
-- Insert into archive
INSERT INTO audit_log_archive (
id, aggregate_type, aggregate_id, event_type,
payload, metadata, occurred_at, sequence_number
)
SELECT
id, aggregate_type, aggregate_id, event_type,
payload, metadata, occurred_at, sequence_number
FROM audit_log
WHERE id = ANY(v_ids);
GET DIAGNOSTICS v_archived_count = ROW_COUNT;
-- Delete from source table (bypasses rule via SECURITY DEFINER)
-- We temporarily disable the rule for this session
ALTER TABLE audit_log DISABLE RULE audit_no_delete;
DELETE FROM audit_log WHERE id = ANY(v_ids);
ALTER TABLE audit_log ENABLE RULE audit_no_delete;
RETURN v_archived_count;
END;
$$
SQL);
$this->addSql("COMMENT ON FUNCTION archive_audit_entries IS 'Moves audit entries older than cutoff to archive table. Only method to delete from audit_log. Requires superuser or table owner.'");
}
public function down(Schema $schema): void
{
$this->addSql('DROP FUNCTION IF EXISTS archive_audit_entries');
$this->addSql('DROP RULE IF EXISTS audit_archive_no_update ON audit_log_archive');
$this->addSql('DROP RULE IF EXISTS audit_archive_no_delete ON audit_log_archive');
$this->addSql('DROP TABLE IF EXISTS audit_log_archive');
}
}