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

@ -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);