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
parent
88957f8f57
commit
8304f2e7dc
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\User;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
|
@ -9,12 +10,20 @@ use Symfony\Component\HttpFoundation\Response;
|
|||
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
|
||||
{
|
||||
// console.view via RBAC, or the legacy is_admin flag as a safety net.
|
||||
abort_unless((bool) $request->user()?->isOperator(), 403);
|
||||
$user = $request->user();
|
||||
|
||||
if ($user instanceof User && $user->is_admin && ! $user->isOperator()) {
|
||||
$user->assignRole('Owner');
|
||||
}
|
||||
|
||||
abort_unless((bool) $user?->can('console.view'), 403);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,13 @@ class Datacenters extends Component
|
|||
// stored — otherwise `FSN` passes the rule but the lowercased insert collides.
|
||||
$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([
|
||||
'code' => $data['code'],
|
||||
|
|
|
|||
|
|
@ -22,14 +22,10 @@ class User extends Authenticatable
|
|||
/** The five operator roles that grant access to the admin console. */
|
||||
public const OPERATOR_ROLES = ['Owner', 'Admin', 'Support', 'Billing', 'Read-only'];
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** True when this user holds an operator role (the RBAC console boundary). */
|
||||
public function isOperator(): bool
|
||||
{
|
||||
return $this->is_admin || $this->hasAnyRole(self::OPERATOR_ROLES);
|
||||
return $this->hasAnyRole(self::OPERATOR_ROLES);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ Broadcast::channel('App.Models.User.{id}', function ($user, $id) {
|
|||
});
|
||||
|
||||
// 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.
|
||||
Broadcast::channel('customer.{customerId}.run', function ($user, $customerId) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
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 () {
|
||||
foreach (['Support', 'Billing', 'Read-only'] as $role) {
|
||||
Livewire::actingAs(operator($role))->test(Datacenters::class)
|
||||
|
|
|
|||
Loading…
Reference in New Issue