diff --git a/app/Console/Commands/BootstrapPassport.php b/app/Console/Commands/BootstrapPassport.php new file mode 100644 index 000000000..02bc43c46 --- /dev/null +++ b/app/Console/Commands/BootstrapPassport.php @@ -0,0 +1,76 @@ +withBootstrapLock(fn () => $seeder->run(app(ClientRepository::class))); + } catch (Throwable $exception) { + $this->error('Passport bootstrap failed: '.$exception->getMessage()); + + return self::FAILURE; + } + + $this->info('Passport personal access client is ready.'); + + return self::SUCCESS; + } + + private function withBootstrapLock(callable $callback): void + { + $connection = DB::connection(); + + match ($connection->getDriverName()) { + 'pgsql' => $this->withPostgresLock($connection, $callback), + 'mysql', 'mariadb' => $this->withMysqlLock($connection, $callback), + default => $callback(), + }; + } + + private function withPostgresLock(ConnectionInterface $connection, callable $callback): void + { + $connection->select('SELECT pg_advisory_lock(?)', [self::PGSQL_LOCK_KEY]); + + try { + $callback(); + } finally { + $connection->select('SELECT pg_advisory_unlock(?)', [self::PGSQL_LOCK_KEY]); + } + } + + private function withMysqlLock(ConnectionInterface $connection, callable $callback): void + { + $result = $connection->selectOne('SELECT GET_LOCK(?, 30) AS lock_acquired', [self::MYSQL_LOCK_NAME]); + + if ((int) ($result->lock_acquired ?? 0) !== 1) { + throw new RuntimeException('Could not acquire Passport bootstrap lock.'); + } + + try { + $callback(); + } finally { + $connection->select('SELECT RELEASE_LOCK(?)', [self::MYSQL_LOCK_NAME]); + } + } +} diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 23aa100fa..b2f17be12 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -79,11 +79,17 @@ if [ ! -f storage/oauth-private.key ]; then php artisan passport:keys --force || true fi -# 10) Wayfinder TS regen — Vite needs the files before it boots. +# 10) Passport personal access client for API key creation. +if [ "${TARGET}" = "production" ]; then + echo "[entrypoint] bootstrapping Passport personal access client" + php artisan trypost:bootstrap-passport +fi + +# 11) Wayfinder TS regen — Vite needs the files before it boots. echo "[entrypoint] regenerating wayfinder helpers" php artisan wayfinder:generate --with-form || true -# 11) Cache strategy: prod = pre-cache; dev = clear. +# 12) Cache strategy: prod = pre-cache; dev = clear. if [ "${TARGET}" = "production" ]; then php artisan config:cache php artisan route:cache @@ -96,7 +102,7 @@ else php artisan event:clear fi -# 12) Permissions. Production php-fpm pool runs as www-data (Alpine default), +# 13) Permissions. Production php-fpm pool runs as www-data (Alpine default), # so storage and bootstrap/cache must be writable by that user — Laravel # needs to write session files, view cache, log files, etc. if [ "${TARGET}" = "production" ]; then diff --git a/tests/Feature/PassportBootstrapCommandTest.php b/tests/Feature/PassportBootstrapCommandTest.php new file mode 100644 index 000000000..7b9d9c9ff --- /dev/null +++ b/tests/Feature/PassportBootstrapCommandTest.php @@ -0,0 +1,223 @@ +where('revoked', false) + ->where(function ($query): void { + $query->whereNull('provider')->orWhere('provider', 'users'); + }) + ->get() + ->filter(fn (Client $client): bool => $client->hasGrantType('personal_access')) + ->values(); +} + +function ensurePassportKeyPermissions(): void +{ + foreach ([storage_path('oauth-private.key'), storage_path('oauth-public.key')] as $path) { + if (is_file($path)) { + chmod($path, 0600); + } + } +} + +function useTemporaryPassportKeys(): void +{ + static $keyPath; + + if (! $keyPath) { + $keyPath = sys_get_temp_dir().'/trypost-passport-test-keys'; + + if (! is_dir($keyPath)) { + mkdir($keyPath, 0700, true); + } + + $privateKey = openssl_pkey_new([ + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]); + + if ($privateKey === false) { + throw new RuntimeException('Could not generate temporary Passport private key.'); + } + + openssl_pkey_export($privateKey, $privateKeyContents); + + $publicKeyDetails = openssl_pkey_get_details($privateKey); + + if (! is_array($publicKeyDetails) || ! isset($publicKeyDetails['key'])) { + throw new RuntimeException('Could not generate temporary Passport public key.'); + } + + file_put_contents($keyPath.'/oauth-private.key', $privateKeyContents); + file_put_contents($keyPath.'/oauth-public.key', $publicKeyDetails['key']); + chmod($keyPath.'/oauth-private.key', 0600); + chmod($keyPath.'/oauth-public.key', 0600); + } + + Passport::loadKeysFrom($keyPath); +} + +it('creates the users personal access client when missing', function () { + Client::query()->delete(); + + expect(passportPersonalAccessClients())->toHaveCount(0); + + $this->artisan('trypost:bootstrap-passport') + ->expectsOutputToContain('Passport personal access client is ready.') + ->assertSuccessful(); + + $clients = passportPersonalAccessClients(); + + expect($clients)->toHaveCount(1); + expect($clients->first()->revoked)->toBeFalse(); +}); + +it('does not create duplicate personal access clients on repeated runs', function () { + $this->artisan('trypost:bootstrap-passport')->assertSuccessful(); + $this->artisan('trypost:bootstrap-passport')->assertSuccessful(); + + expect(passportPersonalAccessClients())->toHaveCount(1); +}); + +it('does not revoke or replace an existing personal access client', function () { + $this->artisan('trypost:bootstrap-passport')->assertSuccessful(); + + $client = passportPersonalAccessClients()->first(); + + $this->artisan('trypost:bootstrap-passport')->assertSuccessful(); + + expect(passportPersonalAccessClients())->toHaveCount(1); + expect($client->refresh()->revoked)->toBeFalse(); + expect(passportPersonalAccessClients()->first()->id)->toBe($client->id); +}); + +it('keeps existing tokens valid after repeated bootstrap runs', function () { + $this->artisan('trypost:bootstrap-passport')->assertSuccessful(); + useTemporaryPassportKeys(); + + $user = User::factory()->create(); + $workspace = Workspace::factory()->create([ + 'account_id' => $user->account_id, + 'user_id' => $user->id, + ]); + $workspace->members()->attach($user->id, ['role' => Role::Admin->value]); + $user->update(['current_workspace_id' => $workspace->id]); + + $token = $user->createToken('Existing')->token; + AccessToken::find($token->id) + ->forceFill(['workspace_id' => $workspace->id]) + ->saveQuietly(); + + $this->artisan('trypost:bootstrap-passport')->assertSuccessful(); + + $token = $token->refresh(); + + expect($token->revoked)->toBeFalse(); + expect($token->client_id)->toBe(passportPersonalAccessClients()->first()->id); +}); + +it('allows creating api keys after passport bootstrap', function () { + $this->artisan('trypost:bootstrap-passport')->assertSuccessful(); + useTemporaryPassportKeys(); + + $user = User::factory()->create(); + $workspace = Workspace::factory()->create([ + 'account_id' => $user->account_id, + 'user_id' => $user->id, + ]); + $workspace->members()->attach($user->id, ['role' => Role::Admin->value]); + $user->update(['current_workspace_id' => $workspace->id]); + + $this->actingAs($user) + ->post(route('app.api-keys.store'), ['name' => 'My API Key']) + ->assertRedirect(); + + $tokens = AccessToken::where('user_id', $user->id) + ->where('workspace_id', $workspace->id) + ->get(); + + expect($tokens)->toHaveCount(1); + expect($tokens->first()->revoked)->toBeFalse(); +}); + +it('creates a new valid client when the only personal access client is revoked', function () { + Client::query()->delete(); + + $revoked = app(ClientRepository::class)->createPersonalAccessGrantClient('Revoked client', 'users'); + $revoked->forceFill(['revoked' => true])->save(); + $revokedSecret = $revoked->secret; + + $this->artisan('trypost:bootstrap-passport') + ->expectsOutputToContain('Passport personal access client is ready.') + ->assertSuccessful(); + + $clients = passportPersonalAccessClients(); + + expect($clients)->toHaveCount(1); + expect($clients->first()->id)->not->toBe($revoked->id); + expect($revoked->refresh()->revoked)->toBeTrue(); + expect($revoked->refresh()->secret)->toBe($revokedSecret); +}); + +it('returns a clear failure when passport bootstrap fails', function () { + $this->app->bind(PassportSeeder::class, fn () => new class extends PassportSeeder + { + public function run(ClientRepository $clients): void + { + throw new RuntimeException('simulated seeder failure'); + } + }); + + $this->artisan('trypost:bootstrap-passport') + ->expectsOutputToContain('Passport bootstrap failed: simulated seeder failure') + ->assertFailed(); +}); + +it('does not regenerate passport keys', function () { + $privateKey = storage_path('oauth-private.key'); + $publicKey = storage_path('oauth-public.key'); + + file_put_contents($privateKey, 'existing-private-key'); + file_put_contents($publicKey, 'existing-public-key'); + + $this->artisan('trypost:bootstrap-passport')->assertSuccessful(); + + expect(file_get_contents($privateKey))->toBe('existing-private-key'); + expect(file_get_contents($publicKey))->toBe('existing-public-key'); +}); + +it('registers the bootstrap command once', function () { + $commands = collect(Artisan::all())->keys() + ->filter(fn (string $name): bool => $name === 'trypost:bootstrap-passport') + ->values(); + + expect($commands)->toHaveCount(1); +}); + +it('does not print the client secret', function () { + Client::query()->delete(); + + Artisan::call('trypost:bootstrap-passport'); + + $output = Artisan::output(); + $client = passportPersonalAccessClients()->sole(); + + expect($output)->not->toContain($client->secret); +});