*/ private const CUSTOMER_ROLES = ['customer', 'api-only']; protected $signature = 'audit:admins {--with-emails : Vollständige E-Mail-Adressen statt nur der Domain ausgeben}'; protected $description = 'Listet alle Konten mit Admin-, Editor-, Super-Admin- oder Sonderrechten als Markdown (Nachweis BN-02).'; public function handle(): int { $users = $this->privilegedUsers(); $now = now(); $this->line(sprintf('# Berechtigungen, Stand %s', $now->format('d.m.Y H:i'))); $this->newLine(); $this->line($this->summary($users, $now)); $this->newLine(); $this->line('| ID | E-Mail | Rollen | Super-Admin | Aktiv | Gelöscht | Admin-Zugang | TOTP | Passkeys | Letzte Anmeldung | API-Tokens | Sitzungen zuletzt widerrufen |'); $this->line('|---|---|---|---|---|---|---|---|---|---|---|---|'); foreach ($users as $user) { $this->line('| '.implode(' | ', $this->row($user)).' |'); } if ($users->isEmpty()) { $this->line('| – | – | – | – | – | – | – | – | – | – | – | – |'); } $this->newLine(); $this->line('Admin-Zugang = aktiv und Super-Admin oder Rolle `admin`/`editor` (`User::canAccessAdmin()`). Sitzungen liegen in Redis und sind je Konto nicht zählbar; ein Widerruf beendet alle älteren Sitzungen.'); $this->newLine(); $this->line('**Zugänge außerhalb der Anwendung** (Ploi, Hetzner-Konsole, SSH): [eintragen]'); return self::SUCCESS; } /** * @return Collection */ private function privilegedUsers(): Collection { return User::query() ->withTrashed() ->with(['roles:id,name', 'permissions:id,name']) ->withCount(['passkeys', 'tokens']) ->withMax('tokens', 'last_used_at') ->where(fn (Builder $query) => $query ->where('is_super_admin', true) ->orWhereHas('roles', fn (Builder $roles) => $roles->whereNotIn('name', self::CUSTOMER_ROLES)) ->orWhereHas('permissions')) ->orderBy('id') ->get(); } /** * @param Collection $users */ private function summary(Collection $users, Carbon $now): string { $withAccess = $users->filter(fn (User $user): bool => ! $user->trashed() && $user->canAccessAdmin()); $withSecondFactor = $withAccess->filter(fn (User $user): bool => $this->hasSecondFactor($user)); $editors = $withAccess->filter(fn (User $user): bool => $user->roles->contains('name', 'editor')); $superAdmins = $withAccess->where('is_super_admin', true); return sprintf( 'Stand %s: %d %s mit Zugang zum Admin-Bereich, davon %d mit zweitem Faktor (TOTP oder Passkey), %d Super-Admin, %d Editor-%s. Gelistet sind %d Konten mit Rechten über den Kundenbereich hinaus, einschließlich gesperrter und gelöschter.', $now->format('d.m.Y'), $withAccess->count(), $withAccess->count() === 1 ? 'Konto' : 'Konten', $withSecondFactor->count(), $superAdmins->count(), $editors->count(), $editors->count() === 1 ? 'Konto' : 'Konten', $users->count(), ); } /** * @return list */ private function row(User $user): array { $roles = $user->roles->pluck('name') ->merge($user->permissions->pluck('name')->map(fn (string $name): string => 'direkt: '.$name)) ->implode(', '); $revokedAt = $user->sessions_revoked_at_ms ? Carbon::createFromTimestampMs($user->sessions_revoked_at_ms)->timezone(config('app.timezone'))->format('d.m.Y H:i') : '–'; $tokenLastUsedAt = $user->getAttribute('tokens_max_last_used_at'); $tokenLastUsed = $tokenLastUsedAt ? ', zuletzt '.Carbon::parse($tokenLastUsedAt)->format('d.m.Y') : ''; return [ (string) $user->id, $this->email($user), $roles !== '' ? $roles : '–', $user->is_super_admin ? 'ja' : 'nein', $user->is_active ? 'ja' : 'gesperrt', $user->trashed() ? $user->deleted_at->format('d.m.Y') : 'nein', ! $user->trashed() && $user->canAccessAdmin() ? 'ja' : 'nein', $user->two_factor_confirmed_at ? 'ja' : 'nein', (string) $user->passkeys_count, $user->last_login_at?->format('d.m.Y H:i') ?? '–', $user->tokens_count.$tokenLastUsed, $revokedAt, ]; } private function hasSecondFactor(User $user): bool { return $user->two_factor_confirmed_at !== null || $user->passkeys_count > 0; } private function email(User $user): string { if ($this->option('with-emails')) { return (string) $user->email; } return '…@'.Str::after((string) $user->email, '@'); } }