From 55c3975d5377779ab2be44f25db3255032e34e8b Mon Sep 17 00:00:00 2001 From: boban Date: Sun, 5 Jul 2026 00:42:16 +0200 Subject: [PATCH] feat(rbac): role enum + column + gates foundation (admin>operator>viewer) Co-Authored-By: Claude Opus 4.8 --- app/Console/Commands/Install.php | 1 + app/Console/Commands/ResetAdmin.php | 3 + app/Enums/Role.php | 37 +++++++++++ app/Models/User.php | 16 ++++- app/Providers/AppServiceProvider.php | 11 ++++ database/factories/UserFactory.php | 17 +++++ ...6_07_05_000001_add_role_to_users_table.php | 23 +++++++ lang/de/roles.php | 3 + lang/en/roles.php | 3 + tests/Feature/RoleFoundationTest.php | 64 +++++++++++++++++++ 10 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 app/Enums/Role.php create mode 100644 database/migrations/2026_07_05_000001_add_role_to_users_table.php create mode 100644 lang/de/roles.php create mode 100644 lang/en/roles.php create mode 100644 tests/Feature/RoleFoundationTest.php diff --git a/app/Console/Commands/Install.php b/app/Console/Commands/Install.php index ad4a8f1..eb4511b 100644 --- a/app/Console/Commands/Install.php +++ b/app/Console/Commands/Install.php @@ -55,6 +55,7 @@ class Install extends Command 'email' => $email, 'password' => $password, // 'hashed' cast hashes on save '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 diff --git a/app/Console/Commands/ResetAdmin.php b/app/Console/Commands/ResetAdmin.php index d8d3cf8..0337596 100644 --- a/app/Console/Commands/ResetAdmin.php +++ b/app/Console/Commands/ResetAdmin.php @@ -40,6 +40,9 @@ class ResetAdmin extends Command 'password' => Hash::make($password), 'must_change_password' => false, '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')) { diff --git a/app/Enums/Role.php b/app/Enums/Role.php new file mode 100644 index 0000000..013c2f8 --- /dev/null +++ b/app/Enums/Role.php @@ -0,0 +1,37 @@ + 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_). */ + public function label(): string + { + return (string) __('roles.role_'.$this->value); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index a11aa7f..217d042 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Enums\Role; use App\Notifications\QueuedResetPassword; use Database\Factories\UserFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; @@ -15,7 +16,7 @@ use Illuminate\Support\Str; use PragmaRX\Google2FA\Exceptions\Google2FAException; 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'])] class User extends Authenticatable { @@ -32,6 +33,7 @@ class User extends Authenticatable 'two_factor_confirmed_at' => 'datetime', 'must_change_password' => 'boolean', 'onboarding_tour_completed_at' => 'datetime', + 'role' => Role::class, ]; } @@ -144,6 +146,18 @@ class User extends Authenticatable 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 * codes (nothing left to recover into). Call after removing any factor. diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index a96bdc4..189d00a 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,12 +2,15 @@ namespace App\Providers; +use App\Enums\Role; use App\Http\Middleware\EnsureSecurityOnboarded; use App\Models\Setting; +use App\Models\User; use App\Services\DeploymentService; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Http\Request; use Illuminate\Support\Facades\Crypt; +use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Vite; @@ -52,6 +55,14 @@ class AppServiceProvider extends ServiceProvider 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 // (/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 diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index c4ceb07..79b0a87 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -30,9 +30,26 @@ class UserFactory extends Factory 'email_verified_at' => now(), 'password' => static::$password ??= Hash::make('password'), '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. */ diff --git a/database/migrations/2026_07_05_000001_add_role_to_users_table.php b/database/migrations/2026_07_05_000001_add_role_to_users_table.php new file mode 100644 index 0000000..d04827b --- /dev/null +++ b/database/migrations/2026_07_05_000001_add_role_to_users_table.php @@ -0,0 +1,23 @@ +string('role')->default('admin')->after('email'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('role'); + }); + } +}; diff --git a/lang/de/roles.php b/lang/de/roles.php new file mode 100644 index 0000000..6ff898a --- /dev/null +++ b/lang/de/roles.php @@ -0,0 +1,3 @@ + 'Administrator', 'role_operator' => 'Operator', 'role_viewer' => 'Betrachter']; diff --git a/lang/en/roles.php b/lang/en/roles.php new file mode 100644 index 0000000..986b43b --- /dev/null +++ b/lang/en/roles.php @@ -0,0 +1,3 @@ + 'Administrator', 'role_operator' => 'Operator', 'role_viewer' => 'Viewer']; diff --git a/tests/Feature/RoleFoundationTest.php b/tests/Feature/RoleFoundationTest.php new file mode 100644 index 0000000..a0de20b --- /dev/null +++ b/tests/Feature/RoleFoundationTest.php @@ -0,0 +1,64 @@ + 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()); + } +}