Admin ▸ Pressemitteilung zeigt laufende Boosts; „Boost beenden“ verlangt einen Grund und billing:manage, beendet laufende und angeschlossene Zeiträume, verwirft die Seiten-Caches und protokolliert „Boost beendet“. Credits werden nicht zurückgebucht. audit:admins listet alle Konten mit Rechten über den Kundenbereich hinaus (Super-Admin, Rollen außer customer/api-only, direkte Permissions), auch gesperrte und gelöschte, als Markdown mit 2FA, Passkeys, Tokens und letztem Sitzungswiderruf. E-Mail standardmäßig nur mit Domain (Nachweis BN-02). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
149 lines
5.9 KiB
PHP
149 lines
5.9 KiB
PHP
<?php
|
||
|
||
namespace App\Console\Commands;
|
||
|
||
use App\Models\User;
|
||
use Illuminate\Console\Command;
|
||
use Illuminate\Database\Eloquent\Builder;
|
||
use Illuminate\Database\Eloquent\Collection;
|
||
use Illuminate\Support\Carbon;
|
||
use Illuminate\Support\Str;
|
||
|
||
/**
|
||
* Berechtigungsnachweis BN-02 (Rechtspaket 10/2026): listet jedes Konto, das
|
||
* über den Kundenbereich hinaus Rechte hat — Super-Admin-Merker, eine Rolle
|
||
* außer `customer`/`api-only` oder direkt vergebene Permissions —, auch
|
||
* gesperrte und gelöschte. Ausgabe als Markdown für die Ablage unter
|
||
* `dev/rechtliches/nachweise/`; jährlich wiederholen.
|
||
*
|
||
* Sitzungen liegen auf Live in Redis und lassen sich nicht je Konto zählen;
|
||
* die Liste zeigt stattdessen den letzten Widerruf (`sessions_revoked_at_ms`).
|
||
*
|
||
* E-Mail-Adressen erscheinen standardmäßig nur mit Domain, damit die Datei
|
||
* ins Repository darf. Der Befehl ändert nichts.
|
||
*/
|
||
class AuditAdmins extends Command
|
||
{
|
||
/** @var list<string> */
|
||
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<int, User>
|
||
*/
|
||
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<int, User> $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<string>
|
||
*/
|
||
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, '@');
|
||
}
|
||
}
|