mirror of
https://github.com/coollabsio/coolify.git
synced 2026-08-24 10:05:47 -05:00
fix(api-tokens): scope token access to current team (#11396)
This commit is contained in:
@@ -41,6 +41,10 @@ class ApiTokenExpirationWarningJob implements ShouldBeEncrypted, ShouldQueue, Si
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! $team->members()->whereKey($token->tokenable_id)->exists()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$warningSentAt = now();
|
$warningSentAt = now();
|
||||||
|
|
||||||
$team->notify(new ApiTokenExpiringNotification($token));
|
$team->notify(new ApiTokenExpiringNotification($token));
|
||||||
|
|||||||
@@ -59,7 +59,10 @@ class ApiTokens extends Component
|
|||||||
|
|
||||||
private function getTokens()
|
private function getTokens()
|
||||||
{
|
{
|
||||||
$this->tokens = auth()->user()->tokens->sortByDesc('created_at');
|
$this->tokens = auth()->user()->tokens()
|
||||||
|
->where('team_id', currentTeam()->id)
|
||||||
|
->latest()
|
||||||
|
->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updatedPermissions($permissionToUpdate)
|
public function updatedPermissions($permissionToUpdate)
|
||||||
@@ -148,7 +151,10 @@ class ApiTokens extends Component
|
|||||||
public function revoke(int $id)
|
public function revoke(int $id)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$token = auth()->user()->tokens()->where('id', $id)->firstOrFail();
|
$token = auth()->user()->tokens()
|
||||||
|
->where('team_id', currentTeam()->id)
|
||||||
|
->where('id', $id)
|
||||||
|
->firstOrFail();
|
||||||
$this->authorize('delete', $token);
|
$this->authorize('delete', $token);
|
||||||
$token->delete();
|
$token->delete();
|
||||||
$this->getTokens();
|
$this->getTokens();
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class ApiTokenPolicy
|
|||||||
*/
|
*/
|
||||||
public function view(User $user, PersonalAccessToken $token): bool
|
public function view(User $user, PersonalAccessToken $token): bool
|
||||||
{
|
{
|
||||||
return $user->id === $token->tokenable_id && $token->tokenable_type === User::class;
|
return $this->belongsToUserAndCurrentTeam($user, $token);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,7 +36,7 @@ class ApiTokenPolicy
|
|||||||
*/
|
*/
|
||||||
public function update(User $user, PersonalAccessToken $token): bool
|
public function update(User $user, PersonalAccessToken $token): bool
|
||||||
{
|
{
|
||||||
return $user->id === $token->tokenable_id && $token->tokenable_type === User::class;
|
return $this->belongsToUserAndCurrentTeam($user, $token);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,7 +44,7 @@ class ApiTokenPolicy
|
|||||||
*/
|
*/
|
||||||
public function delete(User $user, PersonalAccessToken $token): bool
|
public function delete(User $user, PersonalAccessToken $token): bool
|
||||||
{
|
{
|
||||||
return $user->id === $token->tokenable_id && $token->tokenable_type === User::class;
|
return $this->belongsToUserAndCurrentTeam($user, $token);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -86,4 +86,19 @@ class ApiTokenPolicy
|
|||||||
{
|
{
|
||||||
return $user->isAdmin() || $user->isOwner();
|
return $user->isAdmin() || $user->isOwner();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function belongsToUserAndCurrentTeam(User $user, PersonalAccessToken $token): bool
|
||||||
|
{
|
||||||
|
if ($user->id !== $token->tokenable_id || $token->tokenable_type !== User::class) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentTeamId = $user->currentTeam()?->id;
|
||||||
|
|
||||||
|
if ($currentTeamId === null || $token->team_id === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) $currentTeamId === (string) $token->team_id;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ function createTokenExpiring(User $user, Team $team, ?Carbon $expiresAt, ?Carbon
|
|||||||
|
|
||||||
describe('ApiTokenExpirationWarningJob', function () {
|
describe('ApiTokenExpirationWarningJob', function () {
|
||||||
test('notifies team when token expires within 24h', function () {
|
test('notifies team when token expires within 24h', function () {
|
||||||
$token = createTokenExpiring($this->user, $this->team, now()->addHours(23));
|
$token = createTokenExpiring($this->user, $this->team, Carbon::now()->addHours(23));
|
||||||
|
|
||||||
(new ApiTokenExpirationWarningJob)->handle();
|
(new ApiTokenExpirationWarningJob)->handle();
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ describe('ApiTokenExpirationWarningJob', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('does not mark token as warned when notification fails', function () {
|
test('does not mark token as warned when notification fails', function () {
|
||||||
$token = createTokenExpiring($this->user, $this->team, now()->addHours(23));
|
$token = createTokenExpiring($this->user, $this->team, Carbon::now()->addHours(23));
|
||||||
$dispatcher = Mockery::mock(Dispatcher::class);
|
$dispatcher = Mockery::mock(Dispatcher::class);
|
||||||
$dispatcher->shouldReceive('send')
|
$dispatcher->shouldReceive('send')
|
||||||
->once()
|
->once()
|
||||||
@@ -67,7 +67,7 @@ describe('ApiTokenExpirationWarningJob', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('database marker prevents duplicate warnings on repeat runs', function () {
|
test('database marker prevents duplicate warnings on repeat runs', function () {
|
||||||
createTokenExpiring($this->user, $this->team, now()->addHours(12));
|
createTokenExpiring($this->user, $this->team, Carbon::now()->addHours(12));
|
||||||
|
|
||||||
(new ApiTokenExpirationWarningJob)->handle();
|
(new ApiTokenExpirationWarningJob)->handle();
|
||||||
(new ApiTokenExpirationWarningJob)->handle();
|
(new ApiTokenExpirationWarningJob)->handle();
|
||||||
@@ -76,7 +76,7 @@ describe('ApiTokenExpirationWarningJob', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('database marker prevents duplicate warnings after cache is flushed', function () {
|
test('database marker prevents duplicate warnings after cache is flushed', function () {
|
||||||
createTokenExpiring($this->user, $this->team, now()->addHours(12));
|
createTokenExpiring($this->user, $this->team, Carbon::now()->addHours(12));
|
||||||
|
|
||||||
(new ApiTokenExpirationWarningJob)->handle();
|
(new ApiTokenExpirationWarningJob)->handle();
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ describe('ApiTokenExpirationWarningJob', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('skips tokens that already have an expiration warning marker', function () {
|
test('skips tokens that already have an expiration warning marker', function () {
|
||||||
createTokenExpiring($this->user, $this->team, now()->addHours(12), now()->subHour());
|
createTokenExpiring($this->user, $this->team, Carbon::now()->addHours(12), Carbon::now()->subHour());
|
||||||
|
|
||||||
(new ApiTokenExpirationWarningJob)->handle();
|
(new ApiTokenExpirationWarningJob)->handle();
|
||||||
|
|
||||||
@@ -96,8 +96,8 @@ describe('ApiTokenExpirationWarningJob', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('notifies once for each unmarked expiring token', function () {
|
test('notifies once for each unmarked expiring token', function () {
|
||||||
createTokenExpiring($this->user, $this->team, now()->addHours(12));
|
createTokenExpiring($this->user, $this->team, Carbon::now()->addHours(12));
|
||||||
createTokenExpiring($this->user, $this->team, now()->addHours(23));
|
createTokenExpiring($this->user, $this->team, Carbon::now()->addHours(23));
|
||||||
|
|
||||||
(new ApiTokenExpirationWarningJob)->handle();
|
(new ApiTokenExpirationWarningJob)->handle();
|
||||||
|
|
||||||
@@ -105,7 +105,7 @@ describe('ApiTokenExpirationWarningJob', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('skips tokens expiring more than 24h out', function () {
|
test('skips tokens expiring more than 24h out', function () {
|
||||||
createTokenExpiring($this->user, $this->team, now()->addDays(3));
|
createTokenExpiring($this->user, $this->team, Carbon::now()->addDays(3));
|
||||||
|
|
||||||
(new ApiTokenExpirationWarningJob)->handle();
|
(new ApiTokenExpirationWarningJob)->handle();
|
||||||
|
|
||||||
@@ -113,7 +113,7 @@ describe('ApiTokenExpirationWarningJob', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('skips already-expired tokens', function () {
|
test('skips already-expired tokens', function () {
|
||||||
createTokenExpiring($this->user, $this->team, now()->subHour());
|
createTokenExpiring($this->user, $this->team, Carbon::now()->subHour());
|
||||||
|
|
||||||
(new ApiTokenExpirationWarningJob)->handle();
|
(new ApiTokenExpirationWarningJob)->handle();
|
||||||
|
|
||||||
@@ -127,4 +127,14 @@ describe('ApiTokenExpirationWarningJob', function () {
|
|||||||
|
|
||||||
Notification::assertNothingSent();
|
Notification::assertNothingSent();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('skips tokens whose owner is no longer a team member', function () {
|
||||||
|
$token = createTokenExpiring($this->user, $this->team, Carbon::now()->addHours(12));
|
||||||
|
$this->team->members()->detach($this->user);
|
||||||
|
|
||||||
|
(new ApiTokenExpirationWarningJob)->handle();
|
||||||
|
|
||||||
|
Notification::assertNothingSent();
|
||||||
|
expect($token->fresh()->api_token_expiration_warning_sent_at)->toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -76,6 +76,42 @@ test('member can still create read token', function () {
|
|||||||
->and($token->abilities)->toBe(['read']);
|
->and($token->abilities)->toBe(['read']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('api token list only contains tokens for the current team', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$otherTeam = Team::factory()->create();
|
||||||
|
$this->team->members()->attach($user->id, ['role' => 'admin']);
|
||||||
|
$otherTeam->members()->attach($user->id, ['role' => 'admin']);
|
||||||
|
|
||||||
|
session(['currentTeam' => $this->team]);
|
||||||
|
$currentTeamToken = $user->createToken('current-team-token', ['read'])->accessToken;
|
||||||
|
|
||||||
|
session(['currentTeam' => $otherTeam]);
|
||||||
|
$user->createToken('other-team-token', ['read']);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
session(['currentTeam' => $this->team]);
|
||||||
|
|
||||||
|
Livewire::test(ApiTokens::class)
|
||||||
|
->assertSet('tokens', fn ($tokens) => $tokens->pluck('id')->all() === [$currentTeamToken->id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('user cannot revoke a token from another team through the current team', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$otherTeam = Team::factory()->create();
|
||||||
|
$this->team->members()->attach($user->id, ['role' => 'admin']);
|
||||||
|
$otherTeam->members()->attach($user->id, ['role' => 'admin']);
|
||||||
|
|
||||||
|
session(['currentTeam' => $otherTeam]);
|
||||||
|
$otherTeamToken = $user->createToken('other-team-token', ['read'])->accessToken;
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
session(['currentTeam' => $this->team]);
|
||||||
|
|
||||||
|
Livewire::test(ApiTokens::class)->call('revoke', $otherTeamToken->id);
|
||||||
|
|
||||||
|
expect($user->tokens()->whereKey($otherTeamToken->id)->exists())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
test('owner can create root token', function () {
|
test('owner can create root token', function () {
|
||||||
$owner = User::factory()->create();
|
$owner = User::factory()->create();
|
||||||
$this->team->members()->attach($owner->id, ['role' => 'owner']);
|
$this->team->members()->attach($owner->id, ['role' => 'owner']);
|
||||||
|
|||||||
@@ -1,9 +1,28 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Team;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Policies\ApiTokenPolicy;
|
use App\Policies\ApiTokenPolicy;
|
||||||
use Laravel\Sanctum\PersonalAccessToken;
|
use Laravel\Sanctum\PersonalAccessToken;
|
||||||
|
|
||||||
|
function apiTokenForUserAndTeam(int $userId, int $teamId): PersonalAccessToken
|
||||||
|
{
|
||||||
|
$token = Mockery::mock(PersonalAccessToken::class)->makePartial();
|
||||||
|
$token->tokenable_id = $userId;
|
||||||
|
$token->tokenable_type = User::class;
|
||||||
|
$token->team_id = $teamId;
|
||||||
|
|
||||||
|
return $token;
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiTokenTeam(int $teamId): Team
|
||||||
|
{
|
||||||
|
$team = new Team;
|
||||||
|
$team->id = $teamId;
|
||||||
|
|
||||||
|
return $team;
|
||||||
|
}
|
||||||
|
|
||||||
it('allows any user to view any api tokens', function () {
|
it('allows any user to view any api tokens', function () {
|
||||||
$user = Mockery::mock(User::class)->makePartial();
|
$user = Mockery::mock(User::class)->makePartial();
|
||||||
|
|
||||||
@@ -28,10 +47,9 @@ it('allows any user to manage api tokens', function () {
|
|||||||
it('allows owner to view their own api token', function () {
|
it('allows owner to view their own api token', function () {
|
||||||
$user = Mockery::mock(User::class)->makePartial();
|
$user = Mockery::mock(User::class)->makePartial();
|
||||||
$user->id = 1;
|
$user->id = 1;
|
||||||
|
$user->shouldReceive('currentTeam')->andReturn(apiTokenTeam(10));
|
||||||
|
|
||||||
$token = Mockery::mock(PersonalAccessToken::class)->makePartial();
|
$token = apiTokenForUserAndTeam(1, 10);
|
||||||
$token->tokenable_id = 1;
|
|
||||||
$token->tokenable_type = User::class;
|
|
||||||
|
|
||||||
$policy = new ApiTokenPolicy;
|
$policy = new ApiTokenPolicy;
|
||||||
expect($policy->view($user, $token))->toBeTrue();
|
expect($policy->view($user, $token))->toBeTrue();
|
||||||
@@ -52,10 +70,9 @@ it('denies non-owner from viewing api token', function () {
|
|||||||
it('allows owner to update their own api token', function () {
|
it('allows owner to update their own api token', function () {
|
||||||
$user = Mockery::mock(User::class)->makePartial();
|
$user = Mockery::mock(User::class)->makePartial();
|
||||||
$user->id = 1;
|
$user->id = 1;
|
||||||
|
$user->shouldReceive('currentTeam')->andReturn(apiTokenTeam(10));
|
||||||
|
|
||||||
$token = Mockery::mock(PersonalAccessToken::class)->makePartial();
|
$token = apiTokenForUserAndTeam(1, 10);
|
||||||
$token->tokenable_id = 1;
|
|
||||||
$token->tokenable_type = User::class;
|
|
||||||
|
|
||||||
$policy = new ApiTokenPolicy;
|
$policy = new ApiTokenPolicy;
|
||||||
expect($policy->update($user, $token))->toBeTrue();
|
expect($policy->update($user, $token))->toBeTrue();
|
||||||
@@ -76,10 +93,9 @@ it('denies non-owner from updating api token', function () {
|
|||||||
it('allows owner to delete their own api token', function () {
|
it('allows owner to delete their own api token', function () {
|
||||||
$user = Mockery::mock(User::class)->makePartial();
|
$user = Mockery::mock(User::class)->makePartial();
|
||||||
$user->id = 1;
|
$user->id = 1;
|
||||||
|
$user->shouldReceive('currentTeam')->andReturn(apiTokenTeam(10));
|
||||||
|
|
||||||
$token = Mockery::mock(PersonalAccessToken::class)->makePartial();
|
$token = apiTokenForUserAndTeam(1, 10);
|
||||||
$token->tokenable_id = 1;
|
|
||||||
$token->tokenable_type = User::class;
|
|
||||||
|
|
||||||
$policy = new ApiTokenPolicy;
|
$policy = new ApiTokenPolicy;
|
||||||
expect($policy->delete($user, $token))->toBeTrue();
|
expect($policy->delete($user, $token))->toBeTrue();
|
||||||
@@ -97,6 +113,31 @@ it('denies non-owner from deleting api token', function () {
|
|||||||
expect($policy->delete($user, $token))->toBeFalse();
|
expect($policy->delete($user, $token))->toBeFalse();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('denies access to an owned api token from another team', function (string $ability) {
|
||||||
|
$user = Mockery::mock(User::class)->makePartial();
|
||||||
|
$user->id = 1;
|
||||||
|
$user->shouldReceive('currentTeam')->andReturn(apiTokenTeam(10));
|
||||||
|
|
||||||
|
$token = apiTokenForUserAndTeam(1, 20);
|
||||||
|
|
||||||
|
$policy = new ApiTokenPolicy;
|
||||||
|
expect($policy->{$ability}($user, $token))->toBeFalse();
|
||||||
|
})->with(['view', 'update', 'delete']);
|
||||||
|
|
||||||
|
it('denies access to an owned api token without team identifiers', function (string $ability) {
|
||||||
|
$user = Mockery::mock(User::class)->makePartial();
|
||||||
|
$user->id = 1;
|
||||||
|
$user->shouldReceive('currentTeam')->andReturnNull();
|
||||||
|
|
||||||
|
$token = Mockery::mock(PersonalAccessToken::class)->makePartial();
|
||||||
|
$token->tokenable_id = 1;
|
||||||
|
$token->tokenable_type = User::class;
|
||||||
|
$token->team_id = null;
|
||||||
|
|
||||||
|
$policy = new ApiTokenPolicy;
|
||||||
|
expect($policy->{$ability}($user, $token))->toBeFalse();
|
||||||
|
})->with(['view', 'update', 'delete']);
|
||||||
|
|
||||||
it('allows admin to use root permissions', function () {
|
it('allows admin to use root permissions', function () {
|
||||||
$user = Mockery::mock(User::class)->makePartial();
|
$user = Mockery::mock(User::class)->makePartial();
|
||||||
$user->shouldReceive('isAdmin')->andReturn(true);
|
$user->shouldReceive('isAdmin')->andReturn(true);
|
||||||
|
|||||||
Reference in New Issue
Block a user