presseportale/app/Console/Commands/AuditCompanyMemberships.php
Kevin Adametz 301bca3a90 auth: automatische Firmenrechte mit Herkunft und Entzug, Nachweis vor Sicherheitsaktionen (Audit Block 1, Deploy C)
F10: company_user.granted_via unterscheidet automatisch vergebene
Rechte (E-Mail-Sync) von manuellen und aus dem Altbestand. Beim Login
senkt der Sync automatische responsible-Rechte ohne aktuellen E-Mail-
Treffer auf member (E2) — Mitarbeiterwechsel nehmen den Schreibzugriff
mit, Lesezugriff bleibt. Das Admin-Formular markiert nur neue oder
geänderte Zeilen als manual, unveränderte behalten ihre Herkunft.

Bestandsprüfung: php artisan company-memberships:audit listet
responsible-Zuordnungen ohne E-Mail-Grundlage (Tabelle ab Go-Live, CSV
vollständig) und markiert mit --classify die erklärbaren als auto. Auf
Live nach der Migration einmal mit --classify laufen lassen; nicht
erklärbare Zeilen bleiben manual und damit geschützt.

F03 (E3): Zwei-Faktor deaktivieren, Wiederherstellungs-Codes erneuern
und die E-Mail-Änderung verlangen einen aktuellen App-Code oder einen
Wiederherstellungs-Code (der dabei verbraucht wird); ohne 2FA das
Passwort. Reine Magic-Link-Konten ohne Passwort (der Altbestand) können
die Adresse weiter ändern — die neue Adresse muss ihren Bestätigungslink
ohnehin einlösen. Fünf Fehlversuche pro Minute.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 10:19:28 +00:00

145 lines
6.3 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.
*/
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}';
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;
}
$marked = 0;
foreach (collect($explained)->chunk(500) as $chunk) {
foreach ($chunk as $pair) {
$marked += DB::table('company_user')
->where('company_id', $pair['company_id'])
->where('user_id', $pair['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;
}
}