security/api/betrieb: Fortify-2FA-Nebenwege gesperrt, Policy nur für aktive Super-Admins, Nachprüfungen aus dem Umsetzungsaudit (Block 7, U08–U10, U13, 7.1, 7.2, 7.5)

- Fortifys sieben 2FA-Verwaltungsrouten antworten mit 404 (Middleware über
  fortify.middleware); die Sicherheitsseite verlangt weiter den zweiten Faktor (U09)
- PressReleasePolicy/CompanyPolicy::before() bejahen nur aktive Super-Admins (7.2)
- API-Update prüft bei Portalwechsel die bestehende Kategorie gegen das neue Portal (U10)
- Aggregation: Zeitbudget 480 s unter dem Lock von 600 s, Marke nur unter
  Zeilensperre vom erwarteten Stand aus, sonst Fehler-Exit (U13)
- Newsletter-Bereinigungsmigration behält je Paar die sent-Zeile (U08)
- E-Mail-Wechsel stößt den Rechte-Sync sofort an; company-memberships:audit
  --classify zeigt vorher die Kandidaten und begrenzt mit --before (7.1)
- Detailseite wählt bei Slug-Kollision wie der Melde-Resolver (7.5)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-18 12:03:04 +00:00
parent fb03fb166d
commit 235d3928ab
18 changed files with 447 additions and 26 deletions

View file

@ -8,8 +8,10 @@ use App\Models\PressRelease;
use App\Models\PressReleaseEvent;
use App\Scopes\PortalScope;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use RuntimeException;
/**
* Schreibt die gezählten Aufrufe aus dem Ereignis-Log in den Gesamtzähler
@ -32,7 +34,9 @@ use Illuminate\Support\Facades\DB;
*/
class AggregatePressReleaseViews extends Command
{
protected $signature = 'press-releases:aggregate-views {--chunk=5000 : Ereignisse je Durchgang}';
protected $signature = 'press-releases:aggregate-views
{--chunk=5000 : Ereignisse je Durchgang}
{--budget=480 : Zeitbudget in Sekunden; der Rest wartet auf den nächsten Lauf}';
protected $description = 'Überträgt gezählte Aufrufe aus dem Ereignis-Log in press_releases.hits.';
@ -48,9 +52,18 @@ class AggregatePressReleaseViews extends Command
*/
public const LockKey = 'press-releases:aggregate-views';
/**
* Der Lock verfällt nach dieser Zeit. Das Zeitbudget (--budget) muss
* darunter liegen, sonst kann ein zweiter Lauf den Lock übernehmen,
* während der erste noch verbucht (Audit U13). Als letzte Schranke wird
* die Marke nur unter Zeilensperre und nur vom erwarteten Stand aus
* fortgeschrieben – ein fremder Stand bricht den Durchgang ab.
*/
public const LockSeconds = 600;
public function handle(): int
{
$lock = Cache::lock(self::LockKey, 600);
$lock = Cache::lock(self::LockKey, self::LockSeconds);
if (! $lock->get()) {
$this->warn('Ein anderer Aggregationslauf ist noch aktiv – dieser Aufruf tut nichts.');
@ -68,6 +81,8 @@ class AggregatePressReleaseViews extends Command
private function aggregate(): int
{
$chunk = max(100, (int) $this->option('chunk'));
$budget = min(self::LockSeconds - 60, max(0, (int) $this->option('budget')));
$startedAt = microtime(true);
$watermark = AppSetting::getValue(self::WatermarkKey);
// Fehlende Marke heißt: noch nie initialisiert. Sie jetzt als 0 zu
@ -91,6 +106,12 @@ class AggregatePressReleaseViews extends Command
$totalReleases = 0;
while (true) {
if (microtime(true) - $startedAt > $budget) {
$this->warn(sprintf('Zeitbudget von %d s erreicht – der Rest folgt im nächsten Lauf.', $budget));
break;
}
// Obergrenze des Durchgangs zuerst festlegen, damit gleichzeitig
// eintreffende Aufrufe die Summe nicht gegen die Marke verschieben.
// Bewusst über die IDs statt über max(): eine Aggregatfunktion
@ -121,19 +142,14 @@ class AggregatePressReleaseViews extends Command
// Zähler schon erhöht, die Marke steht aber noch auf dem alten
// Stand — der nächste Lauf verbuchte genau diese Aufrufe ein
// zweites Mal.
DB::transaction(function () use ($counts, $upperBound, &$totalEvents, &$totalReleases): void {
foreach ($counts as $pressReleaseId => $views) {
PressRelease::query()
->withoutGlobalScope(PortalScope::class)
->whereKey((int) $pressReleaseId)
->increment('hits', (int) $views);
try {
$this->bookChunk($counts, $watermark, $upperBound, $totalEvents, $totalReleases);
} catch (RuntimeException $exception) {
report($exception);
$this->error($exception->getMessage());
$totalReleases++;
$totalEvents += (int) $views;
}
AppSetting::setValue(self::WatermarkKey, $upperBound);
});
return self::FAILURE;
}
$watermark = $upperBound;
}
@ -142,4 +158,40 @@ class AggregatePressReleaseViews extends Command
return self::SUCCESS;
}
/**
* Zähler und Marke in EINER Transaktion; siehe Kommentar im Aufrufer.
*
* @param Collection<int|string, int|string> $counts
*/
private function bookChunk(Collection $counts, int $watermark, int $upperBound, int &$totalEvents, int &$totalReleases): void
{
DB::transaction(function () use ($counts, $watermark, $upperBound, &$totalEvents, &$totalReleases): void {
// Marke unter Zeilensperre gegen den erwarteten Stand prüfen:
// hat ein anderer Lauf sie inzwischen verschoben (Lock
// verfallen), würde dieser Durchgang dieselben Ereignisse ein
// zweites Mal verbuchen (Audit U13).
$current = AppSetting::query()->whereKey(self::WatermarkKey)->lockForUpdate()->first();
if ($current === null || (int) $current->value !== $watermark) {
throw new RuntimeException(sprintf(
'Wasserstandsmarke steht auf %s statt %d – ein anderer Lauf hat übernommen, Durchgang verworfen.',
$current === null ? 'null' : (string) $current->value,
$watermark,
));
}
foreach ($counts as $pressReleaseId => $views) {
PressRelease::query()
->withoutGlobalScope(PortalScope::class)
->whereKey((int) $pressReleaseId)
->increment('hits', (int) $views);
$totalReleases++;
$totalEvents += (int) $views;
}
AppSetting::setValue(self::WatermarkKey, $upperBound);
});
}
}

View file

@ -20,12 +20,19 @@ use Illuminate\Support\Facades\Storage;
* der Login-Sync sie zurücknehmen kann, sobald die Grundlage wegfällt. Nicht
* erklärbare Zeilen bleiben `manual` und damit geschützt – die Entscheidung
* je Zeile trifft ein Mensch anhand der Liste.
*
* Die Klassifizierung ist eine Heuristik (Audit 7.1): eine bewusst manuell
* vergebene Verantwortlichkeit mit zufällig passender Adresse wird ebenfalls
* als auto markiert. Deshalb zeigt --classify vorher die betroffenen Zeilen,
* und --before begrenzt den Lauf auf Zuordnungen aus der Zeit vor dem Deploy
* von `granted_via` – alles Jüngere trägt seine Herkunft bereits.
*/
class AuditCompanyMemberships extends Command
{
protected $signature = 'company-memberships:audit
{--classify : Erklärbare responsible-Zuordnungen als automatisch (auto) markieren}
{--since=2026-07-20 : Nur Zuordnungen ab diesem Datum in der Tabelle zeigen (Go-Live); die Datei enthält alle}';
{--since=2026-07-20 : Nur Zuordnungen ab diesem Datum in der Tabelle zeigen (Go-Live); die Datei enthält alle}
{--before= : --classify nur für Zuordnungen, die vor diesem Zeitpunkt entstanden sind (Deploy von granted_via); danach ist die Herkunft bereits gespeichert}';
protected $description = 'Listet responsible-Firmenzuordnungen ohne aktuelle E-Mail-Grundlage und markiert erklärbare als automatisch vergeben.';
@ -73,13 +80,37 @@ class AuditCompanyMemberships extends Command
return self::SUCCESS;
}
$before = (string) $this->option('before');
$candidates = $this->responsibleRows()
->filter(fn (object $row): bool => isset($explained[$row->company_id.':'.$row->user_id]))
->where('granted_via', '!=', 'auto')
->filter(fn (object $row): bool => $before === '' || (string) $row->created_at < $before)
->values();
$this->line(sprintf(
'Umzustufen auf auto: %d Zuordnungen%s (Stichprobe, max. 20):',
$candidates->count(),
$before !== '' ? sprintf(' vor %s', $before) : '',
));
$this->table(
['User', 'E-Mail', 'Firma', 'Firmenname', 'Herkunft', 'Angelegt'],
$candidates->take(20)->map(fn (object $row): array => [
$row->user_id,
$row->email,
$row->company_id,
mb_strimwidth((string) $row->company_name, 0, 40, '…'),
$row->granted_via,
substr((string) $row->created_at, 0, 16),
])->all(),
);
$marked = 0;
foreach (collect($explained)->chunk(500) as $chunk) {
foreach ($chunk as $pair) {
foreach ($candidates->chunk(500) as $chunk) {
foreach ($chunk as $row) {
$marked += DB::table('company_user')
->where('company_id', $pair['company_id'])
->where('user_id', $pair['user_id'])
->where('company_id', $row->company_id)
->where('user_id', $row->user_id)
->where('role', 'responsible')
->where('granted_via', '!=', 'auto')
->update(['granted_via' => 'auto']);