feat(rbac): role enum + column + gates foundation (admin>operator>viewer)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>feat/v1-foundation
parent
3c25f097fa
commit
55c3975d53
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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')) {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return ['role_admin' => 'Administrator', 'role_operator' => 'Operator', 'role_viewer' => 'Betrachter'];
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return ['role_admin' => 'Administrator', 'role_operator' => 'Operator', 'role_viewer' => 'Viewer'];
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue