archive: Auftragstabelle statt Marker, exklusiver Abschluss, Quarantäne, Restore über Payload-Identität und signierte Exporte, Schlüsselrotation (Umsetzungsaudit 29.09.: A-01 bis A-15, OPT-6, 5.1 d; 02.10.: O04, O05, O07, O08)

- A-01/A-02/A-04: archive_pending_jobs je Vorgang (ArchivePendingQueue),
  Depublikationsstand am Auftrag festgehalten, Nachholer ohne Schlüssel
  FAILURE; archive_pending_at nur noch Sammelmarker
- O07: Direktweg und Nachholer beanspruchen den Auftrag unter Zeilensperre
- O08: fehlende Snapshot-Datei → Verweis auf den image_removed-Eintrag oder
  offener Auftrag, nie ein still unvollständiger Nachweis
- A-03/A-06: Sweep verschiebt in die Quarantäne, Pause beim Restore,
  archive:reregister; Restore-Abgleich löscht Anwendungskopien erneut
- O04/O05: Restore ordnet über den Payload-Pfad zu; unsignierte Exporte nur
  mit --accept-unsigned, reregister bricht bei ungültigem/fehlendem Export ab
- A-05, A-07 bis A-15: Altbestand als möglicherweise veröffentlicht, Hold-
  Historie, Zweck per POST, Nachweise gelöschter Bilder nur mit
  compliance:manage, Anhänge auf ihrer Disk, Schlüssel-ID und
  ARCHIVE_PREVIOUS_KEYS, HMAC per HKDF, archive:verify-export
- 5.1 d: Depublikation im Meldeverfahren trägt legal_request_id

PressReleaseService.php enthält zusätzlich K-01 (Prüfung im Publish-Lock),
N19 (archive() gibt Kopien frei) und N09 (markImagesPublished nur für
auslieferbare Bilder), weil die Datei ungeteilt committet ist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-10-02 13:09:01 +00:00
parent c82f279fef
commit 60650baeb8
29 changed files with 2918 additions and 412 deletions

View file

@ -3,16 +3,19 @@
namespace App\Services\PressRelease;
use App\Enums\ActivityType;
use App\Enums\ArchiveEvent;
use App\Enums\ArchiveReasonCategory;
use App\Enums\LegalRequestStatus;
use App\Enums\LegalRequestType;
use App\Enums\LegalRequestVerificationMethod;
use App\Models\ArchiveEntry;
use App\Models\ArchivePendingJob;
use App\Models\LegalRequest;
use App\Models\PressRelease;
use App\Models\PressReleaseRedactionSnapshot;
use App\Models\User;
use App\Services\ActivityLog;
use App\Services\Archive\ArchivePendingQueue;
use App\Services\Archive\PressReleaseArchiver;
use App\Services\PressRelease\Classification\ClassificationManager;
use App\Services\PressRelease\Classification\ClassificationResult;
@ -152,7 +155,7 @@ class CorrectionService
$this->assertNotRateLimited($pressRelease);
[$legalRequest, $snapshot] = DB::transaction(function () use ($pressRelease, $newValues, $requesterEmail, $method, $classification): array {
[$legalRequest, $snapshot, $job] = DB::transaction(function () use ($pressRelease, $newValues, $requesterEmail, $method, $classification, $actor): array {
$legalRequest = LegalRequest::query()->create([
'press_release_id' => $pressRelease->id,
'portal' => $pressRelease->portal?->value,
@ -175,15 +178,16 @@ class CorrectionService
], $legalRequest);
$this->writeChangedFields($pressRelease, $classification['fields'], $newValues);
$pressRelease->forceFill(['archive_pending_at' => now()])->save();
$pressRelease->save();
$pressRelease->lockSync('Kleine Korrektur '.$legalRequest->reference());
$job = $this->queueArchive($pressRelease, $snapshot, $legalRequest, $actor, 'Kleine Korrektur (Selbstbedienung)');
return [$legalRequest, $snapshot];
return [$legalRequest, $snapshot, $job];
});
WebCaches::forgetSitemaps();
$entry = $this->archiveChange($pressRelease, $snapshot, $legalRequest, $actor, 'Kleine Korrektur (Selbstbedienung)');
$entry = $this->archiveChange($pressRelease, $job, $snapshot, $legalRequest, $actor);
$changedFields = collect($classification['fields'])
->filter(fn (array $meta): bool => $meta['changed'])
@ -238,7 +242,7 @@ class CorrectionService
return false;
}
$snapshot = DB::transaction(function () use ($pressRelease, $legalRequest, $classification, $newValues): PressReleaseRedactionSnapshot {
[$snapshot, $job] = DB::transaction(function () use ($pressRelease, $legalRequest, $classification, $newValues, $actor): array {
$legalRequest->forceFill([
'status' => LegalRequestStatus::Resolved->value,
'payload' => array_merge($legalRequest->payload ?? [], ['fields' => $classification['fields']]),
@ -251,15 +255,16 @@ class CorrectionService
], $legalRequest);
$this->writeChangedFields($pressRelease, $classification['fields'], $newValues);
$pressRelease->forceFill(['archive_pending_at' => now()])->save();
$pressRelease->save();
$pressRelease->lockSync('Kostenpflichtige Änderung '.$legalRequest->reference());
$job = $this->queueArchive($pressRelease, $snapshot, $legalRequest, $actor, 'Kostenpflichtige Änderung');
return $snapshot;
return [$snapshot, $job];
});
WebCaches::forgetSitemaps();
$entry = $this->archiveChange($pressRelease, $snapshot, $legalRequest, $actor, 'Kostenpflichtige Änderung');
$entry = $this->archiveChange($pressRelease, $job, $snapshot, $legalRequest, $actor);
$changedFields = collect($classification['fields'])
->filter(fn (array $meta): bool => $meta['changed'])
@ -284,47 +289,54 @@ class CorrectionService
return true;
}
/**
* Beweisarchiv (F.4): Auftrag in der fachlichen Transaktion mit
* Vorher-Stand aus dem Snapshot und Nachher-Stand dieser Änderung
* (Umsetzungsaudit 29.09.2026, A-02/A-04).
*/
private function queueArchive(PressRelease $pressRelease, PressReleaseRedactionSnapshot $snapshot, LegalRequest $legalRequest, ?User $actor, string $note): ArchivePendingJob
{
return app(ArchivePendingQueue::class)->push(
(int) $pressRelease->getKey(),
ArchiveEvent::GdprChange,
$snapshot,
ArchiveReasonCategory::Correction,
$legalRequest->getKey(),
$actor?->getKey(),
$note,
[
'before' => (array) ($snapshot->encrypted_before['fields'] ?? []),
'after' => $pressRelease->only(self::CORRECTABLE_FIELDS),
],
);
}
/**
* Eintrag nach dem Commit schreiben. Scheitert die Ablage, bleibt der
* Auftrag offen und `archive:retry-pending` holt ihn nach (N11).
*/
private function archiveChange(PressRelease $pressRelease, ArchivePendingJob $job, PressReleaseRedactionSnapshot $snapshot, LegalRequest $legalRequest, ?User $actor): ?ArchiveEntry
{
$payload = (array) $job->payload;
return app(ArchivePendingQueue::class)->completeWith($job, fn () => app(PressReleaseArchiver::class)->gdprChange(
$pressRelease,
(array) ($payload['before'] ?? []),
(array) ($payload['after'] ?? []),
$legalRequest,
$actor,
ArchiveReasonCategory::Correction,
$job->note,
$snapshot->getKey(),
));
}
/**
* Schreibt die geänderten Felder (Text durch den Sanitizer) auf die PM.
*
* @param array<string, array{distance_percent: float, changed: bool}> $classifiedFields
* @param array<string, string|null> $newValues
*/
/**
* Beweisarchiv (F.4): Vorher-Stand aus dem Snapshot, Nachher-Stand aus
* der Meldung. Scheitert die Ablage, bleibt `archive_pending_at`
* stehen und `archive:retry-pending` holt den Eintrag nach (N11).
*/
private function archiveChange(PressRelease $pressRelease, PressReleaseRedactionSnapshot $snapshot, LegalRequest $legalRequest, ?User $actor, string $note): ?ArchiveEntry
{
try {
$entry = app(PressReleaseArchiver::class)->gdprChange(
$pressRelease,
(array) ($snapshot->encrypted_before['fields'] ?? []),
$pressRelease->fresh()?->only(self::CORRECTABLE_FIELDS) ?? [],
$legalRequest,
$actor,
ArchiveReasonCategory::Correction,
$note,
$snapshot->getKey(),
);
} catch (Throwable $exception) {
Log::error('Beweisarchiv: Korrektur konnte nicht archiviert werden, wird nachgeholt.', [
'press_release_id' => $pressRelease->getKey(),
'legal_request_id' => $legalRequest->getKey(),
'error' => $exception->getMessage(),
]);
return null;
}
if ($entry !== null) {
$pressRelease->forceFill(['archive_pending_at' => null])->save();
}
return $entry;
}
private function writeChangedFields(PressRelease $pressRelease, array $classifiedFields, array $newValues): void
{
foreach ($classifiedFields as $field => $meta) {