fix(api): align token permission handling (#11981)

This commit is contained in:
Andras Bacsai
2026-09-24 12:26:53 +02:00
committed by GitHub
3 changed files with 70 additions and 19 deletions
+15 -10
View File
@@ -4,6 +4,7 @@ namespace App\Livewire\Security;
use App\Models\InstanceSettings;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Validation\Rule;
use Laravel\Sanctum\PersonalAccessToken;
use Livewire\Attributes\Locked;
use Livewire\Component;
@@ -115,35 +116,39 @@ 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', 'root'])],
]);
$permissions = array_values($validated['permissions']);
// Re-evaluate policies fresh against the current authenticated user.
// Never trust $this->canUse* booleans — they come from the Livewire
// snapshot which can be replayed from another user's session.
if (in_array('root', $this->permissions, true) && ! auth()->user()->can('useRootPermissions', PersonalAccessToken::class)) {
if (in_array('root', $permissions, true) && ! auth()->user()->can('useRootPermissions', PersonalAccessToken::class)) {
throw new \Exception('You do not have permission to create tokens with root permissions.');
}
if (array_intersect(['write', 'write:sensitive'], $this->permissions) && ! auth()->user()->can('useWritePermissions', PersonalAccessToken::class)) {
if (array_intersect(['write', 'write:sensitive'], $permissions) && ! auth()->user()->can('useWritePermissions', PersonalAccessToken::class)) {
throw new \Exception('You do not have permission to create tokens with write permissions.');
}
if (in_array('deploy', $this->permissions, true) && ! auth()->user()->can('useDeployPermissions', PersonalAccessToken::class)) {
if (in_array('deploy', $permissions, true) && ! auth()->user()->can('useDeployPermissions', PersonalAccessToken::class)) {
throw new \Exception('You do not have permission to create tokens with deploy permissions.');
}
if (in_array('read:sensitive', $this->permissions, true) && ! auth()->user()->can('useSensitivePermissions', PersonalAccessToken::class)) {
if (in_array('read:sensitive', $permissions, true) && ! auth()->user()->can('useSensitivePermissions', PersonalAccessToken::class)) {
throw new \Exception('You do not have permission to create tokens with read:sensitive permissions.');
}
$this->validate([
'description' => 'required|min:3|max:255',
'expiresInDays' => 'nullable|integer|in:7,30,60,90,365',
]);
$expiresAt = $this->expiresInDays ? now()->addDays($this->expiresInDays) : null;
$token = auth()->user()->createToken($this->description, array_values($this->permissions), $expiresAt);
$token = auth()->user()->createToken($this->description, $permissions, $expiresAt);
auditLog('ui.api_token.created', [
'team_id' => currentTeam()->id,
'api_token_name' => $this->description,
'abilities' => array_values($this->permissions),
'abilities' => $permissions,
'expires_at' => $expiresAt?->toIso8601String(),
]);
$this->getTokens();
+1 -1
View File
@@ -235,7 +235,7 @@ class User extends Authenticatable implements SendsEmail
return $new_team;
}
public function createToken(string $name, array $abilities = ['*'], ?DateTimeInterface $expiresAt = null)
public function createToken(string $name, array $abilities = ['read'], ?DateTimeInterface $expiresAt = null)
{
$plainTextToken = sprintf(
'%s%s%s',
@@ -5,12 +5,13 @@ use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0], ['is_api_enabled' => true]);
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0, 'is_api_enabled' => true]));
$this->team = Team::factory()->create();
@@ -22,6 +23,34 @@ beforeEach(function () {
});
describe('Livewire ApiTokens — member cannot create elevated tokens', function () {
test('tokens created without explicit abilities have read access only', function () {
session(['currentTeam' => $this->team]);
$token = $this->member->createToken('default-token')->accessToken;
expect($token->abilities)->toBe(['read'])
->and($token->can('read'))->toBeTrue()
->and($token->can('write'))->toBeFalse()
->and($token->can('root'))->toBeFalse();
});
test('member cannot create a token with an unknown or wildcard ability', function (array $permissions) {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(ApiTokens::class)
->set('description', 'invalid-token')
->set('permissions', $permissions)
->call('addNewToken');
expect($this->member->tokens()->count())->toBe(0);
})->with([
'wildcard' => [['*']],
'wildcard with read' => [['read', '*']],
'unknown ability' => [['read', 'admin']],
'non-string ability' => [['read', 1]],
]);
test('member cannot create token with root permissions', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
@@ -78,13 +107,9 @@ describe('Livewire ApiTokens — member cannot create elevated tokens', function
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
// Simulate snapshot replay: force the boolean to true
Livewire::test(ApiTokens::class)
->set('canUseRootPermissions', true)
->set('description', 'sneaky-root-token')
->set('permissions', ['root'])
->call('addNewToken')
->assertDispatched('error');
expect(fn () => Livewire::test(ApiTokens::class)
->set('canUseRootPermissions', true))
->toThrow(CannotUpdateLockedPropertyException::class);
expect($this->member->tokens()->count())->toBe(0);
});
@@ -116,9 +141,30 @@ describe('Livewire ApiTokens — member cannot create elevated tokens', function
expect($this->owner->tokens()->count())->toBe(1);
expect($this->owner->tokens()->first()->abilities)->toBe(['root']);
});
test('owner cannot create a wildcard token', function () {
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
Livewire::test(ApiTokens::class)
->set('description', 'owner-wildcard-token')
->set('permissions', ['*'])
->call('addNewToken');
expect($this->owner->tokens()->count())->toBe(0);
});
});
describe('ApiAbility middleware — member with elevated token blocked', function () {
test('existing member wildcard token is blocked', function () {
session(['currentTeam' => $this->team]);
$token = $this->member->createToken('legacy-wildcard-token', ['*']);
$this->withToken($token->plainTextToken)
->getJson('/api/v1/projects')
->assertForbidden();
});
test('member root token is blocked on team_id=0 (root team)', function () {
// Create root team with id=0
$rootTeam = Team::factory()->create(['id' => 0]);