Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions app/Console/Commands/BootstrapFirstAdmin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<?php

namespace App\Console\Commands;

use App\Models\User;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Silber\Bouncer\BouncerFacade as Bouncer;

#[Signature('app:bootstrap-first-admin {email? : Existing user email to promote}')]
#[Description('Create or promote the first admin user')]
class BootstrapFirstAdmin extends Command
{
public function handle(): int
{
$email = $this->argument('email');

if (is_string($email) && $email !== '') {
return $this->promoteExistingUser($email);
}

return $this->createFirstAdmin();
}

protected function createFirstAdmin(): int
{
Comment on lines +11 to +27
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The command description and method name suggest a “create” flow, but the implementation only promotes an existing user (either the first user or a user by email). To avoid confusing operators, consider updating the description text and/or renaming createFirstAdmin() to reflect the actual behavior (or implement the missing create path if intended).

Copilot uses AI. Check for mistakes.
$user = User::query()->first();
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

User::query()->first() has no deterministic ordering, so “first user” may vary by database engine/query plan. If this command is meant to target the earliest-created user, explicitly order by a stable column (e.g., id or created_at) before selecting the first record.

Suggested change
$user = User::query()->first();
$user = User::query()->orderBy('id')->first();

Copilot uses AI. Check for mistakes.

if ($user === null) {
$this->components->error('No users exist yet. Create a user first, then promote it with the email argument.');

return self::FAILURE;
}

$this->ensureAdminRoleExists();

if ($user->isAn('admin')) {
$this->components->info("The first user, {$user->email}, is already an admin.");

return self::SUCCESS;
}

$user->assign('admin');
Comment on lines +26 to +44
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

createFirstAdmin() will promote the first user even if an admin already exists (e.g., the second user is already an admin). That can unexpectedly grant additional admin access on subsequent runs and undermines the “first admin bootstrap” safety goal. Consider first checking whether any user already has the admin role and, if so, exiting without changing roles (or requiring the email argument).

Copilot uses AI. Check for mistakes.
Bouncer::refreshFor($user);

$this->components->info("Promoted {$user->email} to admin.");

return self::SUCCESS;
}

protected function promoteExistingUser(string $email): int
{
$user = User::query()->where('email', $email)->first();

if ($user === null) {
$this->components->error("No user was found for {$email}.");

return self::FAILURE;
}

$this->ensureAdminRoleExists();

if ($user->isAn('admin')) {
$this->components->info("{$user->email} is already an admin.");

return self::SUCCESS;
}

$user->assign('admin');
Bouncer::refreshFor($user);

$this->components->info("Promoted {$user->email} to admin.");

return self::SUCCESS;
}

protected function ensureAdminRoleExists(): void
{
Bouncer::role()->firstOrCreate([
'name' => 'admin',
]);
}
}
31 changes: 31 additions & 0 deletions tests/Feature/Admin/BootstrapFirstAdminCommandTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

it('promotes the first user when no admin exists', function (): void {
$user = User::factory()->create();

$this->artisan('app:bootstrap-first-admin')
->assertExitCode(0);

expect($user->fresh()->isAn('admin'))->toBeTrue();
});
Comment on lines +8 to +15
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test coverage currently exercises “promote first user”, “promote by email”, and “missing user by email”, but it doesn’t cover the bootstrap safety cases introduced in the command: (1) no users exist (should fail), and (2) an admin already exists but is not the first user (should not silently grant admin to the first user). Adding focused tests for these cases would help prevent privilege-escalation regressions.

Copilot uses AI. Check for mistakes.

it('promotes an existing user by email', function (): void {
$user = User::factory()->create();

$this->artisan('app:bootstrap-first-admin', [
'email' => $user->email,
])->assertExitCode(0);

expect($user->fresh()->isAn('admin'))->toBeTrue();
});

it('fails when the user does not exist', function (): void {
$this->artisan('app:bootstrap-first-admin', [
'email' => 'missing@example.com',
])->assertExitCode(1);
});
Loading