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
119 lines
4.8 KiB
PHP
119 lines
4.8 KiB
PHP
<?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');
|
|
}
|
|
}
|