diff --git a/app/Livewire/Security/ApiTokens.php b/app/Livewire/Security/ApiTokens.php index af2ae189a..ffd8f28cf 100644 --- a/app/Livewire/Security/ApiTokens.php +++ b/app/Livewire/Security/ApiTokens.php @@ -50,31 +50,29 @@ private function getTokens() public function updatedPermissions($permissionToUpdate) { - // Check if user is trying to use restricted permissions - if ($permissionToUpdate == 'root' && ! $this->canUseRootPermissions) { + // Re-evaluate policies fresh — never trust stored snapshot booleans + if ($permissionToUpdate == 'root' && ! auth()->user()->can('useRootPermissions', PersonalAccessToken::class)) { $this->dispatch('error', 'You do not have permission to use root permissions.'); - // Remove root from permissions if it was somehow added $this->permissions = array_diff($this->permissions, ['root']); return; } - if (in_array($permissionToUpdate, ['write', 'write:sensitive']) && ! $this->canUseWritePermissions) { + if (in_array($permissionToUpdate, ['write', 'write:sensitive']) && ! auth()->user()->can('useWritePermissions', PersonalAccessToken::class)) { $this->dispatch('error', 'You do not have permission to use write permissions.'); - // Remove write permissions if they were somehow added $this->permissions = array_diff($this->permissions, ['write', 'write:sensitive']); return; } - if ($permissionToUpdate == 'deploy' && ! $this->canUseDeployPermissions) { + if ($permissionToUpdate == 'deploy' && ! auth()->user()->can('useDeployPermissions', PersonalAccessToken::class)) { $this->dispatch('error', 'You do not have permission to use deploy permissions.'); $this->permissions = array_diff($this->permissions, ['deploy']); return; } - if ($permissionToUpdate == 'read:sensitive' && ! $this->canUseSensitivePermissions) { + if ($permissionToUpdate == 'read:sensitive' && ! auth()->user()->can('useSensitivePermissions', PersonalAccessToken::class)) { $this->dispatch('error', 'You do not have permission to use read:sensitive permissions.'); $this->permissions = array_diff($this->permissions, ['read:sensitive']); @@ -100,20 +98,22 @@ public function addNewToken() try { $this->authorize('create', PersonalAccessToken::class); - // Validate permissions based on user role - if (in_array('root', $this->permissions) && ! $this->canUseRootPermissions) { + // 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) && ! 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) && ! $this->canUseWritePermissions) { + if (array_intersect(['write', 'write:sensitive'], $this->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) && ! $this->canUseDeployPermissions) { + if (in_array('deploy', $this->permissions) && ! 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) && ! $this->canUseSensitivePermissions) { + if (in_array('read:sensitive', $this->permissions) && ! auth()->user()->can('useSensitivePermissions', PersonalAccessToken::class)) { throw new \Exception('You do not have permission to create tokens with read:sensitive permissions.'); } diff --git a/tests/Feature/Authorization/ApiAuthorizationTest.php b/tests/Feature/Authorization/ApiAuthorizationTest.php index 59bfd9659..66a6900a3 100644 --- a/tests/Feature/Authorization/ApiAuthorizationTest.php +++ b/tests/Feature/Authorization/ApiAuthorizationTest.php @@ -123,10 +123,10 @@ // --- Member with root token (policy should deny mutations) --- -test('member with root token can view project', function () { +test('member with root token is blocked by middleware', function () { $this->withToken($this->memberRootToken->plainTextToken) ->getJson("/api/v1/projects/{$this->project->uuid}") - ->assertSuccessful(); + ->assertStatus(403); }); test('member with root token cannot delete project', function () { diff --git a/tests/Feature/Authorization/ApiTokenPermissionTest.php b/tests/Feature/Authorization/ApiTokenPermissionTest.php index 44efb7e06..b10afb58e 100644 --- a/tests/Feature/Authorization/ApiTokenPermissionTest.php +++ b/tests/Feature/Authorization/ApiTokenPermissionTest.php @@ -1,5 +1,6 @@ 0], ['is_api_enabled' => true]); + $this->team = Team::factory()->create(); $this->user = User::factory()->create(); $this->team->members()->attach($this->user->id, ['role' => 'owner']); diff --git a/tests/Feature/Security/ApiTokenCreationSecurityTest.php b/tests/Feature/Security/ApiTokenCreationSecurityTest.php new file mode 100644 index 000000000..0e5d363de --- /dev/null +++ b/tests/Feature/Security/ApiTokenCreationSecurityTest.php @@ -0,0 +1,166 @@ + 0], ['is_api_enabled' => true]); + + $this->team = Team::factory()->create(); + + $this->owner = User::factory()->create(); + $this->owner->teams()->attach($this->team, ['role' => 'owner']); + + $this->member = User::factory()->create(); + $this->member->teams()->attach($this->team, ['role' => 'member']); +}); + +describe('Livewire ApiTokens — member cannot create elevated tokens', function () { + test('member cannot create token with root permissions', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(ApiTokens::class) + ->set('description', 'my-root-token') + ->set('permissions', ['root']) + ->call('addNewToken') + ->assertDispatched('error'); + + expect($this->member->tokens()->count())->toBe(0); + }); + + test('member cannot create token with write permissions', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(ApiTokens::class) + ->set('description', 'my-write-token') + ->set('permissions', ['write']) + ->call('addNewToken') + ->assertDispatched('error'); + + expect($this->member->tokens()->count())->toBe(0); + }); + + test('member cannot create token with deploy permissions', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(ApiTokens::class) + ->set('description', 'my-deploy-token') + ->set('permissions', ['deploy']) + ->call('addNewToken') + ->assertDispatched('error'); + + expect($this->member->tokens()->count())->toBe(0); + }); + + test('member cannot create token with read:sensitive permissions', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(ApiTokens::class) + ->set('description', 'my-sensitive-token') + ->set('permissions', ['read', 'read:sensitive']) + ->call('addNewToken') + ->assertDispatched('error'); + + expect($this->member->tokens()->count())->toBe(0); + }); + + test('member cannot bypass by setting canUseRootPermissions property', 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($this->member->tokens()->count())->toBe(0); + }); + + test('member can create token with read permissions', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(ApiTokens::class) + ->set('description', 'my-read-token') + ->set('permissions', ['read']) + ->call('addNewToken') + ->assertNotDispatched('error'); + + expect($this->member->tokens()->count())->toBe(1); + expect($this->member->tokens()->first()->abilities)->toBe(['read']); + }); + + test('owner can create token with root permissions', function () { + $this->actingAs($this->owner); + session(['currentTeam' => $this->team]); + + Livewire::test(ApiTokens::class) + ->set('description', 'my-root-token') + ->set('permissions', ['root']) + ->call('addNewToken') + ->assertNotDispatched('error'); + + expect($this->owner->tokens()->count())->toBe(1); + expect($this->owner->tokens()->first()->abilities)->toBe(['root']); + }); +}); + +describe('ApiAbility middleware — member with elevated token blocked', function () { + 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]); + $member = User::factory()->create(); + $rootTeam->members()->attach($member->id, ['role' => 'member']); + + session(['currentTeam' => $rootTeam]); + $token = $member->createToken('root-token', ['root']); + + $this->withToken($token->plainTextToken) + ->getJson('/api/v1/projects') + ->assertStatus(403); + }); + + test('admin root token passes on team_id=0 (root team)', function () { + $rootTeam = Team::factory()->create(['id' => 0]); + $admin = User::factory()->create(); + $rootTeam->members()->attach($admin->id, ['role' => 'admin']); + + session(['currentTeam' => $rootTeam]); + $token = $admin->createToken('root-token', ['root']); + + $this->withToken($token->plainTextToken) + ->getJson('/api/v1/projects') + ->assertSuccessful(); + }); + + test('member root token is blocked on non-zero team', function () { + session(['currentTeam' => $this->team]); + $token = $this->member->createToken('root-token', ['root']); + + $this->withToken($token->plainTextToken) + ->getJson('/api/v1/projects') + ->assertStatus(403); + }); + + test('member read token passes on non-zero team', function () { + session(['currentTeam' => $this->team]); + $token = $this->member->createToken('read-token', ['read']); + + $this->withToken($token->plainTextToken) + ->getJson('/api/v1/projects') + ->assertSuccessful(); + }); +});