presseportale/app/Models/ArchiveEntry.php
Kevin Adametz 60650baeb8 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>
2026-10-02 13:09:01 +00:00

114 lines
3.3 KiB
PHP

<?php
namespace App\Models;
use App\Enums\ArchiveEvent;
use App\Enums\ArchiveReasonCategory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Support\Carbon;
use LogicException;
/**
* Eintrag im Beweisarchiv (Bildkennzeichnung Block F). Append-only: Nur
* legal_hold-Felder, retain_until (bei Aufhebung) und der Tombstone dürfen
* sich ändern; alles andere wirft. Löschen von Zeilen ist ausgeschlossen —
* der Tombstone bleibt.
*/
class ArchiveEntry extends Model
{
public const UPDATED_AT = null;
/** @var list<string> */
private const MUTABLE = ['legal_hold', 'legal_hold_note', 'legal_hold_set_by', 'legal_hold_set_at', 'retain_until', 'deleted_at', 'payload_path'];
protected $fillable = [
'subject_type',
'subject_id',
'press_release_id',
'legal_request_id',
'event',
'actor_type',
'actor_id',
'reason_category',
'summary',
'payload_path',
'payload_hash',
'payload_bytes',
'key_id',
'retain_until',
'legal_hold',
'legal_hold_note',
'legal_hold_set_by',
'legal_hold_set_at',
'deleted_at',
'created_at',
];
protected function casts(): array
{
return [
'event' => ArchiveEvent::class,
'reason_category' => ArchiveReasonCategory::class,
'summary' => 'array',
'retain_until' => 'date',
'legal_hold' => 'boolean',
'legal_hold_set_at' => 'datetime',
'deleted_at' => 'datetime',
'created_at' => 'datetime',
];
}
protected static function booted(): void
{
static::updating(function (self $entry): void {
$illegal = array_diff(array_keys($entry->getDirty()), self::MUTABLE);
if ($illegal !== []) {
throw new LogicException('archive_entries ist append-only; geändert werden dürfen nur legal_hold, retain_until und der Tombstone (versucht: '.implode(', ', $illegal).').');
}
});
static::deleting(fn () => throw new LogicException('archive_entries werden nie gelöscht; der Tombstone bleibt.'));
}
public function isTombstoned(): bool
{
return $this->deleted_at !== null;
}
/**
* Frist: 31.12. des Bezugsjahres plus die konfigurierten Jahre.
*/
public static function retentionEndFor(\DateTimeInterface $reference): Carbon
{
return Carbon::instance($reference)->endOfYear()->addYears((int) config('archive.retention_years', 3))->startOfDay();
}
/**
* @param Builder<ArchiveEntry> $query
* @return Builder<ArchiveEntry>
*/
public function scopeDueForPurge(Builder $query): Builder
{
return $query->whereNull('deleted_at')->where('legal_hold', false)->whereDate('retain_until', '<', today());
}
/**
* @return MorphTo<Model, $this>
*/
public function subject(): MorphTo
{
return $this->morphTo();
}
/**
* @return HasMany<ArchiveAccessLog, $this>
*/
public function accessLog(): HasMany
{
return $this->hasMany(ArchiveAccessLog::class, 'entry_id');
}
}