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:
118
backend/migrations/Version20260203100001.php
Normal file
118
backend/migrations/Version20260203100001.php
Normal 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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user