Audit: dev/sicherheit/SICHERHEITSAUDIT-2026-10-07.md, Plan und Entscheidungen: dev/sicherheit/ENTWICKLUNGSPLAN-SICHERHEIT-2026-10-07.md. - S-01 Legacy-Rechnungen fremder Kunden nur mit billing:manage (auch Kundenroute). - S-02 Kontaktzuordnung auf der Firmenanzeige verlangt companies:write, $id gesperrt. - S-03 CompanyMembershipObserver: automatische Firmenrechte werden bei jeder Adress-/Zuordnungsänderung sofort neu bewertet; memberships:resync-auto. - S-04 Passwortwechsel beendet alle anderen Sessions (PasswordChanger), Reset alle Sessions und offene Login-Links; Fortify PUT /user/password abgeschaltet. API-Tokens bleiben bestehen. - S-05 Google: email_verified Pflicht, feste Bindung über die Provider-ID; Erstverknüpfung bestehender Konten außerhalb gmail/hd nur per Bestätigungslink in derselben Browser-Session. - S-06 Frischer Identitätsnachweis (Passwort, App-Code, E-Mail-Code oder Passkey) vor E-Mail-Wechsel, erster 2FA-Einrichtung und Token-Erzeugung; Hinweis-Mails an die (bisherige) Kontoadresse. - Entscheidung 5: Passkeys (Fortify, @laravel/passkeys) für alle; Super-Admins und Admins bestätigen die Session für den Admin-Bereich per Passkey oder App-Code (EnsureStrongAdminAuthentication, Notfallschalter AUTH_REQUIRE_STRONG_ADMIN); Reset des zweiten Faktors durch Super-Admins. Neue Migration: create_passkeys_table. Suite 2.403 grün (1 übersprungen), Larastan ohne Meldung. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
105 lines
3.5 KiB
PHP
105 lines
3.5 KiB
PHP
<?php
|
|
|
|
namespace App\Observers;
|
|
|
|
use App\Models\Company;
|
|
use App\Models\Contact;
|
|
use App\Models\User;
|
|
use App\Services\Auth\ContactAccessService;
|
|
use App\Support\EmailQuery;
|
|
use Illuminate\Database\Eloquent\Collection;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
/**
|
|
* Bewertet automatisch vergebene Firmenrechte neu, sobald sich ihre
|
|
* Grundlage ändert (Sicherheitsaudit 07.10.2026, S-03): Kontakt- oder
|
|
* Firmenadresse geändert, Kontakt in eine andere Firma verschoben, gelöscht
|
|
* oder wiederhergestellt, Login-Adresse eines Users geändert.
|
|
*
|
|
* Bisher geschah das nur beim Login — ein aktiver Nutzer behielt entzogene
|
|
* Rechte so lange, wie seine Session lief. Am Model statt in den
|
|
* Formularen: Kunden-Newsroom, Admin-CRM, API und Importer schreiben dieselben
|
|
* Felder. Läuft synchron in derselben Transaktion; ein Rollback nimmt die
|
|
* Rechteänderung mit zurück. Massen-Updates per Query-Builder lösen keine
|
|
* Events aus — dafür `php artisan memberships:resync-auto`.
|
|
*/
|
|
class CompanyMembershipObserver
|
|
{
|
|
public function __construct(private readonly ContactAccessService $contactAccess) {}
|
|
|
|
public function updated(Model $model): void
|
|
{
|
|
if ($model instanceof User) {
|
|
if ($model->wasChanged('email')) {
|
|
$this->contactAccess->syncMembershipsForEmail($model);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
$relevant = $model instanceof Contact ? ['email', 'company_id'] : ['email'];
|
|
|
|
if (! $model->wasChanged($relevant)) {
|
|
return;
|
|
}
|
|
|
|
$this->resync(
|
|
emails: [$model->getOriginal('email'), $model->getAttribute('email')],
|
|
companyIds: $this->companyIdsOf($model, withOriginal: true),
|
|
);
|
|
}
|
|
|
|
public function deleted(Model $model): void
|
|
{
|
|
if ($model instanceof User) {
|
|
return;
|
|
}
|
|
|
|
$this->resync([$model->getAttribute('email')], $this->companyIdsOf($model));
|
|
}
|
|
|
|
public function restored(Model $model): void
|
|
{
|
|
$this->deleted($model);
|
|
}
|
|
|
|
/**
|
|
* @return list<int>
|
|
*/
|
|
private function companyIdsOf(Company|Contact|Model $model, bool $withOriginal = false): array
|
|
{
|
|
$ids = $model instanceof Contact
|
|
? [$model->getAttribute('company_id'), $withOriginal ? $model->getOriginal('company_id') : null]
|
|
: [$model->getKey()];
|
|
|
|
return array_values(array_unique(array_map('intval', array_filter($ids))));
|
|
}
|
|
|
|
/**
|
|
* Alle User, deren automatische Rechte an den betroffenen Firmen hängen,
|
|
* sowie alle User mit einer der betroffenen Adressen.
|
|
*
|
|
* @param list<string|null> $emails
|
|
* @param list<int> $companyIds
|
|
*/
|
|
private function resync(array $emails, array $companyIds): void
|
|
{
|
|
$userIds = $companyIds === [] ? collect() : DB::table('company_user')
|
|
->whereIn('company_id', $companyIds)
|
|
->whereIn('granted_via', ['auto', 'auto-upgrade'])
|
|
->pluck('user_id');
|
|
|
|
/** @var Collection<int, User> $users */
|
|
$users = User::query()->whereIn('id', $userIds->all())->get();
|
|
|
|
foreach (array_unique(array_filter(array_map(
|
|
fn (?string $email): string => mb_strtolower(trim((string) $email)),
|
|
$emails,
|
|
))) as $email) {
|
|
$users = $users->merge(EmailQuery::whereEmail(User::query(), $email)->get());
|
|
}
|
|
|
|
$users->unique('id')->each(fn (User $user) => $this->contactAccess->syncMembershipsForEmail($user));
|
|
}
|
|
}
|