security: Rechteausweitung schließen & Admin-Panel feingranular gaten (K1, H1, H2, M8)

- users:manage/roles:manage auf allen User-/Rollen-Masken erzwungen;
  is_super_admin aus $fillable entfernt und nur per autorisiertem
  forceFill setzbar; zentraler Gate::before-Super-Admin-Kurzschluss (K1/M8).
- Nicht-Super-Admins können Super-Admin-Konten nicht mehr bearbeiten (Fix#2);
  Impersonation mit Rangprüfung, keine Token-Erstellung während Impersonation (H1).
- Destruktive/verwaltende Admin-Aktionen (CRM/Billing/Compliance/Struktur)
  hängen an neuen admin-only Permissions; Editor behält reine Content-Rechte (H2).
- Kanonischer Permission-Katalog (App\Authorization\Permission) als Single
  Source of Truth für Seeder und Monitoring.

Deployment: `php artisan db:seed --class=RolesAndPermissionsSeeder` auf allen
Umgebungen ausführen (neue Permissions).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-14 13:48:50 +00:00
parent 39e13d6d84
commit 77f8f9cb35
34 changed files with 748 additions and 100 deletions

View file

@ -40,6 +40,7 @@
? app(\App\Services\Admin\AdminPerformanceCache::class)->pressReleaseReviewCount()
: 0;
$legalOpenCount = $canAdmin ? \App\Models\LegalRequest::query()->open()->count() : 0;
$contactOpenCount = $canAdmin ? \App\Models\ContactMessage::query()->open()->count() : 0;
@endphp
<flux:navlist variant="outline">
@ -115,14 +116,18 @@
:badge="$reviewCount > 0 ? $reviewCount : null" badge-color="yellow" wire:navigate>
{{ __('Pressemitteilungen') }}
</flux:navlist.item>
<flux:navlist.item icon="folder" :href="route('admin.categories.index')"
:current="request()->routeIs('admin.categories.*')" wire:navigate>
{{ __('Kategorien') }}
</flux:navlist.item>
<flux:navlist.item icon="code-bracket-square" :href="route('admin.footer-codes.index')"
:current="request()->routeIs('admin.footer-codes.*')" wire:navigate>
{{ __('Footer-Codes') }}
</flux:navlist.item>
@can('categories:manage')
<flux:navlist.item icon="folder" :href="route('admin.categories.index')"
:current="request()->routeIs('admin.categories.*')" wire:navigate>
{{ __('Kategorien') }}
</flux:navlist.item>
@endcan
@can('footer-codes:manage')
<flux:navlist.item icon="code-bracket-square" :href="route('admin.footer-codes.index')"
:current="request()->routeIs('admin.footer-codes.*')" wire:navigate>
{{ __('Footer-Codes') }}
</flux:navlist.item>
@endcan
</flux:navlist.group>
{{-- CRM --}}
@ -137,16 +142,28 @@
</flux:navlist.item>
</flux:navlist.group>
{{-- Recht & Compliance --}}
<flux:navlist.group :heading="__('Recht & Compliance')" class="grid mb-4">
<flux:navlist.item icon="scale" :href="route('admin.legal-requests.index')"
:current="request()->routeIs('admin.legal-requests.*')"
:badge="$legalOpenCount > 0 ? $legalOpenCount : null" badge-color="red" wire:navigate>
{{ __('Anfragen') }}
</flux:navlist.item>
</flux:navlist.group>
@can('compliance:manage')
{{-- Kontakt: Anliegen aus dem Kontakt-Widget (Widget + Hub-Formular) --}}
<flux:navlist.group :heading="__('Kontakt')" class="grid mb-4">
<flux:navlist.item icon="chat-bubble-left-right" :href="route('admin.contact-messages.index')"
:current="request()->routeIs('admin.contact-messages.*')"
:badge="$contactOpenCount > 0 ? $contactOpenCount : null" badge-color="red" wire:navigate>
{{ __('Anliegen') }}
</flux:navlist.item>
</flux:navlist.group>
{{-- Recht & Compliance --}}
<flux:navlist.group :heading="__('Recht & Compliance')" class="grid mb-4">
<flux:navlist.item icon="scale" :href="route('admin.legal-requests.index')"
:current="request()->routeIs('admin.legal-requests.*')"
:badge="$legalOpenCount > 0 ? $legalOpenCount : null" badge-color="red" wire:navigate>
{{ __('Anfragen') }}
</flux:navlist.item>
</flux:navlist.group>
@endcan
{{-- Billing --}}
@can('billing:manage')
<flux:navlist.group :heading="__('Billing')" class="grid mb-4">
<flux:navlist.item icon="archive-box" :href="route('admin.invoices.index')"
:current="request()->routeIs('admin.invoices.*')" wire:navigate>
@ -169,6 +186,7 @@
{{ __('Newsletter Sync') }}
</flux:navlist.item>
</flux:navlist.group>
@endcan
{{-- Administration --}}
<flux:navlist.group :heading="__('Administration')" class="grid mb-4">
@ -176,22 +194,32 @@
:current="request()->routeIs('admin.presets.*')" wire:navigate>
{{ __('Voreinstellungen') }}
</flux:navlist.item>
<flux:navlist.item icon="users" :href="route('admin.users.index')"
:current="request()->routeIs('admin.users.*')" wire:navigate>
{{ __('Benutzer') }}
</flux:navlist.item>
<flux:navlist.item icon="shield-check" :href="route('admin.roles.index')"
:current="request()->routeIs('admin.roles.*')" wire:navigate>
{{ __('Rollen & Rechte') }}
</flux:navlist.item>
@can('users:manage')
<flux:navlist.item icon="users" :href="route('admin.users.index')"
:current="request()->routeIs('admin.users.*')" wire:navigate>
{{ __('Benutzer') }}
</flux:navlist.item>
@endcan
@can('roles:manage')
<flux:navlist.item icon="shield-check" :href="route('admin.roles.index')"
:current="request()->routeIs('admin.roles.*')" wire:navigate>
{{ __('Rollen & Rechte') }}
</flux:navlist.item>
@endcan
</flux:navlist.group>
{{-- Reports --}}
<flux:navlist.group :heading="__('Reports')" class="grid mb-4">
<flux:navlist.item icon="chart-bar-square" :href="route('admin.reports.slow-requests')"
:current="request()->routeIs('admin.reports.*')" wire:navigate>
:current="request()->routeIs('admin.reports.slow-requests')" wire:navigate>
{{ __('Performance') }}
</flux:navlist.item>
@can('settings:manage')
<flux:navlist.item icon="heart" :href="route('admin.reports.system-health')"
:current="request()->routeIs('admin.reports.system-health')" wire:navigate>
{{ __('Betriebsüberwachung') }}
</flux:navlist.item>
@endcan
</flux:navlist.group>
@endif
@ -387,6 +415,9 @@
{{ $slot }}
{{-- Schwebendes Kontakt-Widget (unten rechts, eingeloggter Bereich) --}}
<x-web.contact-launcher />
@persist('toast')
<flux:toast position="top end" class="pt-24" />
@endpersist

View file

@ -50,6 +50,8 @@ new #[Layout('components.layouts.app'), Title('Kategorie anlegen')] class extend
public function save(): void
{
$this->authorize('categories:manage');
$this->validate([
'slugDe' => ['nullable', 'string', 'max:120'],
'slugEn' => ['nullable', 'string', 'max:120'],

View file

@ -63,6 +63,8 @@ new #[Layout('components.layouts.app'), Title('Neue Firma')] class extends Compo
public function save(ImageService $imageService): void
{
$this->authorize('companies:write');
$this->validate();
$slug = (new Company)->generateUniqueSlug($this->company_name, ['portal' => $this->portal]);

View file

@ -103,6 +103,8 @@ new #[Layout('components.layouts.app'), Title('Firma bearbeiten')] class extends
public function update(ImageService $imageService): void
{
$this->authorize('companies:write');
$this->validate();
$company = Company::query()->find($this->companyId);
@ -186,6 +188,8 @@ new #[Layout('components.layouts.app'), Title('Firma bearbeiten')] class extends
public function deleteCompany(): void
{
$this->authorize('companies:write');
$company = Company::query()->find($this->companyId);
if (! $company) {
session()->flash('error', __('Die angeforderte Firma wurde nicht gefunden.'));

View file

@ -69,6 +69,8 @@ new #[Layout('components.layouts.app'), Title('Kontakt anlegen')] class extends
public function save(): void
{
$this->authorize('companies:write');
$validated = $this->validate([
'companyId' => ['required', 'integer', Rule::exists('companies', 'id')],
'portal' => ['required', Rule::in(array_map(static fn (Portal $portal): string => $portal->value, Portal::cases()))],

View file

@ -58,6 +58,8 @@ new #[Layout('components.layouts.app'), Title('Kontakt bearbeiten')] class exten
public function save(): void
{
$this->authorize('companies:write');
$validated = $this->validate([
'companyId' => ['required', 'integer', Rule::exists('companies', 'id')],
'portal' => ['required', Rule::in(array_map(static fn (Portal $portal): string => $portal->value, Portal::cases()))],

View file

@ -350,6 +350,8 @@ new #[Layout('components.layouts.app'), Title('Kontakte')] class extends Compone
public function deleteContactFromIndex(int $contactId): void
{
$this->authorize('companies:write');
$contact = Contact::query()->find($contactId);
if (! $contact) {
$this->notification = __('Der angeforderte Kontakt wurde nicht gefunden.');

View file

@ -35,6 +35,8 @@ new #[Layout('components.layouts.app'), Title('Footer-Code anlegen')] class exte
public function save(): void
{
$this->authorize('footer-codes:manage');
$this->validate();
DB::transaction(function (): void {

View file

@ -54,6 +54,8 @@ new #[Layout('components.layouts.app'), Title('Footer-Code bearbeiten')] class e
public function save(): void
{
$this->authorize('footer-codes:manage');
$this->validate();
$code = FooterCode::query()->findOrFail($this->id);
@ -81,6 +83,8 @@ new #[Layout('components.layouts.app'), Title('Footer-Code bearbeiten')] class e
public function delete(): void
{
$this->authorize('footer-codes:manage');
$code = FooterCode::query()->findOrFail($this->id);
$code->delete();

View file

@ -34,6 +34,8 @@ new #[Layout('components.layouts.app'), Title('Footer-Codes')] class extends Com
public function toggleActive(int $id): void
{
$this->authorize('footer-codes:manage');
$footerCode = FooterCode::query()->find($id);
if (! $footerCode) {

View file

@ -52,6 +52,8 @@ new #[Layout('components.layouts.app'), Title('Recht & Compliance')] class exten
*/
public function resolveWithdrawal(int $id): void
{
$this->authorize('compliance:manage');
WithdrawalRequest::query()->findOrFail($id)->update([
'status' => 'resolved',
'resolved_at' => now(),
@ -60,6 +62,8 @@ new #[Layout('components.layouts.app'), Title('Recht & Compliance')] class exten
public function reopenWithdrawal(int $id): void
{
$this->authorize('compliance:manage');
WithdrawalRequest::query()->findOrFail($id)->update([
'status' => 'open',
'resolved_at' => null,
@ -72,6 +76,8 @@ new #[Layout('components.layouts.app'), Title('Recht & Compliance')] class exten
*/
public function resolveTermination(int $id): void
{
$this->authorize('compliance:manage');
TerminationRequest::query()->findOrFail($id)->update([
'status' => 'resolved',
'resolved_at' => now(),
@ -80,6 +86,8 @@ new #[Layout('components.layouts.app'), Title('Recht & Compliance')] class exten
public function reopenTermination(int $id): void
{
$this->authorize('compliance:manage');
TerminationRequest::query()->findOrFail($id)->update([
'status' => 'open',
'resolved_at' => null,

View file

@ -24,22 +24,30 @@ new #[Layout('components.layouts.app'), Title('Anfrage · Recht & Compliance')]
public function markInProgress(): void
{
$this->authorize('compliance:manage');
$this->request()->update(['status' => LegalRequestStatus::InProgress->value]);
$this->notification = __('Status auf „In Bearbeitung" gesetzt.');
}
public function resolve(): void
{
$this->authorize('compliance:manage');
$this->finish(LegalRequestStatus::Resolved, __('Anfrage als erledigt markiert.'));
}
public function reject(): void
{
$this->authorize('compliance:manage');
$this->finish(LegalRequestStatus::Rejected, __('Anfrage abgelehnt.'));
}
public function reopen(): void
{
$this->authorize('compliance:manage');
$this->request()->update([
'status' => LegalRequestStatus::Open->value,
'resolved_by_user_id' => null,

View file

@ -15,6 +15,8 @@ new #[Layout('components.layouts.app'), Title('Newsletter Sync')] class extends
public function triggerDryRun(): void
{
$this->authorize('billing:manage');
$subscription = NewsletterSubscription::query()->latest('id')->first();
if ($subscription === null) {
@ -32,6 +34,8 @@ new #[Layout('components.layouts.app'), Title('Newsletter Sync')] class extends
public function triggerTestSync(): void
{
$this->authorize('billing:manage');
$subscription = NewsletterSubscription::query()->latest('id')->first();
if ($subscription === null) {

View file

@ -39,6 +39,8 @@ new #[Layout('components.layouts.app'), Title('Zahlungen')] class extends Compon
*/
public function cancelBundle(int $userId, LegacySubscriptionService $legacy): void
{
$this->authorize('billing:manage');
$user = User::query()->find($userId);
if (! $user) {

View file

@ -31,6 +31,8 @@ new #[Layout('components.layouts.app'), Title('Tarife & Pakete')] class extends
public function edit(int $planId): void
{
$this->authorize('billing:manage');
$plan = Plan::query()->findOrFail($planId);
$this->editingPlanId = $plan->id;
@ -49,6 +51,8 @@ new #[Layout('components.layouts.app'), Title('Tarife & Pakete')] class extends
public function save(StripePlanSyncService $stripeSync): void
{
$this->authorize('billing:manage');
// Deutsche Dezimal-Eingaben (49,00) für die numeric-Regel normalisieren.
$this->monthlyPrice = str_replace(',', '.', trim($this->monthlyPrice));
$this->yearlyPrice = str_replace(',', '.', trim($this->yearlyPrice));

View file

@ -19,8 +19,15 @@ new #[Layout('components.layouts.app'), Title('Neue Rolle')] class extends Compo
public string $guardName = 'web';
public function mount(): void
{
$this->authorize('roles:manage');
}
public function save(): void
{
$this->authorize('roles:manage');
$validated = $this->validate([
'name' => [
'required',

View file

@ -27,6 +27,8 @@ new #[Layout('components.layouts.app'), Title('Rolle bearbeiten')] class extends
public function mount(int $id): void
{
$this->authorize('roles:manage');
$this->id = $id;
$role = Role::query()
@ -44,6 +46,8 @@ new #[Layout('components.layouts.app'), Title('Rolle bearbeiten')] class extends
public function save(): void
{
$this->authorize('roles:manage');
$validated = $this->validate([
'name' => [
'required',

View file

@ -7,6 +7,11 @@ use Spatie\Permission\Models\Role;
new #[Layout('components.layouts.app'), Title('Rollen & Rechte')] class extends Component
{
public function mount(): void
{
$this->authorize('roles:manage');
}
public function with(): array
{
return [

View file

@ -37,6 +37,11 @@ new #[Layout('components.layouts.app'), Title('Benutzer')] class extends Compone
public ?int $viewingUserId = null;
public function mount(): void
{
$this->authorize('users:manage');
}
public function with(): array
{
$sortable = ['name', 'email', 'portal', 'is_active', 'last_login_at', 'created_at'];

View file

@ -70,6 +70,8 @@ new #[Layout('components.layouts.app'), Title('Benutzer anlegen')] class extends
public function mount(): void
{
$this->authorize('users:manage');
$this->portal = Portal::Both->value;
$this->registrationType = RegistrationType::ExistingLegacy->value;
}
@ -110,6 +112,8 @@ new #[Layout('components.layouts.app'), Title('Benutzer anlegen')] class extends
public function save(): void
{
$this->authorize('users:manage');
$validated = $this->validate([
...$this->userIdentityRules(),
...$this->userRolesAndCompaniesRules(),
@ -124,10 +128,15 @@ new #[Layout('components.layouts.app'), Title('Benutzer anlegen')] class extends
'registration_type' => $validated['registrationType'],
'language' => $validated['language'],
'is_active' => $this->isActive,
'is_super_admin' => $this->isSuperAdmin,
'password' => null,
]);
// Super-Admin-Rang darf nur ein Super-Admin vergeben (is_super_admin ist
// nicht mass-assignable, siehe User::$fillable).
if ($this->isSuperAdmin && auth()->user()->is_super_admin) {
$user->forceFill(['is_super_admin' => true])->save();
}
$user->syncRoles($validated['selectedRoles']);
$user->syncCompaniesWithRoles($validated['linkedCompanyIds'], $validated['companyRoles']);

View file

@ -136,8 +136,21 @@ new #[Layout('components.layouts.app'), Title('Benutzer bearbeiten')] class exte
'country_code' => null,
];
/**
* Verhindert, dass ein Nicht-Super-Admin (nur `users:manage`) ein
* mächtigeres Super-Admin-Konto bearbeitet — sonst ließe sich über einen
* E-Mail-/Rollen-/Aktiv-Wechsel das Konto übernehmen (analog zur
* Impersonation-Rangprüfung). Nur ein Super-Admin darf Super-Admins ändern.
*/
private function assertMayManageRank(User $user): void
{
abort_if($user->is_super_admin && ! auth()->user()->is_super_admin, 403);
}
public function mount(int $id): void
{
$this->authorize('users:manage');
$this->id = $id;
$user = User::query()
@ -150,6 +163,8 @@ new #[Layout('components.layouts.app'), Title('Benutzer bearbeiten')] class exte
])
->findOrFail($id);
$this->assertMayManageRank($user);
$this->name = $user->name;
$this->agencyName = (string) ($user->agency_name ?? '');
$this->email = $user->email;
@ -379,6 +394,8 @@ new #[Layout('components.layouts.app'), Title('Benutzer bearbeiten')] class exte
public function save(): void
{
$this->authorize('users:manage');
$validated = $this->validate([
...$this->userIdentityRules($this->id),
'profile.salutation_key' => ['nullable', 'string', 'max:20'],
@ -411,6 +428,8 @@ new #[Layout('components.layouts.app'), Title('Benutzer bearbeiten')] class exte
$user = User::query()->findOrFail($this->id);
$this->assertMayManageRank($user);
$user->update([
'name' => $validated['name'],
'agency_name' => $validated['agencyName'] ?: null,
@ -419,9 +438,14 @@ new #[Layout('components.layouts.app'), Title('Benutzer bearbeiten')] class exte
'registration_type' => $validated['registrationType'],
'language' => $validated['language'],
'is_active' => $this->isActive,
'is_super_admin' => $this->isSuperAdmin,
]);
// Super-Admin-Rang darf nur ein Super-Admin vergeben/entziehen. is_super_admin
// ist nicht mass-assignable und wird ausschließlich hier autorisiert gesetzt.
if (auth()->user()->is_super_admin) {
$user->forceFill(['is_super_admin' => $this->isSuperAdmin])->save();
}
$user->syncRoles($validated['selectedRoles']);
$profilePayload = [

View file

@ -13,6 +13,8 @@ new #[Layout('components.layouts.app'), Title('Benutzer anzeigen')] class extend
public function mount(int $id): void
{
$this->authorize('users:manage');
$this->id = $id;
if (! User::query()->whereKey($id)->exists()) {

View file

@ -1,5 +1,6 @@
<?php
use App\Actions\Admin\UserImpersonation;
use App\Services\Api\ApiAccessEligibilityService;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
@ -19,6 +20,8 @@ new #[Layout('components.layouts.app'), Title('API-Tokens')] class extends Compo
public ?string $eligibilityMessage = null;
private const IMPERSONATION_DENIAL_MESSAGE = 'Während der Benutzer-Impersonation können keine API-Tokens erstellt oder widerrufen werden.';
private const ABILITIES = [
'press-releases:read' => 'Pressemitteilungen lesen',
'press-releases:write' => 'Pressemitteilungen erstellen und bearbeiten',
@ -29,6 +32,8 @@ new #[Layout('components.layouts.app'), Title('API-Tokens')] class extends Compo
public function createToken(): void
{
$this->ensureNotImpersonating();
$eligibility = app(ApiAccessEligibilityService::class);
$denialReason = $eligibility->denialReason(auth()->user());
@ -59,6 +64,8 @@ new #[Layout('components.layouts.app'), Title('API-Tokens')] class extends Compo
public function revokeToken(int $tokenId): void
{
$this->ensureNotImpersonating();
auth()->user()
->tokens()
->whereKey($tokenId)
@ -72,17 +79,30 @@ new #[Layout('components.layouts.app'), Title('API-Tokens')] class extends Compo
{
$eligibility = app(ApiAccessEligibilityService::class);
$denialReason = $eligibility->denialReason(auth()->user());
$isImpersonating = app(UserImpersonation::class)->isActive();
return [
'abilityOptions' => self::ABILITIES,
'canCreateApiToken' => $denialReason === null,
'apiTokenDenialReason' => $denialReason,
'canCreateApiToken' => $denialReason === null && ! $isImpersonating,
'canRevokeApiTokens' => ! $isImpersonating,
'apiTokenDenialReason' => $isImpersonating
? __(self::IMPERSONATION_DENIAL_MESSAGE)
: $denialReason,
'tokens' => auth()->user()
->tokens()
->latest()
->get(['id', 'name', 'abilities', 'last_used_at', 'created_at']),
];
}
private function ensureNotImpersonating(): void
{
abort_if(
app(UserImpersonation::class)->isActive(),
403,
__(self::IMPERSONATION_DENIAL_MESSAGE),
);
}
}; ?>
<div class="space-y-8">
@ -223,6 +243,7 @@ new #[Layout('components.layouts.app'), Title('API-Tokens')] class extends Compo
icon="trash"
wire:click="revokeToken({{ $token->id }})"
wire:confirm="{{ __('Diesen API-Token wirklich widerrufen?') }}"
:disabled="! $canRevokeApiTokens"
>
{{ __('Widerrufen') }}
</flux:button>