presseportale/app/Console/Commands/AuditCompanyMemberships.php
Kevin Adametz 05cf53e17d 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>
2026-09-18 12:03:04 +00:00

176 lines
7.8 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
/**
* Bestandsprüfung der `responsible`-Firmenzuordnungen (Audit F01/F10, Plan 1.4).
*
* Erklärbar ist eine Verantwortlichkeit, wenn die Konto-Adresse heute an der
* Firma selbst oder an einem ihrer Pressekontakte hinterlegt ist – genau die
* Grundlage, aus der der Login-Sync sie vergibt. Alles andere wird gelistet:
* manuell vergeben, aus dem Altbestand, oder nach einem Adresswechsel stehen
* geblieben. Der Befehl löscht nichts.
*
* `--classify` setzt bei den erklärbaren Zeilen `granted_via = auto`, damit
* 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}
{--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.';
public function handle(): int
{
$total = DB::table('company_user')->where('role', 'responsible')->count();
$explained = $this->explainedPairs();
$unexplained = $this->responsibleRows()
->reject(fn (object $row): bool => isset($explained[$row->company_id.':'.$row->user_id]))
->values();
$this->info(sprintf('%d responsible-Zuordnungen, davon %d über eine hinterlegte E-Mail erklärbar, %d nicht.', $total, count($explained), $unexplained->count()));
$since = (string) $this->option('since');
$recent = $unexplained->filter(fn (object $row): bool => (string) $row->created_at >= $since);
if ($recent->isNotEmpty()) {
$this->line(sprintf('Nicht erklärbar und seit %s entstanden (%d, max. 50 gezeigt):', $since, $recent->count()));
$this->table(
['User', 'E-Mail', 'Firma', 'Firmenname', 'Herkunft', 'Angelegt'],
$recent->take(50)->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(),
);
}
if ($unexplained->isNotEmpty()) {
$file = sprintf('company-memberships-unexplained-%s.csv', now()->format('Y-m-d-His'));
$lines = $unexplained->map(fn (object $row): string => implode(';', [
$row->user_id, $row->email, $row->company_id,
str_replace(';', ',', (string) $row->company_name), $row->granted_via, $row->created_at,
]));
Storage::disk('local')->put($file, "user_id;email;company_id;company_name;granted_via;created_at\n".$lines->implode("\n"));
$this->line(sprintf('Vollständige Liste: storage/app/%s', $file));
}
if (! $this->option('classify')) {
$this->info('Keine Änderung geschrieben. Mit --classify werden die erklärbaren Zeilen als auto markiert.');
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 ($candidates->chunk(500) as $chunk) {
foreach ($chunk as $row) {
$marked += DB::table('company_user')
->where('company_id', $row->company_id)
->where('user_id', $row->user_id)
->where('role', 'responsible')
->where('granted_via', '!=', 'auto')
->update(['granted_via' => 'auto']);
}
}
$this->info(sprintf('%d Zuordnungen als automatisch (auto) markiert.', $marked));
return self::SUCCESS;
}
/**
* Alle responsible-Zeilen mit Konto-Adresse und Firmenname.
*
* @return Collection<int, \stdClass>
*/
private function responsibleRows(): Collection
{
return DB::table('company_user')
->join('users', 'users.id', '=', 'company_user.user_id')
->join('companies', 'companies.id', '=', 'company_user.company_id')
->where('company_user.role', 'responsible')
->orderBy('company_user.created_at')
->get([
'company_user.company_id', 'company_user.user_id', 'company_user.granted_via', 'company_user.created_at',
'users.email', 'companies.name as company_name',
]);
}
/**
* (company_id, user_id)-Paare, die über Firmen- oder Kontakt-Adresse
* erklärbar sind. Einmaliger Lauf, daher LOWER() beidseitig vertretbar.
*
* @return array<string, array{company_id: int, user_id: int}>
*/
private function explainedPairs(): array
{
$viaCompany = DB::table('company_user')
->join('users', 'users.id', '=', 'company_user.user_id')
->join('companies', 'companies.id', '=', 'company_user.company_id')
->where('company_user.role', 'responsible')
->whereRaw('LOWER(companies.email) = LOWER(users.email)');
$viaContact = DB::table('company_user')
->join('users', 'users.id', '=', 'company_user.user_id')
->join('contacts', function ($join): void {
$join->on('contacts.company_id', '=', 'company_user.company_id')
->whereRaw('LOWER(contacts.email) = LOWER(users.email)')
->whereNull('contacts.deleted_at');
})
->where('company_user.role', 'responsible');
$pairs = [];
foreach ([$viaCompany, $viaContact] as $query) {
foreach ($query->distinct()->get(['company_user.company_id', 'company_user.user_id']) as $row) {
$pairs[$row->company_id.':'.$row->user_id] = ['company_id' => (int) $row->company_id, 'user_id' => (int) $row->user_id];
}
}
return $pairs;
}
}