Rollen und Rechte in der Konsole zuweisbar

main
nexxo 2026-07-31 21:46:22 +02:00
parent 6a6a672c11
commit 93e848d3f9
10 changed files with 545 additions and 61 deletions

View File

@ -0,0 +1,164 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Operator;
use Illuminate\Support\Facades\Gate;
use Livewire\Attributes\Layout;
use Livewire\Component;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
/**
* Wer was darf als Matrix, nicht als Migration.
*
* Bis hierher standen die Rechte einer Rolle in Migrationen und die Liste der
* zulässigen Rollennamen an DREI Stellen im Code. Das ist kein Schönheitsfehler
* gewesen: `Developer` existierte als Rolle und wurde vom Einladen-Formular nie
* angeboten, weil eine der drei Listen nicht mitgezogen worden war.
*
* Deshalb kommt hier alles aus der Datenbank. Eine Rolle, die es gibt, steht
* auf diesem Bildschirm und was auf diesem Bildschirm steht, gilt.
*
* ZWEI Grenzen, beide mit Grund:
*
* - **Owner ist unantastbar.** Ein Klick, der dem Owner Rechte nimmt, sperrt
* den Betreiber aus seiner eigenen Installation aus, und es gibt niemanden
* mehr, der es zurücknehmen könnte.
* - **Alles läuft auf dem `operator`-Guard.** Genau diese Verwechslung hat
* `dpa.manage` zusätzlich auf `web` hinterlassen, wo es kein Betreiber je
* sieht Operator löst gegen `operator` auf (siehe dessen `$guard_name`).
*/
#[Layout('layouts.admin')]
class Roles extends Component
{
public const GUARD = 'operator';
/** @var array<int, array<int, string>> Rollen-ID => angehakte Berechtigungen */
public array $granted = [];
public string $newRole = '';
public function mount(): void
{
abort_unless(Gate::allows('staff.manage'), 403);
$this->loadGrants();
}
public function save(int $roleId): void
{
$this->authorize('staff.manage');
$role = $this->role($roleId);
if ($role->name === 'Owner') {
$this->addError('granted.'.$roleId, __('roles.owner_locked'));
return;
}
// Nur bekannte Namen: die Liste kommt aus dem Browser.
$wanted = array_values(array_intersect(
$this->granted[$roleId] ?? [],
$this->allPermissions(),
));
$role->syncPermissions($wanted);
// Spatie hält die Rechte in einem Zwischenspeicher. Ohne das hier
// gölte die Änderung erst, wenn der Zwischenspeicher von selbst
// abläuft — und die Seite zeigte währenddessen das Neue an, während
// das Alte wirkt.
app(PermissionRegistrar::class)->forgetCachedPermissions();
$this->loadGrants();
$this->dispatch('notify', message: __('roles.saved', ['role' => $role->name]));
}
public function create(): void
{
$this->authorize('staff.manage');
$this->validate(['newRole' => ['required', 'string', 'max:64', 'regex:/^[\pL][\pL\pN \-]*$/u']]);
Role::findOrCreate(trim($this->newRole), self::GUARD);
app(PermissionRegistrar::class)->forgetCachedPermissions();
$this->newRole = '';
$this->loadGrants();
$this->dispatch('notify', message: __('roles.created'));
}
public function delete(int $roleId): void
{
$this->authorize('staff.manage');
$role = $this->role($roleId);
if ($role->name === 'Owner') {
$this->addError('newRole', __('roles.owner_locked'));
return;
}
// Eine Rolle zu löschen, die jemand hält, nähme diesem Menschen
// wortlos jeden Zugang — er stünde beim nächsten Anmelden ohne Rolle
// da und käme in die Konsole nicht mehr hinein.
if ($role->users()->exists()) {
$this->addError('newRole', __('roles.in_use', ['role' => $role->name]));
return;
}
$role->delete();
app(PermissionRegistrar::class)->forgetCachedPermissions();
$this->loadGrants();
$this->dispatch('notify', message: __('roles.deleted'));
}
private function role(int $roleId): Role
{
return Role::query()
->where('guard_name', self::GUARD)
->findOr($roleId, fn () => abort(404));
}
/** @return array<int, string> */
private function allPermissions(): array
{
return Permission::query()
->where('guard_name', self::GUARD)
->orderBy('name')
->pluck('name')
->all();
}
private function loadGrants(): void
{
$this->granted = Role::query()
->where('guard_name', self::GUARD)
->with('permissions')
->get()
->mapWithKeys(fn (Role $role) => [$role->id => $role->permissions->pluck('name')->all()])
->all();
}
public function render()
{
return view('livewire.admin.roles', [
'roles' => Role::query()
->where('guard_name', self::GUARD)
->withCount('users')
->orderBy('name')
->get(),
'permissions' => $this->allPermissions(),
// Wie viele Menschen an dieser Rolle hängen — damit niemand blind
// etwas wegnimmt, das gerade sechs Leute benutzen.
'protectedRole' => 'Owner',
'ownRoles' => Operator::find(auth('operator')->id())?->roles->pluck('name')->all() ?? [],
]);
}
}

View File

@ -17,6 +17,7 @@ use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Url;
use Livewire\Attributes\Validate;
@ -108,7 +109,6 @@ class Settings extends Component
#[Validate('required|email|max:255')]
public string $staffEmail = '';
#[Validate('required|in:Owner,Admin,Support,Billing,Read-only')]
public string $staffRole = 'Support';
/** Shown once after inviting, since email delivery is still mocked. */
@ -221,7 +221,10 @@ class Settings extends Component
$data = $this->validate([
'staffName' => 'required|string|max:255',
'staffEmail' => 'required|email|max:255|unique:operators,email',
'staffRole' => 'required|in:Owner,Admin,Support,Billing,Read-only',
// Aus der Datenbank, nicht aus einer Zeile hier: sonst bietet
// das Formular eine gerade angelegte Rolle nicht an — und
// `Developer` war genau so jahrelang unsichtbar.
'staffRole' => ['required', Rule::in(Operator::operatorRoles())],
]);
// Only an Owner may create another Owner.
@ -258,7 +261,7 @@ class Settings extends Component
public function setStaffRole(int $id, string $role): void
{
$this->authorize('staff.manage');
if (! in_array($role, Operator::OPERATOR_ROLES, true)) {
if (! in_array($role, Operator::operatorRoles(), true)) {
return;
}
@ -598,7 +601,7 @@ class Settings extends Component
$operator = $this->currentOperator();
$staff = Operator::query()
->whereHas('roles', fn ($q) => $q->whereIn('name', Operator::OPERATOR_ROLES))
->whereHas('roles', fn ($q) => $q->whereIn('name', Operator::operatorRoles()))
->with('roles')
->orderBy('name')
->get()
@ -627,7 +630,7 @@ class Settings extends Component
'consoleVpnRanges' => (array) config('admin_access.trusted_ranges', []),
'viewerIp' => (string) request()->ip(),
'staff' => $staff,
'roles' => Operator::OPERATOR_ROLES,
'roles' => Operator::operatorRoles(),
'canManageStaff' => $operator?->can('staff.manage') ?? false,
'isOwner' => $operator?->hasRole('Owner') ?? false,
// Only the tabs this operator can actually open. A tab that raises

View File

@ -14,8 +14,8 @@ use App\Services\Wireguard\QrCode;
use App\Services\Wireguard\WireguardHub;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
@ -112,61 +112,61 @@ class Vpn extends Component
// shared lock is what keeps a host and an access off the same tunnel IP.
try {
// Filled inside the transaction; only the handoff needs it afterwards.
$plainConfig = null;
$plainConfig = null;
$peer = Cache::lock('wireguard:allocate', 30)->block(10, function () use ($hub, $publicKey, $keypair, &$plainConfig) {
return DB::transaction(function () use ($hub, $publicKey, $keypair, &$plainConfig) {
// The owner row is locked for the whole insert, and revokeStaff()
// locks the same row: without that, a revocation could commit
// between the check and the insert, find no peer to remove, and
// leave the revoked colleague with a brand-new tunnel.
$owner = Operator::query()->whereKey($this->ownerId)->lockForUpdate()->first();
if ($owner === null || ! $owner->isOperator()) {
return 'owner_must_be_operator';
}
// Inside the lock: checking before it would let two concurrent
// requests both pass and the loser hit the unique index as a
// 500. withTrashed, because a revoked peer keeps its key until
// the hub confirms removal.
$existing = VpnPeer::withTrashed()->where('public_key', $publicKey)->first();
if ($existing !== null) {
return $existing->trashed() ? 'pending_removal' : 'duplicate_key';
}
// A host's key may not have been adopted into vpn_peers yet.
// Re-using it would make `wg set` rewrite that host's allowed-ip
// to the address allocated here — cutting the management tunnel
// to a live machine.
if (Host::query()->where('wg_pubkey', $publicKey)->exists()) {
return 'host_key';
}
$ip = $hub->allocateIp();
// Built and encrypted here so the stored config is part of the
// same insert. Doing it afterwards could leave a live access
// whose config was never stored — unrecoverable, and the
// operator would create a second one not knowing why.
$secret = null;
if ($keypair !== null) {
$plainConfig = $this->clientConfig($keypair, $ip);
if ($this->storeConfig) {
$secret = ConfigVault::encrypt($plainConfig);
$peer = Cache::lock('wireguard:allocate', 30)->block(10, function () use ($hub, $publicKey, $keypair, &$plainConfig) {
return DB::transaction(function () use ($hub, $publicKey, $keypair, &$plainConfig) {
// The owner row is locked for the whole insert, and revokeStaff()
// locks the same row: without that, a revocation could commit
// between the check and the insert, find no peer to remove, and
// leave the revoked colleague with a brand-new tunnel.
$owner = Operator::query()->whereKey($this->ownerId)->lockForUpdate()->first();
if ($owner === null || ! $owner->isOperator()) {
return 'owner_must_be_operator';
}
}
return VpnPeer::create([
'name' => trim($this->name),
'kind' => VpnPeer::KIND_STAFF,
'user_id' => $this->ownerId,
'public_key' => $publicKey,
'allowed_ip' => $ip,
'config_secret' => $secret,
'enabled' => true,
'present' => false,
'created_by' => Auth::guard('operator')->id(),
]);
// Inside the lock: checking before it would let two concurrent
// requests both pass and the loser hit the unique index as a
// 500. withTrashed, because a revoked peer keeps its key until
// the hub confirms removal.
$existing = VpnPeer::withTrashed()->where('public_key', $publicKey)->first();
if ($existing !== null) {
return $existing->trashed() ? 'pending_removal' : 'duplicate_key';
}
// A host's key may not have been adopted into vpn_peers yet.
// Re-using it would make `wg set` rewrite that host's allowed-ip
// to the address allocated here — cutting the management tunnel
// to a live machine.
if (Host::query()->where('wg_pubkey', $publicKey)->exists()) {
return 'host_key';
}
$ip = $hub->allocateIp();
// Built and encrypted here so the stored config is part of the
// same insert. Doing it afterwards could leave a live access
// whose config was never stored — unrecoverable, and the
// operator would create a second one not knowing why.
$secret = null;
if ($keypair !== null) {
$plainConfig = $this->clientConfig($keypair, $ip);
if ($this->storeConfig) {
$secret = ConfigVault::encrypt($plainConfig);
}
}
return VpnPeer::create([
'name' => trim($this->name),
'kind' => VpnPeer::KIND_STAFF,
'user_id' => $this->ownerId,
'public_key' => $publicKey,
'allowed_ip' => $ip,
'config_secret' => $secret,
'enabled' => true,
'present' => false,
'created_by' => Auth::guard('operator')->id(),
]);
});
});
} catch (QueryException) {
@ -390,7 +390,7 @@ class Vpn extends Component
'qrSvg' => $this->showQr && $config !== null ? QrCode::svg($config) : null,
'peers' => $this->visiblePeers()->with(['host', 'owner'])->orderByDesc('present')->orderBy('name')->get(),
'operators' => Operator::query()
->whereHas('roles', fn ($q) => $q->whereIn('name', Operator::OPERATOR_ROLES))
->whereHas('roles', fn ($q) => $q->whereIn('name', Operator::operatorRoles()))
->orderBy('name')->get(['id', 'name', 'email']),
'canManage' => Auth::guard('operator')->user()?->can('vpn.manage.all') ?? false,
'vaultAvailable' => ConfigVault::available(),

View File

@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Fortify\TwoFactorAuthenticatable;
use Spatie\Permission\Models\Role;
use Spatie\Permission\Traits\HasRoles;
/**
@ -27,7 +28,26 @@ class Operator extends Authenticatable
protected string $guard_name = 'operator';
/** The six roles that grant access to the console. */
public const OPERATOR_ROLES = ['Owner', 'Admin', 'Support', 'Billing', 'Read-only', 'Developer'];
/**
* Die Rollen dieser Installation aus der Datenbank, nicht aus einer
* Liste im Code.
*
* Vorher stand sie hier als Konstante UND zweimal als
* `in:Owner,Admin,…`-Regel in Admin\Settings. `Developer` war in der
* Konstanten und in keiner der beiden Regeln: die Rolle existierte, und
* das Einladen-Formular bot sie nie an. Seit Admin\Roles Rollen anlegen
* kann, wäre jede feste Liste ohnehin schon beim ersten Anlegen falsch.
*
* @return array<int, string>
*/
public static function operatorRoles(): array
{
return Role::query()
->where('guard_name', 'operator')
->orderBy('name')
->pluck('name')
->all();
}
protected $fillable = ['name', 'email', 'password', 'last_login_at', 'disabled_at'];
@ -46,7 +66,7 @@ class Operator extends Authenticatable
/** True when this operator holds a role that reaches the console. */
public function isOperator(): bool
{
return $this->hasAnyRole(self::OPERATOR_ROLES);
return $this->hasAnyRole(self::operatorRoles());
}
/** A disabled operator keeps their record and loses their access. */

View File

@ -113,6 +113,7 @@ final class Navigation
// RequireOperatorTwoFactor sends somebody who may not open
// anything else yet, Settings included.
['admin.settings', 'settings', 'settings', null],
['admin.roles', 'users', 'roles', 'staff.manage'],
]],
];
}

41
lang/de/roles.php Normal file
View File

@ -0,0 +1,41 @@
<?php
return [
'title' => 'Rollen und Rechte',
'subtitle' => 'Was eine Rolle darf, steht hier — nicht in einer Migration. Wer die Rolle hält, bekommt es beim nächsten Aufruf.',
'new_label' => 'Neue Rolle',
'new_hint' => 'Zum Beispiel Buchhaltung. Rechte bekommt sie danach in ihrer eigenen Karte.',
'create' => 'Rolle anlegen',
'created' => 'Rolle angelegt.',
'delete' => 'Löschen',
'deleted' => 'Rolle gelöscht.',
'saved' => 'Rechte für :role gespeichert.',
'locked_badge' => 'gesperrt',
'your_role' => 'Ihre Rolle',
'owner_locked' => 'Owner lässt sich hier nicht ändern. Ein Klick, der dem Owner Rechte nimmt, sperrt Sie aus Ihrer eigenen Installation aus — und niemand könnte es zurücknehmen.',
'in_use' => 'Die Rolle :role wird noch gehalten. Wer sie hat, stünde beim nächsten Anmelden ohne Rolle da und käme nicht mehr in die Konsole.',
'holders' => '{0} Niemand hat diese Rolle|{1} :count Person hat diese Rolle|[2,*] :count Personen haben diese Rolle',
'can' => [
'billing.manage' => 'Rechnungen, Gutschriften, Zahlungsprobleme',
'console.view' => 'Die Konsole überhaupt betreten',
'customers.grant_plan' => 'Ein Paket ohne Bezahlung zuweisen',
'customers.impersonate' => 'Sich als Kunde anmelden',
'customers.manage' => 'Kundendaten sehen und ändern',
'datacenters.manage' => 'Rechenzentren verwalten',
'dpa.manage' => 'Auftragsverarbeitungsverträge',
'hosts.manage' => 'Hosts anlegen, übernehmen, entfernen',
'instances.adminlogin' => 'Sich in die Cloud eines Kunden einloggen',
'instances.restart' => 'Eine Kunden-Cloud neu starten',
'mail.manage' => 'Postfächer, Vorlagen, Posteingang',
'maintenance.manage' => 'Wartungsfenster und Störungen',
'plans.manage' => 'Pakete und Preise',
'provisioning.cancel' => 'Eine laufende Bereitstellung abbrechen',
'provisioning.retry' => 'Eine Bereitstellung wiederholen',
'secrets.manage' => 'Zugangsdaten und die Serverdatei',
'site.manage' => 'Firmendaten, Rechnungsserien, Hostnamen',
'staff.manage' => 'Mitarbeiter einladen UND Rollen bestimmen',
'vpn.manage.all' => 'VPN-Zugänge aller Mitarbeiter',
'vpn.view.all' => 'VPN-Zugänge aller Mitarbeiter sehen',
],
];

41
lang/en/roles.php Normal file
View File

@ -0,0 +1,41 @@
<?php
return [
'title' => 'Roles and capabilities',
'subtitle' => 'What a role may do lives here, not in a migration. Whoever holds the role gets it on their next request.',
'new_label' => 'New role',
'new_hint' => 'For example Accounting. Capabilities follow in its own card.',
'create' => 'Create role',
'created' => 'Role created.',
'delete' => 'Delete',
'deleted' => 'Role deleted.',
'saved' => 'Capabilities for :role saved.',
'locked_badge' => 'locked',
'your_role' => 'Your role',
'owner_locked' => 'Owner cannot be changed here. A click that takes capabilities from Owner locks you out of your own installation, and nobody could undo it.',
'in_use' => 'The role :role is still held. Whoever has it would be left with no role on their next sign-in and could not enter the console.',
'holders' => '{0} Nobody holds this role|{1} :count person holds this role|[2,*] :count people hold this role',
'can' => [
'billing.manage' => 'Invoices, credit notes, payment problems',
'console.view' => 'Enter the console at all',
'customers.grant_plan' => 'Grant a plan without payment',
'customers.impersonate' => 'Sign in as a customer',
'customers.manage' => 'See and change customer data',
'datacenters.manage' => 'Manage datacenters',
'dpa.manage' => 'Data processing agreements',
'hosts.manage' => 'Create, onboard and remove hosts',
'instances.adminlogin' => 'Sign in to a customer cloud',
'instances.restart' => 'Restart a customer cloud',
'mail.manage' => 'Mailboxes, templates, inbox',
'maintenance.manage' => 'Maintenance windows and incidents',
'plans.manage' => 'Plans and prices',
'provisioning.cancel' => 'Cancel a running provisioning run',
'provisioning.retry' => 'Retry a provisioning run',
'secrets.manage' => 'Credentials and the server file',
'site.manage' => 'Company details, invoice series, hostnames',
'staff.manage' => 'Invite staff AND define roles',
'vpn.manage.all' => 'Everyone\'s VPN access',
'vpn.view.all' => 'See everyone\'s VPN access',
],
];

View File

@ -0,0 +1,87 @@
<div class="mx-auto max-w-[1120px] space-y-6">
<div class="animate-rise">
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('roles.title') }}</h1>
<p class="mt-1 max-w-[70ch] text-sm text-muted">{{ __('roles.subtitle') }}</p>
</div>
{{-- Neue Rolle. Ein Feld, ein Knopf die Rechte bekommt sie danach in
ihrer eigenen Karte, weil eine leere Rolle noch nichts kaputtmachen
kann und die Matrix im selben Formular unlesbar würde. --}}
<div class="flex flex-wrap items-end gap-3 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
<div class="min-w-56 flex-1">
<x-ui.input name="newRole" wire:model="newRole"
:label="__('roles.new_label')" :hint="__('roles.new_hint')" />
</div>
<x-ui.button variant="primary" wire:click="create" wire:loading.attr="disabled" wire:target="create">
{{ __('roles.create') }}
</x-ui.button>
</div>
@foreach ($roles as $role)
@php $isProtected = $role->name === $protectedRole; @endphp
<div wire:key="role-{{ $role->id }}"
class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0">
<h2 class="flex flex-wrap items-center gap-2 font-semibold text-ink">
{{ $role->name }}
@if ($isProtected)
<span class="rounded-pill border border-warning-border bg-warning-bg px-2.5 py-0.5 text-xs font-medium text-warning">
{{ __('roles.locked_badge') }}
</span>
@endif
@if (in_array($role->name, $ownRoles, true))
<span class="rounded-pill border border-info-border bg-info-bg px-2.5 py-0.5 text-xs font-medium text-info">
{{ __('roles.your_role') }}
</span>
@endif
</h2>
<p class="mt-0.5 text-xs text-muted">
{{ trans_choice('roles.holders', $role->users_count, ['count' => $role->users_count]) }}
</p>
</div>
@unless ($isProtected)
<div class="flex flex-wrap gap-2">
<x-ui.button variant="primary" wire:click="save({{ $role->id }})"
wire:loading.attr="disabled" wire:target="save({{ $role->id }})">
{{ __('common.save') }}
</x-ui.button>
@if ($role->users_count === 0)
<x-ui.button variant="ghost" wire:click="delete({{ $role->id }})"
wire:loading.attr="disabled" wire:target="delete({{ $role->id }})">
{{ __('roles.delete') }}
</x-ui.button>
@endif
</div>
@endunless
</div>
@error('granted.'.$role->id)<p class="text-xs text-danger">{{ $message }}</p>@enderror
@if ($isProtected)
<p class="text-sm text-muted">{{ __('roles.owner_locked') }}</p>
@endif
{{-- Die Matrix. Auch für Owner sichtbar, nur nicht änderbar: was
der Owner darf, ist die Antwort auf „was gibt es überhaupt",
und die soll man nachlesen können. --}}
<div class="grid grid-cols-1 gap-x-6 gap-y-2 sm:grid-cols-2 lg:grid-cols-3">
@foreach ($permissions as $permission)
<label class="flex items-start gap-2 text-sm text-body">
<input type="checkbox" value="{{ $permission }}"
wire:model="granted.{{ $role->id }}"
@disabled($isProtected)
class="mt-0.5 size-4 shrink-0 rounded border-line-strong accent-[var(--accent-active)] disabled:opacity-50">
<span class="min-w-0">
<span class="font-mono text-xs">{{ $permission }}</span>
<span class="block text-xs text-muted">{{ __('roles.can.'.$permission) }}</span>
</span>
</label>
@endforeach
</div>
</div>
@endforeach
@error('newRole')<p class="text-sm text-danger">{{ $message }}</p>@enderror
</div>

View File

@ -131,6 +131,10 @@ Route::get('/infrastructure', fn () => redirect()->route('admin.integrations', s
// one list of what is still missing.
Route::get('/readiness', Admin\Readiness::class)->name('readiness');
Route::get('/settings', Admin\Settings::class)->name('settings');
// Rollen und ihre Rechte. Eigene Seite statt eines Abschnitts unter
// Einstellungen: eine Matrix aus sechs Rollen und einundzwanzig Rechten ist
// keine Karte mehr, und wer sie öffnet, tut genau eine Sache.
Route::get('/roles', Admin\Roles::class)->name('roles');
// Its own route so RequireOperatorTwoFactor can exempt ENROLMENT without
// exempting the rest of admin.settings — see that middleware for why.
Route::get('/two-factor-setup', Admin\TwoFactorSetup::class)->name('two-factor-setup');

View File

@ -0,0 +1,123 @@
<?php
use App\Livewire\Admin\Roles;
use App\Models\Operator;
use Livewire\Livewire;
use Spatie\Permission\Models\Role;
/**
* Rollen und ihre Rechte waren fest im Code. Zuweisbar war in der Konsole nur
* die Rolle selbst was sie DARF, stand in Migrationen.
*
* Beim Nachsehen fiel zweierlei auf, und beides ist der Grund, warum die Liste
* jetzt aus der Datenbank kommt statt aus einer Konstanten:
*
* - `Developer` existiert als Rolle, wurde vom Einladen-Formular aber nie
* angeboten: die zulässigen Namen standen an DREI Stellen (zwei
* Validierungsregeln plus Operator::OPERATOR_ROLES), und eine davon war
* nicht mitgezogen worden.
* - `dpa.manage` lag zusätzlich auf dem `web`-Guard, wo kein Betreiber je
* hinsieht eine Migration hatte den Guard verwechselt.
*/
it('offers every role that actually exists, not a list written down elsewhere', function () {
Role::findOrCreate('Buchhaltung', 'operator');
Livewire::actingAs(operator('Owner'), 'operator')
->test(Roles::class)
->assertSee('Buchhaltung')
->assertSee('Developer');
});
it('lets the owner give a role a capability', function () {
$role = Role::findOrCreate('Buchhaltung', 'operator');
Livewire::actingAs(operator('Owner'), 'operator')
->test(Roles::class)
->set("granted.{$role->id}", ['console.view', 'billing.manage'])
->call('save', $role->id)
->assertHasNoErrors();
expect($role->fresh()->permissions->pluck('name')->sort()->values()->all())
->toBe(['billing.manage', 'console.view']);
});
it('takes a capability away again', function () {
$role = Role::findOrCreate('Buchhaltung', 'operator');
$role->givePermissionTo('billing.manage', 'console.view');
Livewire::actingAs(operator('Owner'), 'operator')
->test(Roles::class)
->set("granted.{$role->id}", ['console.view'])
->call('save', $role->id);
expect($role->fresh()->permissions->pluck('name')->all())->toBe(['console.view']);
});
it('refuses to let Owner be stripped', function () {
// Sonst sperrt sich der Betreiber mit einem Klick selbst aus, und es gibt
// niemanden mehr, der es zurücknehmen könnte. Die einzige Rolle, die
// dieser Bildschirm nicht anfassen darf.
$owner = Role::where('name', 'Owner')->where('guard_name', 'operator')->firstOrFail();
$vorher = $owner->permissions->pluck('name')->sort()->values()->all();
Livewire::actingAs(operator('Owner'), 'operator')
->test(Roles::class)
->set("granted.{$owner->id}", ['console.view'])
->call('save', $owner->id)
->assertHasErrors();
expect($owner->fresh()->permissions->pluck('name')->sort()->values()->all())->toBe($vorher);
});
it('creates a role', function () {
Livewire::actingAs(operator('Owner'), 'operator')
->test(Roles::class)
->set('newRole', 'Buchhaltung')
->call('create')
->assertHasNoErrors();
expect(Role::where('name', 'Buchhaltung')->where('guard_name', 'operator')->exists())->toBeTrue();
});
it('never creates a role on the wrong guard', function () {
// Genau der Fehler, der `dpa.manage` auf dem web-Guard hinterlassen hat:
// ein Eintrag, den kein Betreiber je sieht, weil Operator gegen
// `operator` auflöst.
Livewire::actingAs(operator('Owner'), 'operator')
->test(Roles::class)
->set('newRole', 'Buchhaltung')
->call('create');
expect(Role::where('name', 'Buchhaltung')->where('guard_name', 'web')->exists())->toBeFalse();
});
it('will not delete a role somebody still holds', function () {
$role = Role::findOrCreate('Buchhaltung', 'operator');
Operator::factory()->create()->assignRole($role);
Livewire::actingAs(operator('Owner'), 'operator')
->test(Roles::class)
->call('delete', $role->id)
->assertHasErrors();
expect(Role::where('name', 'Buchhaltung')->exists())->toBeTrue();
});
it('deletes a role nobody holds', function () {
$role = Role::findOrCreate('Buchhaltung', 'operator');
Livewire::actingAs(operator('Owner'), 'operator')
->test(Roles::class)
->call('delete', $role->id)
->assertHasNoErrors();
expect(Role::where('name', 'Buchhaltung')->exists())->toBeFalse();
});
it('is not reachable without staff.manage', function () {
// Wer bestimmen darf, was eine Rolle kann, bestimmt alles andere gleich
// mit. Dasselbe Recht wie das Einladen von Mitarbeitern.
Livewire::actingAs(operator('Admin'), 'operator')
->test(Roles::class)
->assertForbidden();
});