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 a7f991f01e
commit 62202a4b13
10 changed files with 596 additions and 24 deletions

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']);
}
}