admin: Boost vorzeitig beenden, Berechtigungsliste audit:admins

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>
This commit is contained in:
Kevin Adametz 2026-10-13 10:16:21 +00:00
parent 2150d4b7ae
commit fce7baea2b
6 changed files with 419 additions and 0 deletions

View file

@ -0,0 +1,149 @@
<?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, '@');
}
}

View file

@ -47,6 +47,9 @@ enum ActivityType: string
/** Cloudflare Turnstile war nicht erreichbar — Formular durchgelassen, bitte nachprüfen (Spamschutz). */
case BotCheckUnavailable = 'bot_check_unavailable';
/** Admin hat eine gebuchte Platzierung vorzeitig beendet. */
case BoostEnded = 'boost_ended';
public function label(): string
{
return match ($this) {
@ -77,6 +80,7 @@ enum ActivityType: string
self::LoginLockout => 'Anmeldesperre',
self::TwoFactorFailed => '2FA fehlgeschlagen',
self::BotCheckUnavailable => 'Ohne Bot-Prüfung',
self::BoostEnded => 'Boost beendet',
};
}
@ -107,6 +111,7 @@ enum ActivityType: string
self::LoginFailed, self::TwoFactorFailed => 'orange',
self::LoginLockout => 'red',
self::BotCheckUnavailable => 'orange',
self::BoostEnded => 'amber',
};
}
}

View file

@ -2,6 +2,7 @@
namespace App\Services\PressRelease;
use App\Enums\ActivityType;
use App\Enums\BoostPlacement;
use App\Enums\Portal;
use App\Enums\PressReleaseClassification;
@ -11,6 +12,7 @@ use App\Models\Boost;
use App\Models\PressRelease;
use App\Models\User;
use App\Scopes\PortalScope;
use App\Services\ActivityLog;
use App\Services\Billing\CreditPricingService;
use App\Services\Billing\CreditWalletService;
use App\Support\WebPageCache;
@ -35,6 +37,7 @@ class BoostService
public function __construct(
private readonly CreditPricingService $pricing,
private readonly CreditWalletService $wallet,
private readonly ActivityLog $activityLog,
) {}
/**
@ -180,6 +183,52 @@ class BoostService
return $boost;
}
/**
* Beendet alle laufenden und bereits angeschlossenen Boosts einer PM
* sofort (Admin ▸ Pressemitteilung), etwa nach einer Test-Buchung oder
* wenn die Platzierung nicht mehr vertretbar ist. Die Zeilen bleiben als
* Buchungsnachweis erhalten; Credits werden nicht zurückgebucht —
* Erstattungen laufen getrennt.
*
* @return int Anzahl der beendeten Boosts
*/
public function endActive(PressRelease $pressRelease, User $endedBy, string $reason): int
{
$now = now();
$boosts = $pressRelease->boosts()->active()->get();
if ($boosts->isEmpty()) {
return 0;
}
foreach ($boosts as $boost) {
$boost->update([
'starts_at' => $boost->starts_at->isFuture() ? $now : $boost->starts_at,
'ends_at' => $now,
]);
}
$boosts->pluck('placement')->unique()->each(
fn (BoostPlacement $placement) => $this->forgetPlacementCaches($pressRelease, $placement),
);
$this->activityLog->record(
ActivityType::BoostEnded,
sprintf(
'%s hat %d gebuchte Platzierung(en) von PM #%d vorzeitig beendet. Grund: %s',
$endedBy->name,
$boosts->count(),
$pressRelease->id,
$reason,
),
user: $endedBy,
subject: $pressRelease,
meta: ['boost_ids' => $boosts->modelKeys(), 'ended_by' => $endedBy->id, 'reason' => $reason],
);
return $boosts->count();
}
/**
* Serialisiert Käufe, die dasselbe sichtbare Slot-Inventar verändern.
*