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
46b89e1d23
commit
0240c143c8
30 changed files with 1746 additions and 6 deletions
112
app/Models/ArchiveEntry.php
Normal file
112
app/Models/ArchiveEntry.php
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
<?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',
|
||||
'event',
|
||||
'actor_type',
|
||||
'actor_id',
|
||||
'reason_category',
|
||||
'summary',
|
||||
'payload_path',
|
||||
'payload_hash',
|
||||
'payload_bytes',
|
||||
'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');
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue