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'); } }