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>
This commit is contained in:
Kevin Adametz 2026-09-18 10:19:28 +00:00
parent eeb1cde48d
commit 301bca3a90
10 changed files with 596 additions and 24 deletions

View file

@ -0,0 +1,145 @@
<?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;
}
}

View file

@ -214,7 +214,7 @@ class Company extends Model
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class)
->withPivot('role')
->withPivot('role', 'granted_via')
->withTimestamps();
}

View file

@ -15,6 +15,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Laravel\Cashier\Billable;
use Laravel\Fortify\TwoFactorAuthenticatable;
@ -373,7 +374,7 @@ class User extends Authenticatable implements MustVerifyEmail
public function companies(): BelongsToMany
{
return $this->belongsToMany(Company::class)
->withPivot('role')
->withPivot('role', 'granted_via')
->withTimestamps();
}
@ -601,10 +602,24 @@ class User extends Authenticatable implements MustVerifyEmail
*/
public function syncCompaniesWithRoles(array $companyIds, array $roles): void
{
// Herkunft (Audit F10): Was der Admin neu anlegt oder in der Rolle
// ändert, ist `manual` und bleibt vom automatischen Entzug unberührt.
// Unveränderte Zeilen behalten ihre Herkunft – sonst würde jedes
// Speichern des Formulars alle automatischen Rechte zementieren.
$current = DB::table('company_user')
->where('user_id', $this->getKey())
->pluck('role', 'company_id')
->mapWithKeys(fn (string $role, int|string $companyId): array => [(int) $companyId => $role]);
$payload = [];
foreach ($companyIds as $companyId) {
$payload[$companyId] = ['role' => $roles[$companyId] ?? 'member'];
$role = $roles[$companyId] ?? 'member';
$payload[$companyId] = ['role' => $role];
if (! isset($current[(int) $companyId]) || $current[(int) $companyId] !== $role) {
$payload[$companyId]['granted_via'] = 'manual';
}
}
$this->companies()->sync($payload);

View file

@ -9,6 +9,7 @@ use App\Models\Contact;
use App\Models\User;
use App\Scopes\PortalScope;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
/**
@ -106,6 +107,11 @@ class ContactAccessService
* Konto, das seine E-Mail gerade auf eine fremde Firmen-Adresse gewechselt
* hat, ist unverifiziert und erhält nichts – der Login-Event feuert auch
* vor der Verifizierungs-Weiche. Deaktivierte Konten ebenfalls nicht.
*
* Gegenrichtung (Audit F10, E2): automatisch vergebene `responsible`-Rechte,
* deren E-Mail-Grundlage weggefallen ist (Adresse an Firma oder Kontakt
* getauscht, Kontakt gelöscht), fallen auf `member` zurück. Manuell oder
* aus dem Altbestand vergebene Rechte (`granted_via = manual`) bleiben.
*/
public function syncMembershipsForEmail(User $user): void
{
@ -121,12 +127,31 @@ class ContactAccessService
$contacts = $this->contactsFor($email);
$companies = $this->companiesFor($email);
$companyIds = $this->companyIdsFrom($contacts, $companies);
if ($contacts->isEmpty() && $companies->isEmpty()) {
$this->revokeStaleAutomaticGrants($user, $companyIds);
if ($companyIds->isEmpty()) {
return;
}
$this->linkMemberships($user, $this->companyIdsFrom($contacts, $companies), $contacts);
$this->linkMemberships($user, $companyIds, $contacts);
}
/**
* Senkt automatisch vergebene `responsible`-Pivots ohne aktuellen
* E-Mail-Treffer auf `member`. Lesezugriff bleibt, Schreibzugriff geht.
*
* @param Collection<int, int> $currentCompanyIds
*/
private function revokeStaleAutomaticGrants(User $user, Collection $currentCompanyIds): void
{
DB::table('company_user')
->where('user_id', $user->id)
->where('role', 'responsible')
->where('granted_via', 'auto')
->whereNotIn('company_id', $currentCompanyIds->all())
->update(['role' => 'member', 'updated_at' => now()]);
}
/**
@ -256,10 +281,11 @@ class ContactAccessService
$linked = $user->companies()->withoutGlobalScope(PortalScope::class)->whereKey($companyId)->first();
if ($linked === null) {
$user->companies()->attach($companyId, ['role' => 'responsible']);
$user->companies()->attach($companyId, ['role' => 'responsible', 'granted_via' => 'auto']);
} elseif ($linked->pivot->role === 'member') {
// Nur den Read-only-Default hochstufen; owner/responsible bleiben.
$user->companies()->updateExistingPivot($companyId, ['role' => 'responsible']);
// Die Hochstufung ist automatisch und damit wieder rücknehmbar.
$user->companies()->updateExistingPivot($companyId, ['role' => 'responsible', 'granted_via' => 'auto']);
}
}