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:
parent
eeb1cde48d
commit
301bca3a90
10 changed files with 596 additions and 24 deletions
145
app/Console/Commands/AuditCompanyMemberships.php
Normal file
145
app/Console/Commands/AuditCompanyMemberships.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -214,7 +214,7 @@ class Company extends Model
|
|||
public function users(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(User::class)
|
||||
->withPivot('role')
|
||||
->withPivot('role', 'granted_via')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue