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;
|
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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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'],
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue