fix(admin): self-heal legacy is_admin into Owner (no RBAC bypass); validate datacenter country server-side

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-25 18:19:24 +02:00
parent 88957f8f57
commit 8304f2e7dc
5 changed files with 31 additions and 11 deletions

View File

@ -2,6 +2,7 @@
namespace App\Http\Middleware; namespace App\Http\Middleware;
use App\Models\User;
use Closure; use Closure;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@ -9,12 +10,20 @@ use Symfony\Component\HttpFoundation\Response;
class EnsureAdmin class EnsureAdmin
{ {
/** /**
* Only operators (is_admin) may reach the admin console. * Only operators (a user holding an RBAC role) may reach the admin console.
* A legacy is_admin account with no role is self-healed into Owner on access
* migrating it into RBAC rather than permanently trusting the old flag, so
* such accounts are never locked out yet the role boundary still governs.
*/ */
public function handle(Request $request, Closure $next): Response public function handle(Request $request, Closure $next): Response
{ {
// console.view via RBAC, or the legacy is_admin flag as a safety net. $user = $request->user();
abort_unless((bool) $request->user()?->isOperator(), 403);
if ($user instanceof User && $user->is_admin && ! $user->isOperator()) {
$user->assignRole('Owner');
}
abort_unless((bool) $user?->can('console.view'), 403);
return $next($request); return $next($request);
} }

View File

@ -27,7 +27,13 @@ class Datacenters extends Component
// stored — otherwise `FSN` passes the rule but the lowercased insert collides. // stored — otherwise `FSN` passes the rule but the lowercased insert collides.
$this->code = strtolower(trim($this->code)); $this->code = strtolower(trim($this->code));
$data = $this->validate(); // Country must be one of the curated list — a forged request can bypass
// the <select>, so enforce membership server-side (mirrors EditDatacenter).
$data = $this->validate([
'code' => 'required|string|max:8|alpha_dash|unique:datacenters,code',
'name' => 'required|string|max:255',
'location' => 'nullable|in:'.implode(',', array_keys((array) config('countries'))),
]);
Datacenter::create([ Datacenter::create([
'code' => $data['code'], 'code' => $data['code'],

View File

@ -22,14 +22,10 @@ class User extends Authenticatable
/** The five operator roles that grant access to the admin console. */ /** The five operator roles that grant access to the admin console. */
public const OPERATOR_ROLES = ['Owner', 'Admin', 'Support', 'Billing', 'Read-only']; public const OPERATOR_ROLES = ['Owner', 'Admin', 'Support', 'Billing', 'Read-only'];
/** /** True when this user holds an operator role (the RBAC console boundary). */
* True when this user is a CluPilot operator. Prefers RBAC roles, but keeps
* the legacy is_admin flag as a fallback so an operator can never be locked
* out of the console by a stale permission cache or a missing role.
*/
public function isOperator(): bool public function isOperator(): bool
{ {
return $this->is_admin || $this->hasAnyRole(self::OPERATOR_ROLES); return $this->hasAnyRole(self::OPERATOR_ROLES);
} }
/** /**

View File

@ -7,7 +7,7 @@ Broadcast::channel('App.Models.User.{id}', function ($user, $id) {
}); });
// Operator console live provisioning feed — operators only. // Operator console live provisioning feed — operators only.
Broadcast::channel('admin.runs', fn ($user) => $user->isOperator()); Broadcast::channel('admin.runs', fn ($user) => (bool) $user->can('console.view'));
// A customer's own provisioning feed — bridged from the auth user by email. // A customer's own provisioning feed — bridged from the auth user by email.
Broadcast::channel('customer.{customerId}.run', function ($user, $customerId) { Broadcast::channel('customer.{customerId}.run', function ($user, $customerId) {

View File

@ -20,6 +20,15 @@ it('grants console access to every operator role but not to customers', function
$this->actingAs($customer)->get(route('admin.overview'))->assertForbidden(); $this->actingAs($customer)->get(route('admin.overview'))->assertForbidden();
}); });
it('self-heals a legacy is_admin account with no role into Owner', function () {
$legacy = User::factory()->create(['is_admin' => true]);
$legacy->syncRoles([]); // simulate a pre-RBAC admin with no operator role
$this->actingAs($legacy)->get(route('admin.overview'))->assertOk();
expect($legacy->fresh()->hasRole('Owner'))->toBeTrue();
});
it('denies datacenter management to roles without the capability', function () { it('denies datacenter management to roles without the capability', function () {
foreach (['Support', 'Billing', 'Read-only'] as $role) { foreach (['Support', 'Billing', 'Read-only'] as $role) {
Livewire::actingAs(operator($role))->test(Datacenters::class) Livewire::actingAs(operator($role))->test(Datacenters::class)