feat(rbac): role enum + column + gates foundation (admin>operator>viewer)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-07-05 00:42:16 +02:00
parent 3c25f097fa
commit 55c3975d53
10 changed files with 177 additions and 1 deletions

View File

@ -55,6 +55,7 @@ class Install extends Command
'email' => $email, 'email' => $email,
'password' => $password, // 'hashed' cast hashes on save 'password' => $password, // 'hashed' cast hashes on save
'must_change_password' => true, // nudges a rotation on first login (optional) 'must_change_password' => true, // nudges a rotation on first login (optional)
'role' => 'admin', // the first/root account is a full administrator
]); ]);
// install.sh greps these two lines for the closing banner — this is the ONLY time the // install.sh greps these two lines for the closing banner — this is the ONLY time the

View File

@ -40,6 +40,9 @@ class ResetAdmin extends Command
'password' => Hash::make($password), 'password' => Hash::make($password),
'must_change_password' => false, 'must_change_password' => false,
'remember_token' => Str::random(60), 'remember_token' => Str::random(60),
// Restore full admin rights — this is a lockout-recovery path, so the operator must
// regain the control plane regardless of the account's prior role.
'role' => 'admin',
]); ]);
if ($this->option('disable-2fa')) { if ($this->option('disable-2fa')) {

37
app/Enums/Role.php Normal file
View File

@ -0,0 +1,37 @@
<?php
namespace App\Enums;
/**
* Account role. Hierarchy: admin > operator > viewer. admin has every ability; operator can run
* day-to-day operations (services, files, terminal to managed servers) but not the dangerous
* control-plane actions; viewer is read-only.
*/
enum Role: string
{
case Admin = 'admin';
case Operator = 'operator';
case Viewer = 'viewer';
/** Numeric rank for hierarchy comparisons. */
public function rank(): int
{
return match ($this) {
self::Admin => 3,
self::Operator => 2,
self::Viewer => 1,
};
}
/** True when this role is at least as privileged as $other. */
public function atLeast(self $other): bool
{
return $this->rank() >= $other->rank();
}
/** Localised label (lang/{de,en}/roles.php key role_<value>). */
public function label(): string
{
return (string) __('roles.role_'.$this->value);
}
}

View File

@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Enums\Role;
use App\Notifications\QueuedResetPassword; use App\Notifications\QueuedResetPassword;
use Database\Factories\UserFactory; use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
@ -15,7 +16,7 @@ use Illuminate\Support\Str;
use PragmaRX\Google2FA\Exceptions\Google2FAException; use PragmaRX\Google2FA\Exceptions\Google2FAException;
use PragmaRX\Google2FAQRCode\Google2FA; use PragmaRX\Google2FAQRCode\Google2FA;
#[Fillable(['name', 'email', 'password', 'must_change_password', 'locale'])] #[Fillable(['name', 'email', 'password', 'must_change_password', 'locale', 'role'])]
#[Hidden(['password', 'remember_token', 'two_factor_secret', 'two_factor_recovery_codes'])] #[Hidden(['password', 'remember_token', 'two_factor_secret', 'two_factor_recovery_codes'])]
class User extends Authenticatable class User extends Authenticatable
{ {
@ -32,6 +33,7 @@ class User extends Authenticatable
'two_factor_confirmed_at' => 'datetime', 'two_factor_confirmed_at' => 'datetime',
'must_change_password' => 'boolean', 'must_change_password' => 'boolean',
'onboarding_tour_completed_at' => 'datetime', 'onboarding_tour_completed_at' => 'datetime',
'role' => Role::class,
]; ];
} }
@ -144,6 +146,18 @@ class User extends Authenticatable
return ! $this->must_change_password; return ! $this->must_change_password;
} }
/** True when the account is a full administrator. */
public function isAdmin(): bool
{
return $this->role === Role::Admin;
}
/** True when the account's role is at least $role in the admin>operator>viewer hierarchy. */
public function roleAtLeast(Role $role): bool
{
return $this->role?->atLeast($role) ?? false;
}
/** /**
* When neither factor remains (no TOTP, no keys), 2FA is fully off so drop the backup * When neither factor remains (no TOTP, no keys), 2FA is fully off so drop the backup
* codes (nothing left to recover into). Call after removing any factor. * codes (nothing left to recover into). Call after removing any factor.

View File

@ -2,12 +2,15 @@
namespace App\Providers; namespace App\Providers;
use App\Enums\Role;
use App\Http\Middleware\EnsureSecurityOnboarded; use App\Http\Middleware\EnsureSecurityOnboarded;
use App\Models\Setting; use App\Models\Setting;
use App\Models\User;
use App\Services\DeploymentService; use App\Services\DeploymentService;
use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Vite; use Illuminate\Support\Facades\Vite;
@ -52,6 +55,14 @@ class AppServiceProvider extends ServiceProvider
EnsureSecurityOnboarded::class, EnsureSecurityOnboarded::class,
]); ]);
// RBAC abilities. admin has every dangerous control-plane ability; operator can run
// day-to-day operations; viewer is read-only. Gates are the single source consumed by route
// `can:` middleware, component mount() abort_unless, per-action guards and @can in Blade.
foreach (['manage-panel', 'manage-users', 'manage-network', 'manage-fleet'] as $ability) {
Gate::define($ability, fn (User $u) => $u->isAdmin());
}
Gate::define('operate', fn (User $u) => $u->roleAtLeast(Role::Operator));
// Defense-in-depth: a coarse per-identity cap on the Livewire action endpoint // Defense-in-depth: a coarse per-identity cap on the Livewire action endpoint
// (/livewire/update carries every component request, including the auth actions). The // (/livewire/update carries every component request, including the auth actions). The
// per-action limiters in the components are the precise gate; this is the blunt cap so a // per-action limiters in the components are the precise gate; this is the blunt cap so a

View File

@ -30,9 +30,26 @@ class UserFactory extends Factory
'email_verified_at' => now(), 'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'), 'password' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10), 'remember_token' => Str::random(10),
'role' => 'admin',
]; ];
} }
/**
* Indicate that the model's role should be operator.
*/
public function operator(): static
{
return $this->state(['role' => 'operator']);
}
/**
* Indicate that the model's role should be viewer.
*/
public function viewer(): static
{
return $this->state(['role' => 'viewer']);
}
/** /**
* Indicate that the model's email address should be unverified. * Indicate that the model's email address should be unverified.
*/ */

View File

@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/** Account role (admin|operator|viewer). Default 'admin' so existing installs backfill to admin — no lock-out. */
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('role')->default('admin')->after('email');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('role');
});
}
};

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

@ -0,0 +1,3 @@
<?php
return ['role_admin' => 'Administrator', 'role_operator' => 'Operator', 'role_viewer' => 'Betrachter'];

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

@ -0,0 +1,3 @@
<?php
return ['role_admin' => 'Administrator', 'role_operator' => 'Operator', 'role_viewer' => 'Viewer'];

View File

@ -0,0 +1,64 @@
<?php
namespace Tests\Feature;
use App\Enums\Role;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Gate;
use Tests\TestCase;
/**
* RBAC foundation: the Role enum hierarchy, the User helpers, the Gates, and the first-user
* bootstrap all agree that admin > operator > viewer. This is the base every later `can:` /
* abort_unless / @can builds on.
*/
class RoleFoundationTest extends TestCase
{
use RefreshDatabase;
public function test_role_hierarchy_at_least(): void
{
$this->assertTrue(Role::Admin->atLeast(Role::Operator));
$this->assertFalse(Role::Viewer->atLeast(Role::Operator));
}
public function test_user_role_helpers(): void
{
$admin = User::factory()->create();
$this->assertTrue($admin->isAdmin());
$this->assertTrue($admin->roleAtLeast(Role::Operator));
$operator = User::factory()->operator()->create();
$this->assertFalse($operator->isAdmin());
$this->assertTrue($operator->roleAtLeast(Role::Operator));
$this->assertFalse($operator->roleAtLeast(Role::Admin));
$viewer = User::factory()->viewer()->create();
$this->assertFalse($viewer->roleAtLeast(Role::Operator));
}
public function test_gates_follow_the_hierarchy(): void
{
$admin = User::factory()->create();
$this->assertTrue(Gate::forUser($admin)->allows('manage-panel'));
$this->assertTrue(Gate::forUser($admin)->allows('operate'));
$operator = User::factory()->operator()->create();
$this->assertFalse(Gate::forUser($operator)->allows('manage-panel'));
$this->assertTrue(Gate::forUser($operator)->allows('operate'));
$viewer = User::factory()->viewer()->create();
$this->assertFalse(Gate::forUser($viewer)->allows('operate'));
}
public function test_install_creates_first_user_as_admin(): void
{
$this->assertSame(0, Artisan::call('clusev:install', ['--email' => 'admin@example.test']));
$user = User::first();
$this->assertNotNull($user);
$this->assertTrue($user->isAdmin());
}
}