diff --git a/app/Livewire/Security/ApiTokens.php b/app/Livewire/Security/ApiTokens.php index 4501d81c57..5436cf1efa 100644 --- a/app/Livewire/Security/ApiTokens.php +++ b/app/Livewire/Security/ApiTokens.php @@ -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, scopes: list|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 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', [ diff --git a/app/Models/PersonalAccessToken.php b/app/Models/PersonalAccessToken.php index 375187a46a..0c1c0f555f 100644 --- a/app/Models/PersonalAccessToken.php +++ b/app/Models/PersonalAccessToken.php @@ -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( diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 510ffe3b7b..075fd5d6e3 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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 diff --git a/database/migrations/2026_10_27_142736_add_paused_at_to_personal_access_tokens_table.php b/database/migrations/2026_10_27_142736_add_paused_at_to_personal_access_tokens_table.php new file mode 100644 index 0000000000..60e4a87217 --- /dev/null +++ b/database/migrations/2026_10_27_142736_add_paused_at_to_personal_access_tokens_table.php @@ -0,0 +1,24 @@ +timestamp('paused_at')->nullable(); + }); + } + } + + public function down(): void + { + Schema::table('personal_access_tokens', function (Blueprint $table) { + $table->dropColumn('paused_at'); + }); + } +}; diff --git a/resources/css/app.css b/resources/css/app.css index 6d4621eee2..85995a8452 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -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), diff --git a/resources/views/livewire/security/api-tokens.blade.php b/resources/views/livewire/security/api-tokens.blade.php index 988d83de32..964f57068b 100644 --- a/resources/views/livewire/security/api-tokens.blade.php +++ b/resources/views/livewire/security/api-tokens.blade.php @@ -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
@@ -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) -
- + + - + @if ($editingToken) + + + @else + + @endif + @if ($editingToken) +

+ The token value does not change. Clients that use it get the new access on their next request. +

+ @endif +
@@ -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]">
-
- {{ $token->name }} +
+ {{ $token->name }} + @if ($token->isPaused()) + + @endif
@if ($token->scopes === null) @@ -245,8 +271,17 @@ {{ $token->expires_at->format('Y-m-d') }} @endif
-
+
@if (auth()->id() === $token->tokenable_id) + +