Rollen und Rechte in der Konsole zuweisbar
parent
6a6a672c11
commit
93e848d3f9
|
|
@ -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() ?? [],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
]],
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
],
|
||||
];
|
||||
|
|
@ -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',
|
||||
],
|
||||
];
|
||||
|
|
@ -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>
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
Loading…
Reference in New Issue