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:
parent
39e13d6d84
commit
77f8f9cb35
34 changed files with 748 additions and 100 deletions
|
|
@ -20,7 +20,8 @@ class UserImpersonation
|
|||
return $this->canInitiate($admin)
|
||||
&& ! $admin->is($target)
|
||||
&& ! $this->isActive()
|
||||
&& $this->canBeImpersonated($target);
|
||||
&& $this->canBeImpersonated($target)
|
||||
&& $this->mayImpersonateRank($admin, $target);
|
||||
}
|
||||
|
||||
public function canBeImpersonated(User $target): bool
|
||||
|
|
@ -28,6 +29,16 @@ class UserImpersonation
|
|||
return $target->canAccessCustomer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Rang-Schutz: Ein Super-Admin darf nur von einem Super-Admin im Panel
|
||||
* angemeldet werden. Verhindert, dass ein Admin (users:manage) das mächtigere
|
||||
* Super-Admin-Konto übernimmt und in dessen Namen z. B. API-Token erzeugt.
|
||||
*/
|
||||
public function mayImpersonateRank(User $admin, User $target): bool
|
||||
{
|
||||
return $admin->is_super_admin || ! $target->is_super_admin;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
|
|
@ -49,6 +60,10 @@ class UserImpersonation
|
|||
throw new AuthorizationException(__('Dieser Benutzer kann nicht im Panel angemeldet werden.'));
|
||||
}
|
||||
|
||||
if (! $this->mayImpersonateRank($admin, $target)) {
|
||||
throw new AuthorizationException(__('Super-Admins können nur von Super-Admins im Panel angemeldet werden.'));
|
||||
}
|
||||
|
||||
session([self::SessionKey => $admin->getKey()]);
|
||||
|
||||
Auth::login($target);
|
||||
|
|
|
|||
141
app/Authorization/Permission.php
Normal file
141
app/Authorization/Permission.php
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
<?php
|
||||
|
||||
namespace App\Authorization;
|
||||
|
||||
use App\Services\Admin\AdminSystemHealth;
|
||||
|
||||
/**
|
||||
* Zentraler Katalog der Spatie-Permissions. Einzige Quelle für die
|
||||
* Permission-Namen und die Rollen-Zuordnung — genutzt vom
|
||||
* RolesAndPermissionsSeeder und von der Betriebsüberwachung
|
||||
* ({@see AdminSystemHealth}). Blade-/Route-Gates verwenden
|
||||
* weiterhin die String-Literale (Laravel-idiomatisch), deren kanonische
|
||||
* Definition aber hier liegt.
|
||||
*/
|
||||
final class Permission
|
||||
{
|
||||
public const PressReleasesRead = 'press-releases:read';
|
||||
|
||||
public const PressReleasesWrite = 'press-releases:write';
|
||||
|
||||
public const PressReleasesPublish = 'press-releases:publish';
|
||||
|
||||
public const PressReleaseImagesWrite = 'press-release-images:write';
|
||||
|
||||
public const CompaniesRead = 'companies:read';
|
||||
|
||||
public const CompaniesWrite = 'companies:write';
|
||||
|
||||
public const CategoriesManage = 'categories:manage';
|
||||
|
||||
public const FooterCodesManage = 'footer-codes:manage';
|
||||
|
||||
public const BillingManage = 'billing:manage';
|
||||
|
||||
public const ComplianceManage = 'compliance:manage';
|
||||
|
||||
public const SettingsManage = 'settings:manage';
|
||||
|
||||
public const NewsletterSubscribe = 'newsletter:subscribe';
|
||||
|
||||
public const UsersManage = 'users:manage';
|
||||
|
||||
public const RolesManage = 'roles:manage';
|
||||
|
||||
public const ApiImmediatePublication = 'api:immediate-publication';
|
||||
|
||||
/**
|
||||
* Alle existierenden Permissions.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function all(): array
|
||||
{
|
||||
return [
|
||||
self::PressReleasesRead,
|
||||
self::PressReleasesWrite,
|
||||
self::PressReleasesPublish,
|
||||
self::PressReleaseImagesWrite,
|
||||
self::CompaniesRead,
|
||||
self::CompaniesWrite,
|
||||
self::CategoriesManage,
|
||||
self::FooterCodesManage,
|
||||
self::BillingManage,
|
||||
self::ComplianceManage,
|
||||
self::SettingsManage,
|
||||
self::NewsletterSubscribe,
|
||||
self::UsersManage,
|
||||
self::RolesManage,
|
||||
self::ApiImmediatePublication,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Nur an `admin` vergebene Verwaltungsrechte (Befund H2). Basis für den
|
||||
* Deployment-Check „Berechtigungen ausgerollt".
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function adminOnly(): array
|
||||
{
|
||||
return [
|
||||
self::CompaniesWrite,
|
||||
self::CategoriesManage,
|
||||
self::FooterCodesManage,
|
||||
self::BillingManage,
|
||||
self::ComplianceManage,
|
||||
self::SettingsManage,
|
||||
self::UsersManage,
|
||||
self::RolesManage,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Permissions der Rolle `editor` (Content-Rolle ohne destruktive/Verwaltung).
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function editor(): array
|
||||
{
|
||||
return [
|
||||
self::PressReleasesRead,
|
||||
self::PressReleasesWrite,
|
||||
self::PressReleasesPublish,
|
||||
self::PressReleaseImagesWrite,
|
||||
self::CompaniesRead,
|
||||
self::NewsletterSubscribe,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Permissions der Rolle `customer`.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function customer(): array
|
||||
{
|
||||
return [
|
||||
self::PressReleasesRead,
|
||||
self::PressReleasesWrite,
|
||||
self::PressReleaseImagesWrite,
|
||||
self::CompaniesRead,
|
||||
self::NewsletterSubscribe,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Permissions der Rolle `api-only`.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function apiOnly(): array
|
||||
{
|
||||
return [
|
||||
self::PressReleasesRead,
|
||||
self::PressReleasesWrite,
|
||||
self::PressReleaseImagesWrite,
|
||||
self::CompaniesRead,
|
||||
self::NewsletterSubscribe,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -39,7 +39,8 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||
'registration_type',
|
||||
'language',
|
||||
'is_active',
|
||||
'is_super_admin',
|
||||
// 'is_super_admin' bewusst NICHT mass-assignable: nur über autorisiertes
|
||||
// forceFill() setzbar (Super-Admin-Rang), sonst Privilege-Escalation-Landmine.
|
||||
'last_login_at',
|
||||
'last_login_ip',
|
||||
'gdpr_consent_at',
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ use Illuminate\Auth\Events\Verified;
|
|||
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
|
||||
use Illuminate\Auth\Notifications\ResetPassword;
|
||||
use Illuminate\Auth\Notifications\VerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Events\QueryExecuted;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Queue\Events\JobFailed;
|
||||
|
|
@ -74,6 +75,8 @@ class AppServiceProvider extends ServiceProvider
|
|||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
Model::preventLazyLoading(app()->isLocal());
|
||||
|
||||
$scheme = request()->header('X-Forwarded-Proto')
|
||||
?? request()->server('HTTP_X_FORWARDED_PROTO')
|
||||
?? (request()->secure() ? 'https' : 'http');
|
||||
|
|
@ -91,6 +94,12 @@ class AppServiceProvider extends ServiceProvider
|
|||
|
||||
// Zugriffs-Gates als Single Source of Truth (auch in Blade via @can und in
|
||||
// Policies nutzbar). Dieselbe Definition, die die Panel-Middleware prüfen.
|
||||
// Super-Admins dürfen alles: zentraler Kurzschluss vor allen Gates und
|
||||
// Permission-Checks (analog zu den Policy-`before()`-Methoden). Damit
|
||||
// greifen feingranulare `can('users:manage')`-Prüfungen auch für
|
||||
// Super-Admins, die keine Spatie-Rolle mit der Permission tragen.
|
||||
Gate::before(fn (User $user, string $ability): ?bool => $user->is_super_admin ? true : null);
|
||||
|
||||
Gate::define('access-admin', fn (User $user): bool => $user->canAccessAdmin());
|
||||
Gate::define('access-customer', fn (User $user): bool => $user->canAccessCustomer());
|
||||
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ class UserImporter
|
|||
$regType = self::REG_TYPE_MAP[$row->registration_type] ?? RegistrationType::ExistingLegacy;
|
||||
|
||||
$user = User::withoutTimestamps(function () use ($email, $name, $portal, $regType, $language, $row, $legacyPortal): User {
|
||||
return User::query()->updateOrCreate(
|
||||
$user = User::query()->updateOrCreate(
|
||||
['email' => $email],
|
||||
[
|
||||
'name' => $name,
|
||||
|
|
@ -159,7 +159,6 @@ class UserImporter
|
|||
'registration_type' => $regType->value,
|
||||
'language' => $language,
|
||||
'is_active' => (bool) $row->is_active,
|
||||
'is_super_admin' => (bool) $row->is_super_admin,
|
||||
'last_login_at' => $row->last_login ?: null,
|
||||
'last_login_ip' => $row->ip_address ?: null,
|
||||
'legacy_portal' => $legacyPortal,
|
||||
|
|
@ -169,6 +168,12 @@ class UserImporter
|
|||
// Kein Passwort – User erhält Go-Live-Reset-Mail (D-09)
|
||||
]
|
||||
);
|
||||
|
||||
// is_super_admin ist nicht mass-assignable (siehe User::$fillable) –
|
||||
// im vertrauenswürdigen Import explizit per forceFill setzen.
|
||||
$user->forceFill(['is_super_admin' => (bool) $row->is_super_admin])->save();
|
||||
|
||||
return $user;
|
||||
});
|
||||
|
||||
// Rolle zuweisen
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Authorization\Permission as PermissionCatalog;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
|
@ -24,48 +25,18 @@ class RolesAndPermissionsSeeder extends Seeder
|
|||
Role::firstOrCreate(['name' => $role]);
|
||||
}
|
||||
|
||||
$permissions = [
|
||||
'press-releases:read',
|
||||
'press-releases:write',
|
||||
'press-releases:publish',
|
||||
'press-release-images:write',
|
||||
'companies:read',
|
||||
'newsletter:subscribe',
|
||||
'users:manage',
|
||||
'roles:manage',
|
||||
// Legacy-API-Parität (10.07.2026): Bestandskunden mit dem alten
|
||||
// immediatePublication-Recht (connektar) veröffentlichen per
|
||||
// /service-POST sofort — sofern der Admin-Schalter „KI-Prüfung
|
||||
// Legacy-API" aus ist. Zuweisung je User (WS-7-Checkliste).
|
||||
'api:immediate-publication',
|
||||
];
|
||||
// Kanonischer Permission-Katalog inkl. Rollen-Zuordnung: siehe
|
||||
// App\Authorization\Permission. Schreibende CRM-/Verwaltungsrechte gehen
|
||||
// nur an `admin` (Befund H2); der `editor` behält reine Content-Rechte.
|
||||
$permissions = PermissionCatalog::all();
|
||||
|
||||
foreach ($permissions as $permission) {
|
||||
Permission::firstOrCreate(['name' => $permission]);
|
||||
}
|
||||
|
||||
Role::findByName('admin')->syncPermissions($permissions);
|
||||
Role::findByName('editor')->syncPermissions([
|
||||
'press-releases:read',
|
||||
'press-releases:write',
|
||||
'press-releases:publish',
|
||||
'press-release-images:write',
|
||||
'companies:read',
|
||||
'newsletter:subscribe',
|
||||
]);
|
||||
Role::findByName('customer')->syncPermissions([
|
||||
'press-releases:read',
|
||||
'press-releases:write',
|
||||
'press-release-images:write',
|
||||
'companies:read',
|
||||
'newsletter:subscribe',
|
||||
]);
|
||||
Role::findByName('api-only')->syncPermissions([
|
||||
'press-releases:read',
|
||||
'press-releases:write',
|
||||
'press-release-images:write',
|
||||
'companies:read',
|
||||
'newsletter:subscribe',
|
||||
]);
|
||||
Role::findByName('editor')->syncPermissions(PermissionCatalog::editor());
|
||||
Role::findByName('customer')->syncPermissions(PermissionCatalog::customer());
|
||||
Role::findByName('api-only')->syncPermissions(PermissionCatalog::apiOnly());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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.'));
|
||||
|
|
|
|||
|
|
@ -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()))],
|
||||
|
|
|
|||
|
|
@ -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()))],
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 [
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -42,41 +42,54 @@ Route::middleware(['auth', 'verified', BlockAdminDuringImpersonation::class, Ens
|
|||
Volt::route('admin/press-releases/{id}', 'admin.press-releases.show')->name('admin.press-releases.show');
|
||||
Volt::route('admin/press-releases/{id}/edit', 'admin.press-releases.edit')->name('admin.press-releases.edit');
|
||||
|
||||
Volt::route('admin/categories', 'admin.categories.index')->name('admin.categories.index');
|
||||
Volt::route('admin/categories/create', 'admin.categories.create')->name('admin.categories.create');
|
||||
Volt::route('admin/categories/{id}/edit', 'admin.categories.edit')->name('admin.categories.edit');
|
||||
// Rubriken-Taxonomie: portalweite Struktur — nur admin (Befund H2).
|
||||
Route::middleware('can:categories:manage')->group(function (): void {
|
||||
Volt::route('admin/categories', 'admin.categories.index')->name('admin.categories.index');
|
||||
Volt::route('admin/categories/create', 'admin.categories.create')->name('admin.categories.create');
|
||||
Volt::route('admin/categories/{id}/edit', 'admin.categories.edit')->name('admin.categories.edit');
|
||||
});
|
||||
|
||||
Volt::route('admin/footer-codes', 'admin.footer-codes.index')->name('admin.footer-codes.index');
|
||||
Volt::route('admin/footer-codes/create', 'admin.footer-codes.create')->name('admin.footer-codes.create');
|
||||
Volt::route('admin/footer-codes/{id}/edit', 'admin.footer-codes.edit')->name('admin.footer-codes.edit');
|
||||
Route::middleware('can:footer-codes:manage')->group(function (): void {
|
||||
Volt::route('admin/footer-codes', 'admin.footer-codes.index')->name('admin.footer-codes.index');
|
||||
Volt::route('admin/footer-codes/create', 'admin.footer-codes.create')->name('admin.footer-codes.create');
|
||||
Volt::route('admin/footer-codes/{id}/edit', 'admin.footer-codes.edit')->name('admin.footer-codes.edit');
|
||||
});
|
||||
|
||||
// CRM
|
||||
// Lesen (companies:read) auch für Editoren; Schreiben nur admin (companies:write, Befund H2).
|
||||
Volt::route('admin/companies', 'admin.companies.index')->name('admin.companies.index');
|
||||
Volt::route('admin/companies/create', 'admin.companies.create')->name('admin.companies.create');
|
||||
Volt::route('admin/companies/{id}', 'admin.companies.show')->name('admin.companies.show');
|
||||
Volt::route('admin/companies/{id}/edit', 'admin.companies.edit')->name('admin.companies.edit');
|
||||
Volt::route('admin/companies/{companyId}/contacts/create', 'admin.contacts.create')->name('admin.companies.contacts.create');
|
||||
|
||||
Volt::route('admin/companies/{id}', 'admin.companies.show')->where('id', '[0-9]+')->name('admin.companies.show');
|
||||
Volt::route('admin/contacts', 'admin.contacts.index')->name('admin.contacts.index');
|
||||
Volt::route('admin/contacts/create', 'admin.contacts.create')->name('admin.contacts.create');
|
||||
Volt::route('admin/contacts/{id}/edit', 'admin.contacts.edit')->name('admin.contacts.edit');
|
||||
|
||||
Route::middleware('can:companies:write')->group(function (): void {
|
||||
Volt::route('admin/companies/create', 'admin.companies.create')->name('admin.companies.create');
|
||||
Volt::route('admin/companies/{id}/edit', 'admin.companies.edit')->name('admin.companies.edit');
|
||||
Volt::route('admin/companies/{companyId}/contacts/create', 'admin.contacts.create')->name('admin.companies.contacts.create');
|
||||
Volt::route('admin/contacts/create', 'admin.contacts.create')->name('admin.contacts.create');
|
||||
Volt::route('admin/contacts/{id}/edit', 'admin.contacts.edit')->name('admin.contacts.edit');
|
||||
});
|
||||
|
||||
// Recht & Compliance (WS-3): Queue für DSGVO-/Persönlichkeitsrechts-/Meldungen
|
||||
Volt::route('admin/legal-requests', 'admin.legal-requests.index')->name('admin.legal-requests.index');
|
||||
Volt::route('admin/legal-requests/{id}', 'admin.legal-requests.show')->name('admin.legal-requests.show');
|
||||
// — sensible Vorgänge, nur admin (Befund H2).
|
||||
Route::middleware('can:compliance:manage')->group(function (): void {
|
||||
Volt::route('admin/legal-requests', 'admin.legal-requests.index')->name('admin.legal-requests.index');
|
||||
Volt::route('admin/legal-requests/{id}', 'admin.legal-requests.show')->name('admin.legal-requests.show');
|
||||
|
||||
// Kontakt: Queue für Anliegen aus dem Kontakt-Widget (Kontaktaufnahme,
|
||||
// Fehlermeldung, PM-Meldung, Feedback)
|
||||
Volt::route('admin/contact-messages', 'admin.contact-messages.index')->name('admin.contact-messages.index');
|
||||
Volt::route('admin/contact-messages/{id}', 'admin.contact-messages.show')->name('admin.contact-messages.show');
|
||||
// Kontakt: Queue für Anliegen aus dem Kontakt-Widget (Kontaktaufnahme,
|
||||
// Fehlermeldung, PM-Meldung, Feedback)
|
||||
Volt::route('admin/contact-messages', 'admin.contact-messages.index')->name('admin.contact-messages.index');
|
||||
Volt::route('admin/contact-messages/{id}', 'admin.contact-messages.show')->name('admin.contact-messages.show');
|
||||
});
|
||||
|
||||
// Billing
|
||||
Volt::route('admin/invoices', 'admin.invoices.index')->name('admin.invoices.index');
|
||||
Route::get('admin/legacy-invoices/{legacyInvoice}/pdf', LegacyInvoicePdfController::class)->name('admin.legacy-invoices.pdf');
|
||||
Volt::route('admin/payments', 'admin.payments.index')->name('admin.payments.index');
|
||||
Volt::route('admin/payments/plans', 'admin.payments.plans')->name('admin.payments.plans');
|
||||
Volt::route('admin/coupons', 'admin.coupons.index')->name('admin.coupons.index');
|
||||
Volt::route('admin/newsletter-sync', 'admin.newsletter.sync')->name('admin.newsletter.sync');
|
||||
// Billing: Rechnungen, Zahlungen, Tarife, Gutscheine — nur admin (Befund H2).
|
||||
Route::middleware('can:billing:manage')->group(function (): void {
|
||||
Volt::route('admin/invoices', 'admin.invoices.index')->name('admin.invoices.index');
|
||||
Route::get('admin/legacy-invoices/{legacyInvoice}/pdf', LegacyInvoicePdfController::class)->name('admin.legacy-invoices.pdf');
|
||||
Volt::route('admin/payments', 'admin.payments.index')->name('admin.payments.index');
|
||||
Volt::route('admin/payments/plans', 'admin.payments.plans')->name('admin.payments.plans');
|
||||
Volt::route('admin/coupons', 'admin.coupons.index')->name('admin.coupons.index');
|
||||
Volt::route('admin/newsletter-sync', 'admin.newsletter.sync')->name('admin.newsletter.sync');
|
||||
});
|
||||
|
||||
// Administration
|
||||
Volt::route('admin/presets', 'admin.presets.index')->name('admin.presets.index');
|
||||
|
|
@ -94,4 +107,8 @@ Route::middleware(['auth', 'verified', BlockAdminDuringImpersonation::class, Ens
|
|||
|
||||
// Reports
|
||||
Volt::route('admin/reports/slow-requests', 'admin.reports.slow-requests')->name('admin.reports.slow-requests');
|
||||
|
||||
Route::middleware('can:settings:manage')->group(function (): void {
|
||||
Volt::route('admin/reports/system-health', 'admin.reports.system-health')->name('admin.reports.system-health');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
134
tests/Feature/Admin/AdminPrivilegeEscalationTest.php
Normal file
134
tests/Feature/Admin/AdminPrivilegeEscalationTest.php
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Livewire\Volt\Volt as LivewireVolt;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Regression zu Befund K1/M8 (Audit 14.07.2026):
|
||||
* Die Rolle `editor` hat Admin-Panel-Zugriff (`access-admin`), aber bewusst
|
||||
* NICHT `users:manage`/`roles:manage`. Die Benutzer-/Rollenverwaltung darf
|
||||
* daher für sie gesperrt sein, und `is_super_admin` darf nur ein Super-Admin
|
||||
* vergeben.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
});
|
||||
|
||||
test('editor cannot open user or role management pages', function () {
|
||||
/** @var TestCase $this */
|
||||
$editor = User::factory()->create();
|
||||
$editor->assignRole('editor');
|
||||
|
||||
$target = User::factory()->create();
|
||||
$target->assignRole('customer');
|
||||
|
||||
$this->actingAs($editor);
|
||||
|
||||
foreach ([
|
||||
'https://pressekonto.test/admin/users',
|
||||
'https://pressekonto.test/admin/users/create',
|
||||
"https://pressekonto.test/admin/users/{$target->id}",
|
||||
"https://pressekonto.test/admin/users/{$target->id}/edit",
|
||||
'https://pressekonto.test/admin/roles',
|
||||
'https://pressekonto.test/admin/roles/create',
|
||||
] as $url) {
|
||||
$this->get($url)->assertForbidden();
|
||||
}
|
||||
});
|
||||
|
||||
test('admin can open user and role management pages', function () {
|
||||
/** @var TestCase $this */
|
||||
$admin = User::factory()->create();
|
||||
$admin->assignRole('admin');
|
||||
|
||||
$this->actingAs($admin);
|
||||
|
||||
$this->get('https://pressekonto.test/admin/users')->assertSuccessful();
|
||||
$this->get('https://pressekonto.test/admin/roles')->assertSuccessful();
|
||||
});
|
||||
|
||||
test('editor cannot mutate roles via the roles component', function () {
|
||||
/** @var TestCase $this */
|
||||
$editor = User::factory()->create();
|
||||
$editor->assignRole('editor');
|
||||
|
||||
$this->actingAs($editor);
|
||||
|
||||
LivewireVolt::test('admin.roles.create')->assertForbidden();
|
||||
});
|
||||
|
||||
test('non super admin cannot elevate a user to super admin', function () {
|
||||
/** @var TestCase $this */
|
||||
$admin = User::factory()->create();
|
||||
$admin->assignRole('admin'); // hat users:manage, ist aber kein Super-Admin
|
||||
|
||||
$target = User::factory()->create(['email' => 'target@example.com']);
|
||||
$target->assignRole('customer');
|
||||
|
||||
$this->actingAs($admin);
|
||||
|
||||
LivewireVolt::test('admin.users.edit', ['id' => $target->id])
|
||||
->set('name', 'Ziel Benutzer')
|
||||
->set('email', 'target@example.com')
|
||||
->set('selectedRoles', ['customer'])
|
||||
->set('isSuperAdmin', true)
|
||||
->call('save')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect($target->fresh()->is_super_admin)->toBeFalse();
|
||||
});
|
||||
|
||||
test('super admin can elevate a user to super admin', function () {
|
||||
/** @var TestCase $this */
|
||||
$superAdmin = User::factory()->superAdmin()->create();
|
||||
|
||||
$target = User::factory()->create(['email' => 'target@example.com']);
|
||||
$target->assignRole('customer');
|
||||
|
||||
$this->actingAs($superAdmin);
|
||||
|
||||
LivewireVolt::test('admin.users.edit', ['id' => $target->id])
|
||||
->set('name', 'Ziel Benutzer')
|
||||
->set('email', 'target@example.com')
|
||||
->set('selectedRoles', ['customer'])
|
||||
->set('isSuperAdmin', true)
|
||||
->call('save')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect($target->fresh()->is_super_admin)->toBeTrue();
|
||||
});
|
||||
|
||||
test('non super admin cannot open the edit page of a super admin', function () {
|
||||
/** @var TestCase $this */
|
||||
$admin = User::factory()->create();
|
||||
$admin->assignRole('admin'); // users:manage, aber kein Super-Admin
|
||||
|
||||
$superAdmin = User::factory()->superAdmin()->create();
|
||||
|
||||
$this->actingAs($admin);
|
||||
|
||||
$this->get("https://pressekonto.test/admin/users/{$superAdmin->id}/edit")->assertForbidden();
|
||||
});
|
||||
|
||||
test('super admin can still edit another super admin', function () {
|
||||
/** @var TestCase $this */
|
||||
$superAdmin = User::factory()->superAdmin()->create();
|
||||
$target = User::factory()->superAdmin()->create(['email' => 'peer@example.com']);
|
||||
|
||||
$this->actingAs($superAdmin);
|
||||
|
||||
$this->get("https://pressekonto.test/admin/users/{$target->id}/edit")->assertSuccessful();
|
||||
});
|
||||
|
||||
test('is_super_admin is not mass assignable', function () {
|
||||
/** @var TestCase $this */
|
||||
$user = User::query()->create([
|
||||
'name' => 'Massenzuweisung',
|
||||
'email' => 'mass@example.com',
|
||||
'is_super_admin' => true,
|
||||
]);
|
||||
|
||||
expect($user->fresh()->is_super_admin)->toBeFalse();
|
||||
});
|
||||
124
tests/Feature/Admin/EditorPermissionBoundaryTest.php
Normal file
124
tests/Feature/Admin/EditorPermissionBoundaryTest.php
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Controllers\Api\Legacy\LegacyPressReleaseController;
|
||||
use App\Models\AppSetting;
|
||||
use App\Models\Company;
|
||||
use App\Models\Contact;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Livewire\Volt\Volt as LivewireVolt;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Regression zu Befund H2 (Audit 14.07.2026): Die Rolle `editor` behält ihre
|
||||
* Content-Rechte (PMs), darf aber keine destruktiven CRM-/Billing-/Compliance-/
|
||||
* Struktur-Aktionen ausführen. Gekoppelt an die neuen Seeder-Permissions
|
||||
* companies:write / categories:manage / footer-codes:manage / billing:manage /
|
||||
* compliance:manage.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
});
|
||||
|
||||
function editorActor(): User
|
||||
{
|
||||
$editor = User::factory()->create();
|
||||
$editor->assignRole('editor');
|
||||
|
||||
return $editor;
|
||||
}
|
||||
|
||||
test('editor is blocked from admin-only management pages', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->actingAs(editorActor());
|
||||
|
||||
foreach ([
|
||||
'admin/categories',
|
||||
'admin/categories/create',
|
||||
'admin/footer-codes',
|
||||
'admin/footer-codes/create',
|
||||
'admin/payments',
|
||||
'admin/coupons',
|
||||
'admin/invoices',
|
||||
'admin/legal-requests',
|
||||
'admin/contact-messages',
|
||||
] as $path) {
|
||||
$this->get("https://pressekonto.test/{$path}")->assertForbidden();
|
||||
}
|
||||
});
|
||||
|
||||
test('editor keeps access to content and read-only CRM pages', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->actingAs(editorActor());
|
||||
|
||||
$this->get('https://pressekonto.test/admin/press-releases')->assertSuccessful();
|
||||
$this->get('https://pressekonto.test/admin/companies')->assertSuccessful();
|
||||
$this->get('https://pressekonto.test/admin/contacts')->assertSuccessful();
|
||||
});
|
||||
|
||||
test('editor cannot open company write forms', function () {
|
||||
/** @var TestCase $this */
|
||||
$company = Company::factory()->presseecho()->create();
|
||||
$this->actingAs(editorActor());
|
||||
|
||||
$this->get('https://pressekonto.test/admin/companies/create')->assertForbidden();
|
||||
$this->get("https://pressekonto.test/admin/companies/{$company->id}/edit")->assertForbidden();
|
||||
});
|
||||
|
||||
test('editor cannot delete a contact from the index', function () {
|
||||
/** @var TestCase $this */
|
||||
$company = Company::factory()->presseecho()->create();
|
||||
$contact = Contact::factory()->for($company)->create();
|
||||
|
||||
$this->actingAs(editorActor());
|
||||
|
||||
LivewireVolt::test('admin.contacts.index')
|
||||
->call('deleteContactFromIndex', $contact->id)
|
||||
->assertForbidden();
|
||||
|
||||
expect(Contact::query()->whereKey($contact->id)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('editor cannot invoke admin-only livewire actions directly', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->actingAs(editorActor());
|
||||
|
||||
LivewireVolt::test('admin.categories.create')
|
||||
->call('save')
|
||||
->assertForbidden();
|
||||
|
||||
LivewireVolt::test('admin.footer-codes.create')
|
||||
->call('save')
|
||||
->assertForbidden();
|
||||
|
||||
LivewireVolt::test('admin.payments.index')
|
||||
->call('cancelBundle', 999_999)
|
||||
->assertForbidden();
|
||||
|
||||
LivewireVolt::test('admin.legal-requests.index')
|
||||
->call('resolveWithdrawal', 999_999)
|
||||
->assertForbidden();
|
||||
|
||||
LivewireVolt::test('admin.press-releases.index')
|
||||
->set('legacyApiAiReview', true)
|
||||
->assertForbidden();
|
||||
|
||||
expect(AppSetting::getBool(LegacyPressReleaseController::SETTING_AI_REVIEW))->toBeFalse();
|
||||
});
|
||||
|
||||
test('admin retains access to all restricted management pages', function () {
|
||||
/** @var TestCase $this */
|
||||
$admin = User::factory()->create();
|
||||
$admin->assignRole('admin');
|
||||
$this->actingAs($admin);
|
||||
|
||||
foreach ([
|
||||
'admin/categories',
|
||||
'admin/footer-codes',
|
||||
'admin/invoices',
|
||||
'admin/legal-requests',
|
||||
'admin/contact-messages',
|
||||
] as $path) {
|
||||
$this->get("https://pressekonto.test/{$path}")->assertSuccessful();
|
||||
}
|
||||
});
|
||||
|
|
@ -45,10 +45,9 @@ test('users without manage permission cannot start impersonation', function () {
|
|||
|
||||
$this->actingAs($editor);
|
||||
|
||||
LivewireVolt::test('admin.users')
|
||||
->assertDontSee('Login als User')
|
||||
->call('loginAsUser', $customer->id)
|
||||
->assertForbidden();
|
||||
// Ohne users:manage ist die Benutzerverwaltung (und damit die
|
||||
// Impersonation) bereits auf Seiten-Ebene gesperrt.
|
||||
$this->get('https://pressekonto.test/admin/users')->assertForbidden();
|
||||
|
||||
$this->assertAuthenticatedAs($editor);
|
||||
$this->assertFalse(session()->has(UserImpersonation::SessionKey));
|
||||
|
|
@ -95,6 +94,52 @@ test('admin can impersonate accounts with admin access', function () {
|
|||
$this->get(route('dashboard'))->assertRedirect(route('me.dashboard'));
|
||||
});
|
||||
|
||||
test('non super admin cannot impersonate a super admin', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
|
||||
$admin = User::factory()->create();
|
||||
$admin->assignRole('admin'); // users:manage, aber kein Super-Admin
|
||||
|
||||
$superAdmin = User::factory()->superAdmin()->create([
|
||||
'email' => 'super@example.com',
|
||||
]);
|
||||
$superAdmin->assignRole('admin');
|
||||
|
||||
$this->actingAs($admin);
|
||||
|
||||
LivewireVolt::test('admin.users')
|
||||
->set('search', 'super@example.com')
|
||||
->call('loginAsUser', $superAdmin->id)
|
||||
->assertForbidden();
|
||||
|
||||
$this->assertAuthenticatedAs($admin);
|
||||
$this->assertFalse(session()->has(UserImpersonation::SessionKey));
|
||||
});
|
||||
|
||||
test('super admin can impersonate another super admin', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
|
||||
$superAdmin = User::factory()->superAdmin()->create();
|
||||
$superAdmin->assignRole('admin');
|
||||
|
||||
$targetSuperAdmin = User::factory()->superAdmin()->create([
|
||||
'email' => 'target-super@example.com',
|
||||
]);
|
||||
$targetSuperAdmin->assignRole('admin');
|
||||
|
||||
$this->actingAs($superAdmin);
|
||||
|
||||
LivewireVolt::test('admin.users')
|
||||
->set('search', 'target-super@example.com')
|
||||
->call('loginAsUser', $targetSuperAdmin->id)
|
||||
->assertRedirect(route('me.dashboard'));
|
||||
|
||||
$this->assertAuthenticatedAs($targetSuperAdmin);
|
||||
expect(session(UserImpersonation::SessionKey))->toBe($superAdmin->id);
|
||||
});
|
||||
|
||||
test('impersonated user can return to the admin account', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use App\Actions\Admin\UserImpersonation;
|
||||
use App\Enums\InvoiceStatus;
|
||||
use App\Enums\UserPaymentOptionStatus;
|
||||
use App\Models\Invoice;
|
||||
|
|
@ -57,6 +58,35 @@ test('customer cannot create api token without active payment access', function
|
|||
expect($customer->tokens()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('api tokens cannot be created or revoked during impersonation', function () {
|
||||
/** @var TestCase $this */
|
||||
$admin = User::factory()->create();
|
||||
$customer = User::factory()->create(['is_active' => true]);
|
||||
UserPaymentOption::factory()->create([
|
||||
'user_id' => $customer->id,
|
||||
'status' => UserPaymentOptionStatus::Active->value,
|
||||
'current_period_start' => now()->subDay(),
|
||||
'current_period_end' => now()->addMonth(),
|
||||
]);
|
||||
$token = $customer->createToken('Existing token', ['press-releases:read'])->accessToken;
|
||||
|
||||
$this->actingAs($customer)
|
||||
->withSession([UserImpersonation::SessionKey => $admin->id]);
|
||||
|
||||
LivewireVolt::test('customer.tokens')
|
||||
->assertSee('Während der Benutzer-Impersonation können keine API-Tokens erstellt oder widerrufen werden.')
|
||||
->set('tokenName', 'Impersonated token')
|
||||
->set('selectedAbilities', ['press-releases:read'])
|
||||
->call('createToken')
|
||||
->assertForbidden();
|
||||
|
||||
LivewireVolt::test('customer.tokens')
|
||||
->call('revokeToken', $token->id)
|
||||
->assertForbidden();
|
||||
|
||||
expect($customer->tokens()->pluck('id')->all())->toBe([$token->id]);
|
||||
});
|
||||
|
||||
test('customer sees only own legacy invoices', function () {
|
||||
/** @var TestCase $this */
|
||||
$customer = User::factory()->create(['is_active' => true]);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue