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>
145 lines
6.3 KiB
PHP
145 lines
6.3 KiB
PHP
<?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;
|
||
}
|
||
}
|