mirror of
https://github.com/coollabsio/coolify.git
synced 2026-09-26 01:10:30 -04:00
fix(api): align token permission handling
This commit is contained in:
@@ -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
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user