archive: Beweisarchiv — verschlüsselte Einträge, Legal Hold, Purge mit Tombstone, Restore-Abgleich, Auslöser und Admin-Seite (Bildkennzeichnung Block F)

- archive_entries (append-only) + archive_access_log; Disk archive außerhalb des Web-Roots; eigener ARCHIVE_KEY (archive:key), AES-256-CBC, SHA-256 am Eintrag
- EvidenceArchive: record/open/manifest/export (signiertes ZIP), setLegalHold/releaseLegalHold (Frist läuft neu), purgeDue, exportTombstones, reconcileAfterRestore
- PressReleaseArchiver: image_removed (Original, Snapshot, Bestätigungen), pm_depublished (letzter veröffentlichter Stand), gdpr_change (Anonymisierung, Korrekturen), label_corrected
- Hooks in Remover, depublish/archive/changeStatusFromAdmin/deleteFromAdmin, AnonymizationService, CorrectionService, ImageCorrectionService
- Scheduler archive:purge 03:20 und archive:export-tombstones 03:30; archive:reconcile-after-restore im Runbook
- Admin ▸ Management ▸ Beweisarchiv (Liste, Legal Hold mit Pflichtvermerk, Öffnen/Export nur mit Zweck)
- Scope-Wächter: neue Befehle nutzen withoutGlobalScope(PortalScope::class)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-22 10:04:21 +00:00
parent da167b713b
commit b5b70416f2
30 changed files with 1746 additions and 6 deletions

View file

@ -0,0 +1,66 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Beweisarchiv (Bildkennzeichnung Block F, Decision-Update 4.2): ein Eintrag
* je Ereignis mit Verweis auf den verschlüsselten Payload im Archiv-Storage.
*
* Append-only: Es gibt nur Anlegen, Tombstone (deleted_at, Payload weg,
* Zeile bleibt ohne Inhalt) und legal_hold setzen/aufheben. Das Audit-Log
* der Anwendung verweist nur auf die id, nie auf Altwerte.
*
* `archive_access_log`: jeder lesende Zugriff mit Admin, Zeitpunkt und
* Pflichtangabe des Zwecks.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('archive_entries', function (Blueprint $table) {
$table->id();
$table->string('subject_type', 80);
$table->unsignedBigInteger('subject_id');
$table->unsignedBigInteger('press_release_id')->nullable();
$table->string('event', 30);
$table->string('actor_type', 20);
$table->unsignedBigInteger('actor_id')->nullable();
$table->string('reason_category', 30);
$table->json('summary')->nullable();
$table->string('payload_path', 512)->nullable();
$table->char('payload_hash', 64)->nullable();
$table->unsignedBigInteger('payload_bytes')->nullable();
$table->date('retain_until');
$table->boolean('legal_hold')->default(false);
$table->string('legal_hold_note', 1000)->nullable();
$table->unsignedBigInteger('legal_hold_set_by')->nullable();
$table->timestamp('legal_hold_set_at')->nullable();
$table->timestamp('deleted_at')->nullable();
$table->timestamp('created_at')->nullable();
$table->index(['subject_type', 'subject_id'], 'archive_entries_subject_idx');
$table->index(['press_release_id'], 'archive_entries_press_release_idx');
$table->index(['retain_until', 'legal_hold', 'deleted_at'], 'archive_entries_purge_idx');
$table->index(['event', 'created_at'], 'archive_entries_event_created_idx');
});
Schema::create('archive_access_log', function (Blueprint $table) {
$table->id();
$table->foreignId('entry_id')->constrained('archive_entries')->cascadeOnDelete();
$table->foreignId('admin_id')->nullable()->constrained('users')->nullOnDelete();
$table->string('action', 20);
$table->string('purpose', 500);
$table->timestamp('accessed_at');
$table->index(['entry_id', 'accessed_at'], 'archive_access_entry_idx');
});
}
public function down(): void
{
Schema::dropIfExists('archive_access_log');
Schema::dropIfExists('archive_entries');
}
};