audit: Nacharbeit Umsetzungsaudit 25.09.2026 — Bildfreigabe, Beweisarchiv, KI-Atomarität, Berichte (Block 8, N01–N05, N07, N08, N10–N21, 5.1, 5.2)
Bilder (N01, N05, N07, N08): gemeinsamer Freigabezustand PressReleaseImage::isPubliclyDeliverable() für Seiten, OG und Medienroute (Bestand bleibt sichtbar, v2 nur mit Bestätigung, Admin-Upload bis zum Kundenklick nicht öffentlich); eigene Ability confirmImageRights nur für den Kontoinhaber, ohne Super-Admin-Kurzschluss, Identität und ausstehende Bestätigung auch im Service; Status-Regel unter der PM-Sperre; Cache-Flush nach dem Commit außerhalb der Dateibereinigung; Snapshot nach Bestätigung an veröffentlichter PM; Bestätigungstexte an die gezeigte Kennung gebunden. Beweisarchiv (N02, N10, N11, N12, N13, 5.1): Tombstone unter Zeilensperre mit erneuter Hold-/Fristprüfung; Dateioperationen ausgewertet, Commands mit FAILURE; record() über .tmp + move; Marker archive_pending_at mit archive:retry-pending und archive:sweep-orphans; Anonymisierung entfernt Bilder über den Remover mit Antragsbezug; Draft-Purge räumt Medien nie veröffentlichter abgelehnter Einreichungen; App-Kopien fallen mit dem Archiv-Tombstone; label_corrected nur für veröffentlichte Bilder; archive_entries.legal_request_id; FileDeletion-Helfer; Systemstatus-Checks. KI und Hintergrund (N03, N04, N14, N20, N21): Ergebnis-UPDATE und Audit in einer Transaktion; erwarteter Hash bis in reject()/holdForManualReview(); Ablaufmail per beanspruchtem Marker in einer Transaktion; strikte Schemaprüfung der Vision-Antwort; Fortschrittsmarke ungecacht. Berichte und Canonical (N15–N19, 5.2): lückenlose Alterskohorten; Feed-Identität mit Portal und Sprache; Sitemap-Rolle in der Kundenansicht; Indexverteilung auf einer Grundgesamtheit; Kopien nach Wegfall der Erstquelle neu bewertet; Kategorie-Admin leert Seiten-Caches; Dry-Run zählt alle Tabellen. Gesamtsuite: 2.122 Tests grün, einer übersprungen; Larastan und Pint sauber. Zwei Migrationen (nullable Spalten, INSTANT). N09 offen. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
34fa342c7f
commit
2a2215b3c3
66 changed files with 3092 additions and 368 deletions
|
|
@ -6,7 +6,11 @@ use App\Enums\ArchiveEvent;
|
|||
use App\Enums\ArchiveReasonCategory;
|
||||
use App\Models\ArchiveAccessLog;
|
||||
use App\Models\ArchiveEntry;
|
||||
use App\Models\ImageDeclarationCorrection;
|
||||
use App\Models\ImageRightsConfirmation;
|
||||
use App\Models\PressReleaseImage;
|
||||
use App\Models\User;
|
||||
use App\Support\FileDeletion;
|
||||
use Illuminate\Contracts\Filesystem\Filesystem;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Encryption\Encrypter;
|
||||
|
|
@ -15,6 +19,7 @@ use Illuminate\Support\Facades\Log;
|
|||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
use ZipArchive;
|
||||
|
||||
/**
|
||||
|
|
@ -26,7 +31,13 @@ use ZipArchive;
|
|||
* 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.
|
||||
* anhand des täglich exportierten Tombstone-Stands und der Tombstones in
|
||||
* der Datenbank.
|
||||
*
|
||||
* Dateioperationen werden ausgewertet (Umsetzungsaudit 25.09.2026, N10):
|
||||
* ein Tombstone entsteht erst nach bestätigter Löschung, Purge und
|
||||
* Restore-Abgleich melden Fehlschläge, der Export wird atomar ersetzt.
|
||||
* Hold und Purge koordinieren sich über dieselbe Zeilensperre (N02).
|
||||
*/
|
||||
class EvidenceArchive
|
||||
{
|
||||
|
|
@ -36,6 +47,11 @@ class EvidenceArchive
|
|||
}
|
||||
|
||||
/**
|
||||
* Payload zuerst als .tmp, dann die Zeile in einer Transaktion, dann
|
||||
* die Datei an ihren Platz — schlägt etwas fehl, bleibt weder Zeile
|
||||
* noch .tmp zurück (N11). Eine Datei ohne Zeile (Commit nach dem
|
||||
* Verschieben gescheitert) räumt sweepOrphans() auf.
|
||||
*
|
||||
* @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
|
||||
|
|
@ -50,6 +66,7 @@ class EvidenceArchive
|
|||
array $manifest,
|
||||
array $files = [],
|
||||
array $summary = [],
|
||||
?int $legalRequestId = null,
|
||||
): ?ArchiveEntry {
|
||||
if (! $this->isConfigured()) {
|
||||
Log::error('Beweisarchiv: ARCHIVE_KEY fehlt, Eintrag konnte nicht abgelegt werden.', [
|
||||
|
|
@ -65,6 +82,7 @@ class EvidenceArchive
|
|||
'subject_type' => $subject->getMorphClass(),
|
||||
'subject_id' => $subject->getKey(),
|
||||
'press_release_id' => $pressReleaseId,
|
||||
'legal_request_id' => $legalRequestId,
|
||||
'reason_category' => $reason->value,
|
||||
'actor_type' => $actorType,
|
||||
'actor_id' => $actorId,
|
||||
|
|
@ -74,26 +92,42 @@ class EvidenceArchive
|
|||
$zip = $this->buildZip($manifest, $files);
|
||||
$hash = hash('sha256', $zip);
|
||||
$path = sprintf('entries/%s/%s.bin', now()->format('Y'), Str::uuid()->toString());
|
||||
$temp = $path.'.tmp';
|
||||
|
||||
if (! $this->disk()->put($path, $this->encrypter()->encrypt($zip, false))) {
|
||||
if (! $this->disk()->put($temp, $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(),
|
||||
]);
|
||||
try {
|
||||
return DB::transaction(function () use ($subject, $pressReleaseId, $legalRequestId, $event, $actorType, $actorId, $reason, $summary, $path, $temp, $hash, $zip): ArchiveEntry {
|
||||
$entry = ArchiveEntry::query()->create([
|
||||
'subject_type' => $subject->getMorphClass(),
|
||||
'subject_id' => $subject->getKey(),
|
||||
'press_release_id' => $pressReleaseId,
|
||||
'legal_request_id' => $legalRequestId,
|
||||
'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(),
|
||||
]);
|
||||
|
||||
if (! $this->disk()->move($temp, $path) || ! $this->disk()->exists($path)) {
|
||||
throw new RuntimeException('Beweisarchiv: Payload konnte nicht abgelegt werden.');
|
||||
}
|
||||
|
||||
return $entry;
|
||||
});
|
||||
} catch (Throwable $exception) {
|
||||
$this->disk()->delete($temp);
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -148,6 +182,7 @@ class EvidenceArchive
|
|||
'event' => $entry->event->value,
|
||||
'subject' => $entry->subject_type.'#'.$entry->subject_id,
|
||||
'press_release_id' => $entry->press_release_id,
|
||||
'legal_request_id' => $entry->legal_request_id,
|
||||
'reason_category' => $entry->reason_category->value,
|
||||
'created_at' => $entry->created_at?->toIso8601String(),
|
||||
'retain_until' => $entry->retain_until->toDateString(),
|
||||
|
|
@ -161,14 +196,30 @@ class EvidenceArchive
|
|||
], 'metadata.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold setzen unter Zeilensperre: ein gleichzeitig laufender Purge sieht
|
||||
* den Hold bei seiner erneuten Prüfung, oder der Hold wartet, bis der
|
||||
* Purge fertig ist — dann ist der Eintrag Tombstone und der Hold wird
|
||||
* abgelehnt (N02).
|
||||
*/
|
||||
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();
|
||||
DB::transaction(function () use ($entry, $admin, $note): void {
|
||||
$locked = $this->lockedEntry($entry);
|
||||
|
||||
if ($locked->isTombstoned()) {
|
||||
throw new RuntimeException('Beweisarchiv: Eintrag ist bereits nach Fristablauf gelöscht (Tombstone), ein Legal Hold ist nicht mehr möglich.');
|
||||
}
|
||||
|
||||
$locked->forceFill([
|
||||
'legal_hold' => true,
|
||||
'legal_hold_note' => Str::limit($note, 990, ''),
|
||||
'legal_hold_set_by' => $admin->getKey(),
|
||||
'legal_hold_set_at' => now(),
|
||||
])->save();
|
||||
});
|
||||
|
||||
$entry->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -176,39 +227,64 @@ class EvidenceArchive
|
|||
*/
|
||||
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();
|
||||
DB::transaction(function () use ($entry, $admin, $note): void {
|
||||
$locked = $this->lockedEntry($entry);
|
||||
|
||||
if ($locked->isTombstoned()) {
|
||||
throw new RuntimeException('Beweisarchiv: Eintrag ist bereits nach Fristablauf gelöscht (Tombstone).');
|
||||
}
|
||||
|
||||
$locked->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();
|
||||
});
|
||||
|
||||
$entry->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fällige Einträge: Payload löschen, Tombstone setzen, Log ohne Inhalt.
|
||||
* Jeder Eintrag wird unter Zeilensperre erneut geprüft; Fehlschläge
|
||||
* werden gezählt und beim nächsten Lauf wiederholt.
|
||||
*
|
||||
* @return array{due: int, purged: int, failed: int}
|
||||
*/
|
||||
public function purgeDue(bool $dryRun = false): int
|
||||
public function purgeDue(bool $dryRun = false): array
|
||||
{
|
||||
$count = 0;
|
||||
$result = ['due' => 0, 'purged' => 0, 'failed' => 0];
|
||||
|
||||
ArchiveEntry::query()->dueForPurge()->orderBy('id')->chunkById(200, function ($entries) use (&$count, $dryRun): void {
|
||||
ArchiveEntry::query()->dueForPurge()->orderBy('id')->chunkById(200, function ($entries) use (&$result, $dryRun): void {
|
||||
foreach ($entries as $entry) {
|
||||
$count++;
|
||||
$result['due']++;
|
||||
|
||||
if ($dryRun) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->tombstone($entry);
|
||||
try {
|
||||
if ($this->tombstone($entry)) {
|
||||
$result['purged']++;
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
$result['failed']++;
|
||||
Log::error('Beweisarchiv: Löschung nach Fristablauf fehlgeschlagen, wird wiederholt.', [
|
||||
'entry_id' => $entry->getKey(),
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return $count;
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tombstone-Stand exportieren, damit er einen Restore überlebt.
|
||||
* Tombstone-Stand exportieren, damit er einen Restore überlebt. Wird
|
||||
* atomar ersetzt; ein Fehlschlag wirft, statt eine Anzahl zu melden.
|
||||
*/
|
||||
public function exportTombstones(): int
|
||||
{
|
||||
|
|
@ -222,10 +298,22 @@ class EvidenceArchive
|
|||
->values()
|
||||
->all();
|
||||
|
||||
$this->disk()->put((string) config('archive.tombstone_export_path'), (string) json_encode([
|
||||
$path = (string) config('archive.tombstone_export_path');
|
||||
$temp = $path.'.tmp';
|
||||
$json = (string) json_encode([
|
||||
'exported_at' => now()->toIso8601String(),
|
||||
'entries' => $rows,
|
||||
], JSON_PRETTY_PRINT));
|
||||
], JSON_PRETTY_PRINT);
|
||||
|
||||
if (! $this->disk()->put($temp, $json)) {
|
||||
throw new RuntimeException('Beweisarchiv: Tombstone-Export konnte nicht geschrieben werden.');
|
||||
}
|
||||
|
||||
if (! $this->disk()->move($temp, $path) || ! $this->disk()->exists($path)) {
|
||||
$this->disk()->delete($temp);
|
||||
|
||||
throw new RuntimeException('Beweisarchiv: Tombstone-Export konnte nicht abgelegt werden.');
|
||||
}
|
||||
|
||||
return count($rows);
|
||||
}
|
||||
|
|
@ -233,26 +321,25 @@ class EvidenceArchive
|
|||
/**
|
||||
* Nach dem Einspielen einer Sicherung: alles, was im exportierten
|
||||
* Tombstone-Stand gelöscht war, erneut entfernen (Datei und Zeile).
|
||||
* Zusätzlich jede Tombstone-Zeile der Datenbank, deren Payload wieder
|
||||
* auf der Disk liegt — deckt den Fall „Disk alt, Datenbank aktuell"
|
||||
* ab (5.1 c).
|
||||
*
|
||||
* @return array{re_tombstoned: int, files_removed: int}
|
||||
* @return array{re_tombstoned: int, files_removed: int, failed: int}
|
||||
*/
|
||||
public function reconcileAfterRestore(): array
|
||||
{
|
||||
$result = ['re_tombstoned' => 0, 'files_removed' => 0];
|
||||
$result = ['re_tombstoned' => 0, 'files_removed' => 0, 'failed' => 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'] ?? []);
|
||||
$rows = is_string($raw) && $raw !== '' ? (array) (json_decode($raw, true)['entries'] ?? []) : [];
|
||||
$handled = [];
|
||||
|
||||
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']++;
|
||||
if ($path !== null && ! isset($handled[$path]) && $this->disk()->exists($path)) {
|
||||
$handled[$path] = true;
|
||||
FileDeletion::delete($this->disk(), $path) ? $result['files_removed']++ : $result['failed']++;
|
||||
}
|
||||
|
||||
$entry = ArchiveEntry::query()->find((int) ($row['id'] ?? 0));
|
||||
|
|
@ -263,22 +350,127 @@ class EvidenceArchive
|
|||
}
|
||||
}
|
||||
|
||||
ArchiveEntry::query()->whereNotNull('deleted_at')->whereNotNull('payload_path')->orderBy('id')
|
||||
->chunkById(500, function ($entries) use (&$result, &$handled): void {
|
||||
foreach ($entries as $entry) {
|
||||
$path = (string) $entry->payload_path;
|
||||
|
||||
if (isset($handled[$path]) || ! $this->disk()->exists($path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$handled[$path] = true;
|
||||
FileDeletion::delete($this->disk(), $path) ? $result['files_removed']++ : $result['failed']++;
|
||||
}
|
||||
});
|
||||
|
||||
if ($result['failed'] > 0) {
|
||||
Log::error('Beweisarchiv: Restore-Abgleich konnte Payloads nicht entfernen.', ['failed' => $result['failed']]);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function tombstone(ArchiveEntry $entry): void
|
||||
/**
|
||||
* Payloads ohne Archivzeile (abgebrochene Ablage, äußerer Rollback,
|
||||
* liegengebliebene .tmp) nach Karenzzeit entfernen (N11).
|
||||
*
|
||||
* @return array{scanned: int, orphans: int, removed: int}
|
||||
*/
|
||||
public function sweepOrphans(bool $dryRun = false, int $olderThanHours = 24): array
|
||||
{
|
||||
DB::transaction(function () use ($entry): void {
|
||||
if ($entry->payload_path !== null && $this->disk()->exists($entry->payload_path)) {
|
||||
$this->disk()->delete($entry->payload_path);
|
||||
$result = ['scanned' => 0, 'orphans' => 0, 'removed' => 0];
|
||||
$threshold = now()->subHours($olderThanHours)->getTimestamp();
|
||||
|
||||
foreach (array_chunk($this->disk()->allFiles('entries'), 500) as $chunk) {
|
||||
$result['scanned'] += count($chunk);
|
||||
$known = ArchiveEntry::query()->whereIn('payload_path', $chunk)->pluck('payload_path')->all();
|
||||
|
||||
foreach (array_diff($chunk, $known) as $file) {
|
||||
if ($this->disk()->lastModified($file) > $threshold) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$result['orphans']++;
|
||||
|
||||
if (! $dryRun && FileDeletion::delete($this->disk(), $file)) {
|
||||
$result['removed']++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unter Zeilensperre erneut prüfen, dann Datei bestätigt löschen, dann
|
||||
* Tombstone. Liefert false, wenn der Eintrag inzwischen nicht mehr
|
||||
* fällig ist (Hold gesetzt, Frist verlängert, schon Tombstone).
|
||||
*/
|
||||
private function tombstone(ArchiveEntry $entry): bool
|
||||
{
|
||||
$done = DB::transaction(function () use ($entry): bool {
|
||||
$locked = $this->lockedEntry($entry);
|
||||
|
||||
if ($locked->isTombstoned() || $locked->legal_hold || $locked->retain_until->gte(today())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($locked->payload_path !== null) {
|
||||
FileDeletion::deleteOrThrow($this->disk(), $locked->payload_path, 'Beweisarchiv');
|
||||
}
|
||||
|
||||
$this->purgeApplicationCopies($locked);
|
||||
|
||||
// Pfad bleibt eingetragen, damit der Restore-Abgleich die Datei
|
||||
// wiederfindet; der Inhalt ist weg.
|
||||
$entry->forceFill(['deleted_at' => now()])->save();
|
||||
$locked->forceFill(['deleted_at' => now()])->save();
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
Log::info('Beweisarchiv: Eintrag nach Fristablauf gelöscht.', ['entry_id' => $entry->getKey(), 'event' => $entry->event->value]);
|
||||
if ($done) {
|
||||
Log::info('Beweisarchiv: Eintrag nach Fristablauf gelöscht.', ['entry_id' => $entry->getKey(), 'event' => $entry->event->value]);
|
||||
}
|
||||
|
||||
return $done;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mit dem Archiv-Tombstone eines entfernten Bildes verschwinden auch die
|
||||
* Kopien in der Anwendung: Bestätigungen, Korrekturen und die weich
|
||||
* gelöschte Bildzeile (5.1 a). Das Archiv hielt die vollständige Kopie.
|
||||
*/
|
||||
private function purgeApplicationCopies(ArchiveEntry $entry): void
|
||||
{
|
||||
if (! in_array($entry->event, [ArchiveEvent::ImageRemoved, ArchiveEvent::ImageReplaced], true)
|
||||
|| $entry->subject_type !== (new PressReleaseImage)->getMorphClass()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$image = PressReleaseImage::withTrashed()->find($entry->subject_id);
|
||||
|
||||
if ($image !== null && ! $image->trashed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
ImageRightsConfirmation::query()
|
||||
->where('subject_type', $entry->subject_type)
|
||||
->where('subject_id', $entry->subject_id)
|
||||
->get()
|
||||
->each(fn (ImageRightsConfirmation $confirmation) => $confirmation->purgeAfterArchive());
|
||||
|
||||
ImageDeclarationCorrection::query()
|
||||
->where('press_release_image_id', $entry->subject_id)
|
||||
->get()
|
||||
->each(fn (ImageDeclarationCorrection $correction) => $correction->purgeAfterArchive());
|
||||
|
||||
$image?->forceDelete();
|
||||
}
|
||||
|
||||
private function lockedEntry(ArchiveEntry $entry): ArchiveEntry
|
||||
{
|
||||
return ArchiveEntry::query()->whereKey($entry->getKey())->lockForUpdate()->firstOrFail();
|
||||
}
|
||||
|
||||
private function logAccess(ArchiveEntry $entry, User $admin, string $action, string $purpose): void
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue