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:
parent
da167b713b
commit
b5b70416f2
30 changed files with 1746 additions and 6 deletions
393
app/Services/Archive/EvidenceArchive.php
Normal file
393
app/Services/Archive/EvidenceArchive.php
Normal file
|
|
@ -0,0 +1,393 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Archive;
|
||||
|
||||
use App\Enums\ArchiveEvent;
|
||||
use App\Enums\ArchiveReasonCategory;
|
||||
use App\Models\ArchiveAccessLog;
|
||||
use App\Models\ArchiveEntry;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Filesystem\Filesystem;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Encryption\Encrypter;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
use ZipArchive;
|
||||
|
||||
/**
|
||||
* Beweisarchiv (Bildkennzeichnung Block F, Decision-Update Abschnitt 4).
|
||||
*
|
||||
* Jeder Eintrag ist ein verschlüsseltes ZIP (manifest.json plus Dateien)
|
||||
* auf der Archiv-Disk, eigener Schlüssel (ARCHIVE_KEY), SHA-256 des
|
||||
* Klartext-ZIPs am Eintrag. Lesen nur über open()/export() mit Zweck und
|
||||
* Zugriffsprotokoll. Löschen nur durch purgeDue() nach Frist ohne
|
||||
* legal_hold; die Zeile bleibt als Tombstone. Nach einem Restore aus einer
|
||||
* Sicherung entfernt reconcileAfterRestore() wiederhergestellte Payloads
|
||||
* anhand des täglich exportierten Tombstone-Stands.
|
||||
*/
|
||||
class EvidenceArchive
|
||||
{
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return $this->keyBytes() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $manifest Fachdaten (werden als manifest.json abgelegt)
|
||||
* @param array<string, string> $files Dateiname → Bytes
|
||||
* @param array<string, mixed> $summary Kleine, nicht personenbezogene Kennzahlen für die Liste
|
||||
*/
|
||||
public function record(
|
||||
ArchiveEvent $event,
|
||||
Model $subject,
|
||||
?int $pressReleaseId,
|
||||
ArchiveReasonCategory $reason,
|
||||
string $actorType,
|
||||
?int $actorId,
|
||||
array $manifest,
|
||||
array $files = [],
|
||||
array $summary = [],
|
||||
): ?ArchiveEntry {
|
||||
if (! $this->isConfigured()) {
|
||||
Log::error('Beweisarchiv: ARCHIVE_KEY fehlt, Eintrag konnte nicht abgelegt werden.', [
|
||||
'event' => $event->value,
|
||||
'subject' => $subject->getMorphClass().'#'.$subject->getKey(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$manifest = [
|
||||
'event' => $event->value,
|
||||
'subject_type' => $subject->getMorphClass(),
|
||||
'subject_id' => $subject->getKey(),
|
||||
'press_release_id' => $pressReleaseId,
|
||||
'reason_category' => $reason->value,
|
||||
'actor_type' => $actorType,
|
||||
'actor_id' => $actorId,
|
||||
'recorded_at' => now()->toIso8601String(),
|
||||
] + $manifest;
|
||||
|
||||
$zip = $this->buildZip($manifest, $files);
|
||||
$hash = hash('sha256', $zip);
|
||||
$path = sprintf('entries/%s/%s.bin', now()->format('Y'), Str::uuid()->toString());
|
||||
|
||||
if (! $this->disk()->put($path, $this->encrypter()->encrypt($zip, false))) {
|
||||
throw new RuntimeException('Beweisarchiv: Payload konnte nicht geschrieben werden.');
|
||||
}
|
||||
|
||||
return ArchiveEntry::query()->create([
|
||||
'subject_type' => $subject->getMorphClass(),
|
||||
'subject_id' => $subject->getKey(),
|
||||
'press_release_id' => $pressReleaseId,
|
||||
'event' => $event,
|
||||
'actor_type' => $actorType,
|
||||
'actor_id' => $actorId,
|
||||
'reason_category' => $reason,
|
||||
'summary' => $summary ?: null,
|
||||
'payload_path' => $path,
|
||||
'payload_hash' => $hash,
|
||||
'payload_bytes' => strlen($zip),
|
||||
'retain_until' => ArchiveEntry::retentionEndFor(now()),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Entschlüsseltes ZIP eines Eintrags — protokolliert mit Zweck.
|
||||
*/
|
||||
public function open(ArchiveEntry $entry, User $admin, string $purpose): string
|
||||
{
|
||||
$this->logAccess($entry, $admin, 'open', $purpose);
|
||||
|
||||
return $this->plaintext($entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* manifest.json eines Eintrags als Array — protokolliert mit Zweck.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function manifest(ArchiveEntry $entry, User $admin, string $purpose): array
|
||||
{
|
||||
$this->logAccess($entry, $admin, 'manifest', $purpose);
|
||||
|
||||
$temp = $this->tempFile($this->plaintext($entry));
|
||||
|
||||
try {
|
||||
$zip = new ZipArchive;
|
||||
|
||||
if ($zip->open($temp) !== true) {
|
||||
throw new RuntimeException('Beweisarchiv: Payload ist kein lesbares ZIP.');
|
||||
}
|
||||
|
||||
$json = $zip->getFromName('manifest.json');
|
||||
$zip->close();
|
||||
} finally {
|
||||
@unlink($temp);
|
||||
}
|
||||
|
||||
return is_string($json) ? (array) json_decode($json, true) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Export für den Anwalt: äußeres ZIP mit payload.zip, metadata.json und
|
||||
* einer HMAC-Signatur über den Hash (mit dem Archivschlüssel).
|
||||
*/
|
||||
public function export(ArchiveEntry $entry, User $admin, string $purpose): string
|
||||
{
|
||||
$this->logAccess($entry, $admin, 'export', $purpose);
|
||||
|
||||
$payload = $this->plaintext($entry);
|
||||
|
||||
return $this->buildZip([
|
||||
'entry_id' => $entry->getKey(),
|
||||
'event' => $entry->event->value,
|
||||
'subject' => $entry->subject_type.'#'.$entry->subject_id,
|
||||
'press_release_id' => $entry->press_release_id,
|
||||
'reason_category' => $entry->reason_category->value,
|
||||
'created_at' => $entry->created_at?->toIso8601String(),
|
||||
'retain_until' => $entry->retain_until->toDateString(),
|
||||
'legal_hold' => $entry->legal_hold,
|
||||
'payload_sha256' => $entry->payload_hash,
|
||||
'exported_at' => now()->toIso8601String(),
|
||||
'exported_by' => $admin->getKey(),
|
||||
], [
|
||||
'payload.zip' => $payload,
|
||||
'signature.txt' => 'sha256='.hash('sha256', $payload)."\nhmac-sha256=".hash_hmac('sha256', (string) $entry->payload_hash, (string) $this->keyBytes())."\n",
|
||||
], 'metadata.json');
|
||||
}
|
||||
|
||||
public function setLegalHold(ArchiveEntry $entry, User $admin, string $note): void
|
||||
{
|
||||
$entry->forceFill([
|
||||
'legal_hold' => true,
|
||||
'legal_hold_note' => Str::limit($note, 990, ''),
|
||||
'legal_hold_set_by' => $admin->getKey(),
|
||||
'legal_hold_set_at' => now(),
|
||||
])->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Aufhebung: die Frist läuft ab dem Aufhebungsdatum neu (4.3).
|
||||
*/
|
||||
public function releaseLegalHold(ArchiveEntry $entry, User $admin, string $note): void
|
||||
{
|
||||
$entry->forceFill([
|
||||
'legal_hold' => false,
|
||||
'legal_hold_note' => Str::limit($note, 990, ''),
|
||||
'legal_hold_set_by' => $admin->getKey(),
|
||||
'legal_hold_set_at' => now(),
|
||||
'retain_until' => ArchiveEntry::retentionEndFor(now()),
|
||||
])->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fällige Einträge: Payload löschen, Tombstone setzen, Log ohne Inhalt.
|
||||
*/
|
||||
public function purgeDue(bool $dryRun = false): int
|
||||
{
|
||||
$count = 0;
|
||||
|
||||
ArchiveEntry::query()->dueForPurge()->orderBy('id')->chunkById(200, function ($entries) use (&$count, $dryRun): void {
|
||||
foreach ($entries as $entry) {
|
||||
$count++;
|
||||
|
||||
if ($dryRun) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->tombstone($entry);
|
||||
}
|
||||
});
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tombstone-Stand exportieren, damit er einen Restore überlebt.
|
||||
*/
|
||||
public function exportTombstones(): int
|
||||
{
|
||||
$rows = ArchiveEntry::query()->whereNotNull('deleted_at')->orderBy('id')
|
||||
->get(['id', 'payload_path', 'deleted_at'])
|
||||
->map(fn (ArchiveEntry $entry): array => [
|
||||
'id' => $entry->getKey(),
|
||||
'payload_path' => $entry->payload_path,
|
||||
'deleted_at' => $entry->deleted_at?->toIso8601String(),
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$this->disk()->put((string) config('archive.tombstone_export_path'), (string) json_encode([
|
||||
'exported_at' => now()->toIso8601String(),
|
||||
'entries' => $rows,
|
||||
], JSON_PRETTY_PRINT));
|
||||
|
||||
return count($rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nach dem Einspielen einer Sicherung: alles, was im exportierten
|
||||
* Tombstone-Stand gelöscht war, erneut entfernen (Datei und Zeile).
|
||||
*
|
||||
* @return array{re_tombstoned: int, files_removed: int}
|
||||
*/
|
||||
public function reconcileAfterRestore(): array
|
||||
{
|
||||
$result = ['re_tombstoned' => 0, 'files_removed' => 0];
|
||||
$raw = $this->disk()->get((string) config('archive.tombstone_export_path'));
|
||||
|
||||
if (! is_string($raw) || $raw === '') {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$rows = (array) (json_decode($raw, true)['entries'] ?? []);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$path = is_string($row['payload_path'] ?? null) ? $row['payload_path'] : null;
|
||||
|
||||
if ($path !== null && $this->disk()->exists($path)) {
|
||||
$this->disk()->delete($path);
|
||||
$result['files_removed']++;
|
||||
}
|
||||
|
||||
$entry = ArchiveEntry::query()->find((int) ($row['id'] ?? 0));
|
||||
|
||||
if ($entry !== null && ! $entry->isTombstoned()) {
|
||||
$entry->forceFill(['deleted_at' => now()])->save();
|
||||
$result['re_tombstoned']++;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function tombstone(ArchiveEntry $entry): void
|
||||
{
|
||||
DB::transaction(function () use ($entry): void {
|
||||
if ($entry->payload_path !== null && $this->disk()->exists($entry->payload_path)) {
|
||||
$this->disk()->delete($entry->payload_path);
|
||||
}
|
||||
|
||||
// Pfad bleibt eingetragen, damit der Restore-Abgleich die Datei
|
||||
// wiederfindet; der Inhalt ist weg.
|
||||
$entry->forceFill(['deleted_at' => now()])->save();
|
||||
});
|
||||
|
||||
Log::info('Beweisarchiv: Eintrag nach Fristablauf gelöscht.', ['entry_id' => $entry->getKey(), 'event' => $entry->event->value]);
|
||||
}
|
||||
|
||||
private function logAccess(ArchiveEntry $entry, User $admin, string $action, string $purpose): void
|
||||
{
|
||||
$purpose = trim($purpose);
|
||||
|
||||
if ($purpose === '') {
|
||||
throw new RuntimeException('Beweisarchiv: Zugriff ohne Zweck ist nicht vorgesehen.');
|
||||
}
|
||||
|
||||
ArchiveAccessLog::query()->create([
|
||||
'entry_id' => $entry->getKey(),
|
||||
'admin_id' => $admin->getKey(),
|
||||
'action' => $action,
|
||||
'purpose' => Str::limit($purpose, 490, ''),
|
||||
'accessed_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function plaintext(ArchiveEntry $entry): string
|
||||
{
|
||||
if ($entry->isTombstoned() || $entry->payload_path === null) {
|
||||
throw new RuntimeException('Beweisarchiv: Eintrag ist nach Fristablauf gelöscht (Tombstone).');
|
||||
}
|
||||
|
||||
$cipher = $this->disk()->get($entry->payload_path);
|
||||
|
||||
if (! is_string($cipher) || $cipher === '') {
|
||||
throw new RuntimeException('Beweisarchiv: Payload fehlt auf der Archiv-Disk.');
|
||||
}
|
||||
|
||||
$zip = (string) $this->encrypter()->decrypt($cipher, false);
|
||||
|
||||
if (! hash_equals((string) $entry->payload_hash, hash('sha256', $zip))) {
|
||||
throw new RuntimeException('Beweisarchiv: Hash des Payloads stimmt nicht mit dem Eintrag überein.');
|
||||
}
|
||||
|
||||
return $zip;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $manifest
|
||||
* @param array<string, string> $files
|
||||
*/
|
||||
private function buildZip(array $manifest, array $files, string $manifestName = 'manifest.json'): string
|
||||
{
|
||||
$temp = $this->tempFile('');
|
||||
$zip = new ZipArchive;
|
||||
|
||||
try {
|
||||
if ($zip->open($temp, ZipArchive::OVERWRITE) !== true) {
|
||||
throw new RuntimeException('Beweisarchiv: ZIP konnte nicht erzeugt werden.');
|
||||
}
|
||||
|
||||
$zip->addFromString($manifestName, (string) json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
|
||||
foreach ($files as $name => $bytes) {
|
||||
$zip->addFromString($name, $bytes);
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
|
||||
return (string) file_get_contents($temp);
|
||||
} finally {
|
||||
@unlink($temp);
|
||||
}
|
||||
}
|
||||
|
||||
private function tempFile(string $contents): string
|
||||
{
|
||||
$temp = tempnam(sys_get_temp_dir(), 'pk-archive-');
|
||||
|
||||
if ($temp === false) {
|
||||
throw new RuntimeException('Beweisarchiv: keine temporäre Datei verfügbar.');
|
||||
}
|
||||
|
||||
file_put_contents($temp, $contents);
|
||||
|
||||
return $temp;
|
||||
}
|
||||
|
||||
private function encrypter(): Encrypter
|
||||
{
|
||||
$key = $this->keyBytes();
|
||||
|
||||
if ($key === null) {
|
||||
throw new RuntimeException('Beweisarchiv: ARCHIVE_KEY fehlt.');
|
||||
}
|
||||
|
||||
return new Encrypter($key, (string) config('archive.cipher', 'AES-256-CBC'));
|
||||
}
|
||||
|
||||
private function keyBytes(): ?string
|
||||
{
|
||||
$key = (string) config('archive.key');
|
||||
|
||||
if ($key === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Str::startsWith($key, 'base64:')) {
|
||||
$key = base64_decode(substr($key, 7), true) ?: '';
|
||||
}
|
||||
|
||||
return strlen($key) === 32 ? $key : null;
|
||||
}
|
||||
|
||||
private function disk(): Filesystem
|
||||
{
|
||||
return Storage::disk((string) config('archive.disk', 'archive'));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue