feat(api-tokens): edit token access and pause or resume tokens (#12396)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-10-30 13:20:19 +01:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 263eab5d27
commit 76b89fa8e9
9 changed files with 441 additions and 50 deletions
+165 -38
View File
@@ -20,6 +20,17 @@ class ApiTokens extends Component
{
use AuthorizesRequests;
/**
* Expiry choice in the edit form that keeps the current expiry date.
*/
private const KEEP_EXPIRY = 'keep';
/**
* The token loaded in the form for editing; null when the form creates a new token.
*/
#[Locked]
public ?int $editingTokenId = null;
public ?string $description = null;
/**
@@ -186,37 +197,9 @@ class ApiTokens extends Component
try {
$this->authorize('create', PersonalAccessToken::class);
$validated = $this->validate([
'description' => 'required|min:3|max:255',
'expiresInDays' => 'nullable|integer|in:7,30,60,90,365',
'permissions' => 'required|array|min:1',
'permissions.*' => ['required', 'string', Rule::in(['read', 'read:sensitive', 'write', 'write:sensitive', 'deploy', 'delete', 'root'])],
'scopeMode' => ['required', Rule::in(['team', 'selected'])],
'selectedScopes' => ['exclude_unless:scopeMode,selected', 'required', 'array', 'min:1'],
'selectedScopes.*' => ['string', 'regex:/^(project|environment|resource):[A-Za-z0-9_-]+$/'],
], [
'selectedScopes.required' => 'Select at least one project, environment, or resource.',
]);
$permissions = array_values($validated['permissions']);
$teamId = currentTeam()->id;
$scopes = $this->scopeMode === 'selected' ? $this->parseSelectedScopes($teamId) : null;
// Never trust the $this->canUse* booleans: check the current user's access in the team again.
$resolver = app(AccessResolver::class);
$isAdmin = auth()->user()->isAdminOfTeam($teamId);
if (in_array('write:sensitive', $permissions, true) && ! $isAdmin) {
throw new \Exception('You do not have permission to create tokens with write:sensitive permissions.');
}
$levels = AccessLevel::parseMany($permissions);
$grants = $scopes === null
? [Grant::team($levels)]
: array_map(fn (Grant $scope) => new Grant($scope->scope, $scope->uuid, $levels), $scopes);
if (! $resolver->userHolds(auth()->user(), $teamId, $grants)) {
throw new \Exception('A token cannot have more access than you have. Choose fewer permissions or narrower projects, environments, or resources.');
}
$expiresAt = filled($validated['expiresInDays'] ?? null) ? now()->addDays((int) $validated['expiresInDays']) : null;
$storedScopes = $scopes === null ? null : array_map(fn (Grant $scope) => ['scope' => $scope->scope->value, 'uuid' => $scope->uuid], $scopes);
['permissions' => $permissions, 'scopes' => $storedScopes, 'expiresInDays' => $expiresInDays] = $this->validateTokenForm($teamId);
$expiresAt = filled($expiresInDays) ? now()->addDays((int) $expiresInDays) : null;
// One transaction, so a token never exists team-wide when saving its scopes fails.
$token = DB::transaction(function () use ($permissions, $expiresAt, $teamId, $storedScopes) {
$token = auth()->user()->createToken($this->description, $permissions, $expiresAt, $teamId);
@@ -239,6 +222,129 @@ class ApiTokens extends Component
}
}
/**
* Load a token into the form, so its description, permissions, access, and expiry can be changed.
*/
public function startEditing(int $id)
{
try {
$token = $this->findOwnToken($id);
$this->authorize('update', $token);
$this->resetValidation();
$this->editingTokenId = $token->id;
$this->description = $token->name;
$this->permissions = $token->abilities ?? ['read'];
sort($this->permissions);
$this->scopeMode = $token->scopes === null ? 'team' : 'selected';
$this->selectedScopes = collect($token->scopes ?? [])->map(fn (array $scope) => "{$scope['scope']}:{$scope['uuid']}")->values()->all();
$this->expiresInDays = self::KEEP_EXPIRY;
} catch (\Exception $e) {
return handleError($e, $this);
}
}
public function cancelEditing(): void
{
$this->resetValidation();
$this->reset('editingTokenId', 'description', 'expiresInDays', 'permissions', 'selectedScopes');
$this->scopeMode = $this->canUseTeamScope ? 'team' : 'selected';
}
/**
* Save the edited token. The token value does not change, and the next request with it uses the new access.
*/
public function updateToken()
{
try {
$token = $this->findOwnToken((int) $this->editingTokenId);
$this->authorize('update', $token);
$teamId = currentTeam()->id;
['permissions' => $permissions, 'scopes' => $storedScopes, 'expiresInDays' => $expiresInDays] = $this->validateTokenForm($teamId, editing: true);
$before = ['abilities' => $token->abilities, 'scopes' => $token->scopes, 'expires_at' => $token->expires_at?->toIso8601String()];
$token->forceFill([
'name' => $this->description,
'abilities' => $permissions,
'scopes' => $storedScopes,
]);
if ($expiresInDays !== self::KEEP_EXPIRY) {
$token->forceFill([
'expires_at' => filled($expiresInDays) ? now()->addDays((int) $expiresInDays) : null,
// A new expiry date needs a new expiration warning.
'api_token_expiration_warning_sent_at' => null,
]);
}
$token->save();
auditLog('ui.api_token.updated', [
'team_id' => $teamId,
'api_token_name' => $token->name,
'before' => $before,
'after' => ['abilities' => $permissions, 'scopes' => $storedScopes, 'expires_at' => $token->expires_at?->toIso8601String()],
]);
$this->cancelEditing();
$this->getTokens();
$this->dispatch('success', 'API token updated.');
} catch (\Exception $e) {
return handleError($e, $this);
}
}
/**
* Validate the form and check that the user may give the token this access.
* Never trust the $this->canUse* booleans: check the current user's access in the team again.
*
* @return array{permissions: list<string>, scopes: list<array{scope: string, uuid: string}>|null, expiresInDays: int|string|null}
*/
private function validateTokenForm(int $teamId, bool $editing = false): array
{
$expiryChoices = array_keys($this->expirationOptions);
if ($editing) {
$expiryChoices[] = self::KEEP_EXPIRY;
}
$validated = $this->validate([
'description' => 'required|min:3|max:255',
'expiresInDays' => ['nullable', Rule::in($expiryChoices)],
'permissions' => 'required|array|min:1',
'permissions.*' => ['required', 'string', Rule::in(['read', 'read:sensitive', 'write', 'write:sensitive', 'deploy', 'delete', 'root'])],
'scopeMode' => ['required', Rule::in(['team', 'selected'])],
'selectedScopes' => ['exclude_unless:scopeMode,selected', 'required', 'array', 'min:1'],
'selectedScopes.*' => ['string', 'regex:/^(project|environment|resource):[A-Za-z0-9_-]+$/'],
], [
'selectedScopes.required' => 'Select at least one project, environment, or resource.',
]);
$permissions = array_values($validated['permissions']);
$scopes = $this->scopeMode === 'selected' ? $this->parseSelectedScopes($teamId) : null;
if (in_array('write:sensitive', $permissions, true) && ! auth()->user()->isAdminOfTeam($teamId)) {
throw new \Exception('You do not have permission to give tokens write:sensitive permissions.');
}
$levels = AccessLevel::parseMany($permissions);
$grants = $scopes === null
? [Grant::team($levels)]
: array_map(fn (Grant $scope) => new Grant($scope->scope, $scope->uuid, $levels), $scopes);
if (! app(AccessResolver::class)->userHolds(auth()->user(), $teamId, $grants)) {
throw new \Exception('A token cannot have more access than you have. Choose fewer permissions or narrower projects, environments, or resources.');
}
return [
'permissions' => $permissions,
'scopes' => $scopes === null ? null : array_map(fn (Grant $scope) => ['scope' => $scope->scope->value, 'uuid' => $scope->uuid], $scopes),
'expiresInDays' => $validated['expiresInDays'] ?? null,
];
}
private function findOwnToken(int $id): PersonalAccessToken
{
return auth()->user()->tokens()
->where('team_id', currentTeam()->id)
->where('id', $id)
->firstOrFail();
}
/**
* @return list<Grant> scopes without levels; a token's levels are its abilities
*/
@@ -264,13 +370,34 @@ class ApiTokens extends Component
return $scopes;
}
/**
* Pause a token so it stops authenticating requests, or resume a paused token. The token value does not change.
* The caller sends the wanted state, so a stale page cannot resume a token that another page paused.
*/
public function setPaused(int $id, bool $paused)
{
try {
$token = $this->findOwnToken($id);
$this->authorize('update', $token);
if ($token->isPaused() !== $paused) {
$token->forceFill(['paused_at' => $paused ? now() : null])->save();
auditLog($paused ? 'ui.api_token.paused' : 'ui.api_token.resumed', [
'team_id' => currentTeam()->id,
'api_token_name' => $token->name,
]);
}
$this->getTokens();
$this->dispatch('success', $paused ? 'API token paused.' : 'API token resumed.');
} catch (\Exception $e) {
return handleError($e, $this);
}
}
public function roll(int $id)
{
try {
$token = auth()->user()->tokens()
->where('team_id', currentTeam()->id)
->where('id', $id)
->firstOrFail();
$token = $this->findOwnToken($id);
$this->authorize('update', $token);
$plainTextToken = $token->roll();
auditLog('ui.api_token.rolled', [
@@ -287,11 +414,11 @@ class ApiTokens extends Component
public function revoke(int $id)
{
try {
$token = auth()->user()->tokens()
->where('team_id', currentTeam()->id)
->where('id', $id)
->firstOrFail();
$token = $this->findOwnToken($id);
$this->authorize('delete', $token);
if ($this->editingTokenId === $token->id) {
$this->cancelEditing();
}
$tokenName = $token->name;
$token->delete();
auditLog('ui.api_token.revoked', [
+10
View File
@@ -15,6 +15,7 @@ class PersonalAccessToken extends SanctumPersonalAccessToken
'api_token_expiration_warning_sent_at',
'team_id',
'scopes',
'paused_at',
];
protected function casts(): array
@@ -22,9 +23,18 @@ class PersonalAccessToken extends SanctumPersonalAccessToken
return [
'api_token_expiration_warning_sent_at' => 'datetime',
'scopes' => 'array',
'paused_at' => 'datetime',
];
}
/**
* A paused token is kept, but it does not authenticate requests until it is resumed.
*/
public function isPaused(): bool
{
return $this->paused_at !== null;
}
public static function generateSecret(): string
{
return sprintf(
+3
View File
@@ -108,6 +108,9 @@ class AppServiceProvider extends ServiceProvider
private function configureSanctumModel(): void
{
Sanctum::usePersonalAccessTokenModel(PersonalAccessToken::class);
Sanctum::authenticateAccessTokensUsing(
fn (PersonalAccessToken $token, bool $isValid): bool => $isValid && ! $token->isPaused()
);
}
private function configureOidcSocialite(): void
@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasColumn('personal_access_tokens', 'paused_at')) {
Schema::table('personal_access_tokens', function (Blueprint $table) {
$table->timestamp('paused_at')->nullable();
});
}
}
public function down(): void
{
Schema::table('personal_access_tokens', function (Blueprint $table) {
$table->dropColumn('paused_at');
});
}
};
+2 -2
View File
@@ -3766,7 +3766,7 @@ input[type="search"]::-webkit-search-results-decoration {
}
.api-tokens-table-grid {
grid-template-columns: minmax(9rem, 1.15fr) minmax(14rem, 1.9fr) 7rem 6.5rem 6.5rem 5.5rem;
grid-template-columns: minmax(9rem, 1.15fr) minmax(14rem, 1.9fr) 7rem 6.5rem 6.5rem 10.5rem;
}
.fleet-servers-table-grid {
@@ -3861,7 +3861,7 @@ input[type="search"]::-webkit-search-results-decoration {
}
.api-tokens-table-grid {
grid-template-columns: minmax(0, 1fr) minmax(9rem, 1.3fr) 6.5rem 4.5rem;
grid-template-columns: minmax(0, 1fr) minmax(9rem, 1.3fr) 6.5rem 10.5rem;
}
.api-tokens-table-grid > :nth-child(3),
@@ -11,6 +11,14 @@
->values()
->push(['value' => '', 'label' => 'Never'])
->all();
$editingToken = $editingTokenId ? $tokens->firstWhere('id', $editingTokenId) : null;
if ($editingToken) {
$currentExpiry = $editingToken->expires_at?->format('Y-m-d') ?? 'Never';
array_unshift($expirationList, [
'value' => 'keep',
'label' => 'Keep current ('.($editingToken->expires_at?->isPast() ? 'expired '.$currentExpiry : $currentExpiry).')',
]);
}
@endphp
<div class="application-settings-form flex flex-col gap-6">
@@ -18,16 +26,31 @@
message="REST API requests are rejected. Tokens still authenticate the MCP endpoint when the MCP server is enabled." />
@can('create', App\Models\PersonalAccessToken::class)
<form wire:submit="addNewToken">
<x-application.settings-section title="New API token">
<form wire:submit="{{ $editingToken ? 'updateToken' : 'addNewToken' }}" id="api-token-form">
<x-application.settings-section :title="$editingToken ? 'Edit API token' : 'New API token'">
<x-slot:actions>
<button type="submit"
class="button button-highlighted">
<x-reicon name="plus" class="size-3.5" />
Create token
</button>
@if ($editingToken)
<button type="button" wire:click="cancelEditing" class="button">
Cancel
</button>
<button type="submit" class="button button-highlighted">
Save changes
</button>
@else
<button type="submit"
class="button button-highlighted">
<x-reicon name="plus" class="size-3.5" />
Create token
</button>
@endif
</x-slot:actions>
@if ($editingToken)
<p class="mb-4 text-[12px] text-neutral-500 dark:text-fg-dim">
The token value does not change. Clients that use it get the new access on their next request.
</p>
@endif
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.input required id="description" label="Description"
placeholder="CI deployment token" />
@@ -208,8 +231,11 @@
x-show="isVisible(@js((string) $token->id))"
class="data-table-row api-tokens-table-grid border-b border-neutral-200 last:border-b-0 dark:border-white/[0.07]">
<div class="min-w-0">
<div class="truncate text-[12px] font-medium text-black dark:text-fg">
{{ $token->name }}
<div class="flex min-w-0 items-center gap-1.5">
<span class="truncate text-[12px] font-medium text-black dark:text-fg">{{ $token->name }}</span>
@if ($token->isPaused())
<x-status-badge label="Paused" type="warning" />
@endif
</div>
<div class="truncate text-[11px] text-neutral-500 dark:text-fg-dim">
@if ($token->scopes === null)
@@ -245,8 +271,17 @@
{{ $token->expires_at->format('Y-m-d') }}
@endif
</div>
<div class="flex justify-end">
<div class="flex items-center justify-end gap-1">
@if (auth()->id() === $token->tokenable_id)
<button type="button" wire:click="startEditing({{ $token->id }})"
x-on:click="document.getElementById('api-token-form')?.scrollIntoView({ behavior: 'smooth' })"
class="inline-flex h-7 items-center rounded-md px-2 text-[11px] font-medium text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-dim dark:hover:bg-white/[0.07] dark:hover:text-fg">
Edit
</button>
<button type="button" wire:click="setPaused({{ $token->id }}, {{ $token->isPaused() ? 'false' : 'true' }})"
class="inline-flex h-7 items-center rounded-md px-2 text-[11px] font-medium text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-dim dark:hover:bg-white/[0.07] dark:hover:text-fg">
{{ $token->isPaused() ? 'Resume' : 'Pause' }}
</button>
<x-table.dropdown panel-class="w-36!" role="menu">
<x-slot:trigger>
<button type="button" class="icon-button" title="Token actions"
+123
View File
@@ -343,4 +343,127 @@ describe('token form', function () {
expect($member->tokens()->pluck('name')->all())->toBe(['shop']);
});
test('edits a team-wide token to one project, and the same token value then sees only that project', function () {
$plainToken = scopedTokenTestToken($this->admin, $this->team, ['read'], null);
$token = PersonalAccessToken::query()->sole();
$this->actingAs($this->admin);
Livewire::test(ApiTokens::class)
->call('startEditing', $token->id)
->assertSet('editingTokenId', $token->id)
->assertSet('description', 'scoped-test')
->assertSet('permissions', ['read'])
->assertSet('scopeMode', 'team')
->assertSet('expiresInDays', 'keep')
->set('scopeMode', 'selected')
->set('selectedScopes', ["project:{$this->shop->uuid}"])
->call('updateToken')
->assertHasNoErrors()
->assertNotDispatched('error')
->assertSet('editingTokenId', null);
expect($token->fresh()->scopes)->toBe([['scope' => 'project', 'uuid' => $this->shop->uuid]])
->and(scopedTokenTestApi($plainToken, 'GET', '/api/v1/projects')->assertOk()->json('*.name'))->toBe(['shop']);
});
test('edits permissions and keeps the expiry, then sets a new expiry', function () {
$plainToken = scopedTokenTestToken($this->admin, $this->team, ['read', 'write'], null);
$token = PersonalAccessToken::query()->sole();
$expiresAt = now()->addDays(10)->startOfSecond();
$token->forceFill(['expires_at' => $expiresAt, 'api_token_expiration_warning_sent_at' => now()])->save();
$this->actingAs($this->admin);
$form = Livewire::test(ApiTokens::class)
->call('startEditing', $token->id)
->set('permissions', ['read'])
->call('updateToken')
->assertHasNoErrors();
$token->refresh();
expect($token->abilities)->toBe(['read'])
->and($token->expires_at->equalTo($expiresAt))->toBeTrue()
->and($token->api_token_expiration_warning_sent_at)->not->toBeNull();
scopedTokenTestApi($plainToken, 'POST', '/api/v1/projects', ['name' => 'new'])->assertForbidden();
$form->call('startEditing', $token->id)
->set('expiresInDays', 90)
->call('updateToken')
->assertHasNoErrors();
$token->refresh();
expect(round(now()->diffInDays($token->expires_at)))->toEqual(90)
->and($token->api_token_expiration_warning_sent_at)->toBeNull();
});
test('a member cannot give a token more access than their grants by editing it', function () {
$member = scopedTokenTestMember($this->team, [['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['read']]]);
scopedTokenTestToken($member, $this->team, ['read'], [['scope' => 'project', 'uuid' => $this->shop->uuid]]);
$token = PersonalAccessToken::query()->sole();
$this->actingAs($member);
Livewire::test(ApiTokens::class)
->call('startEditing', $token->id)
->assertSet('editingTokenId', $token->id)
->set('selectedScopes', ["project:{$this->blog->uuid}"])
->call('updateToken')
->assertDispatched('error');
expect($token->fresh()->scopes)->toBe([['scope' => 'project', 'uuid' => $this->shop->uuid]]);
});
test('cannot edit a token of another user or of another team', function () {
$otherAdmin = User::factory()->create();
$this->team->members()->attach($otherAdmin->id, ['role' => 'admin']);
scopedTokenTestToken($otherAdmin, $this->team, ['read'], null);
$otherTeam = Team::factory()->create();
$otherTeam->members()->attach($this->admin->id, ['role' => 'admin']);
scopedTokenTestToken($this->admin, $otherTeam, ['read'], null);
$this->actingAs($this->admin);
foreach (PersonalAccessToken::query()->get() as $token) {
Livewire::test(ApiTokens::class)
->call('startEditing', $token->id)
->assertSet('editingTokenId', null);
}
});
test('a paused token is rejected by the API and MCP until it is resumed', function () {
$plainToken = scopedTokenTestToken($this->admin, $this->team, ['read'], null);
$token = PersonalAccessToken::query()->sole();
$this->actingAs($this->admin);
Livewire::test(ApiTokens::class)
->call('setPaused', $token->id, true)
->assertDispatched('success', 'API token paused.');
expect($token->fresh()->isPaused())->toBeTrue();
// A stale page that still shows "Pause" must not resume the token.
Livewire::test(ApiTokens::class)->call('setPaused', $token->id, true);
expect($token->fresh()->isPaused())->toBeTrue();
scopedTokenTestApi($plainToken, 'GET', '/api/v1/projects')->assertUnauthorized();
scopedTokenTestMcp($plainToken, 'tools/list')->assertUnauthorized();
$this->actingAs($this->admin);
Livewire::test(ApiTokens::class)
->call('setPaused', $token->id, false)
->assertDispatched('success', 'API token resumed.');
expect($token->fresh()->isPaused())->toBeFalse();
scopedTokenTestApi($plainToken, 'GET', '/api/v1/projects')->assertOk();
scopedTokenTestMcp($plainToken, 'tools/list')->assertOk();
});
test('cannot pause a token of another user', function () {
$otherAdmin = User::factory()->create();
$this->team->members()->attach($otherAdmin->id, ['role' => 'admin']);
scopedTokenTestToken($otherAdmin, $this->team, ['read'], null);
$token = PersonalAccessToken::query()->sole();
$this->actingAs($this->admin);
Livewire::test(ApiTokens::class)->call('setPaused', $token->id, true);
expect($token->fresh()->isPaused())->toBeFalse();
});
});
+3
View File
@@ -1173,6 +1173,9 @@ test('critical operational events persist with their source action and actor', f
'ui.database.restore_started',
'ui.scheduled_task.executed',
'ui.api_token.created',
'ui.api_token.updated',
'ui.api_token.paused',
'ui.api_token.resumed',
'ui.api_token.revoked',
'ui.team_member.role_updated',
'ui.team_member.removed',
+66
View File
@@ -0,0 +1,66 @@
<?php
use App\Models\PersonalAccessToken;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
seedBrowserResourceStack();
$this->token = User::query()->where('email', 'test@example.com')->sole()
->createToken('ci-token', ['read'], null, 0)->accessToken;
});
it('edits a token from the token list and saves the changes', function () {
loginAndSkipBoarding();
$page = visit('/security/api-tokens')
->assertSee('New API token')
->click('button:visible:has-text("Edit")')
->assertSee('Edit API token')
->assertSee('Keep current (Never)')
->assertValue('description', 'ci-token')
->fill('description', 'deploy-token')
->click('button:visible:has-text("Save changes")')
->wait(1)
->assertSee('New API token')
->assertSee('deploy-token');
expect(PersonalAccessToken::query()->sole()->name)->toBe('deploy-token');
$page->screenshot(filename: 'api-token-edited');
});
it('cancels editing without saving', function () {
loginAndSkipBoarding();
$page = visit('/security/api-tokens')
->click('button:visible:has-text("Edit")')
->fill('description', 'changed')
->click('button:visible:has-text("Cancel")')
->wait(1)
->assertSee('New API token')
->assertValue('description', '');
expect($this->token->fresh()->name)->toBe('ci-token');
$page->screenshot(filename: 'api-token-edit-cancelled');
});
it('pauses and resumes a token from the token list', function () {
loginAndSkipBoarding();
$page = visit('/security/api-tokens')
->click('button:visible:has-text("Pause")')
->assertSee('API token paused.')
->assertSee('Paused')
->assertSee('Resume');
expect($this->token->fresh()->isPaused())->toBeTrue();
$page->screenshot(filename: 'api-token-paused');
$page->click('button:visible:has-text("Resume")')
->assertSee('API token resumed.');
expect($this->token->fresh()->isPaused())->toBeFalse();
$page->screenshot(filename: 'api-token-resumed');
});