Files
coolify/tests/Feature/Access/ScopedApiTokenTest.php

482 lines
23 KiB
PHP

<?php
use App\Jobs\ApplicationDeploymentJob;
use App\Livewire\Forms\ApiTokenForm;
use App\Livewire\Security\ApiTokens;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\PersonalAccessToken;
use App\Models\Project;
use App\Models\Scopes\VisibleThroughAccessGrants;
use App\Models\Server;
use App\Models\SharedEnvironmentVariable;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Once;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
Server::flushIdentityMap();
Queue::fake();
config()->set('app.maintenance.store', 'array');
config()->set('cache.default', 'array');
InstanceSettings::query()->delete();
$settings = new InstanceSettings(['is_api_enabled' => true, 'is_mcp_server_enabled' => true]);
$settings->id = 0;
$settings->save();
Once::flush();
$this->team = Team::factory()->create(['is_mcp_server_enabled' => true]);
$this->admin = User::factory()->create();
$this->team->members()->attach($this->admin->id, ['role' => 'admin']);
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = $this->server->standaloneDockers()->firstOrFail();
$this->shop = Project::factory()->create(['team_id' => $this->team->id, 'name' => 'shop']);
$this->shopProduction = Environment::factory()->create(['project_id' => $this->shop->id, 'name' => 'prod']);
$this->blog = Project::factory()->create(['team_id' => $this->team->id, 'name' => 'blog']);
$this->blogStaging = Environment::factory()->create(['project_id' => $this->blog->id, 'name' => 'staging']);
$this->shopApi = scopedTokenTestApplication($this->shopProduction, $this->destination, 'shop-api');
$this->shopWorker = scopedTokenTestApplication($this->shopProduction, $this->destination, 'shop-worker');
$this->blogWeb = scopedTokenTestApplication($this->blogStaging, $this->destination, 'blog-web');
});
afterEach(function () {
Server::flushIdentityMap();
});
function scopedTokenTestApplication(Environment $environment, $destination, string $name): Application
{
return Application::factory()->create([
'name' => $name,
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
}
/**
* @param list<array{scope: string, uuid: string}>|null $scopes
*/
function scopedTokenTestToken(User $user, Team $team, array $abilities, ?array $scopes): string
{
$token = $user->createToken('scoped-test', $abilities, null, $team->id);
$token->accessToken->forceFill(['scopes' => $scopes])->save();
return $token->plainTextToken;
}
/**
* Fill the edit form the way the browser does when the user clicks Edit.
*/
function scopedTokenTestEdit($component, PersonalAccessToken $token)
{
$values = collect(ApiTokenForm::valuesOf($token))->except(['id', 'keepLabel']);
return $component->set($values->mapWithKeys(fn ($value, $field) => ["editForm.{$field}" => $value])->all());
}
function scopedTokenTestMember(Team $team, ?array $access): User
{
$member = User::factory()->create();
$team->members()->attach($member->id, ['role' => 'member', 'access' => $access === null ? null : json_encode($access)]);
return $member;
}
function scopedTokenTestApi(string $token, string $method, string $uri, array $data = [])
{
app('auth')->forgetGuards();
return test()->withHeaders(['Authorization' => 'Bearer '.$token])->json($method, $uri, $data);
}
function scopedTokenTestMcp(string $token, string $method, array $params = [])
{
app('auth')->forgetGuards();
return test()->withHeaders([
'Authorization' => 'Bearer '.$token,
'Accept' => 'application/json, text/event-stream',
])->postJson('/mcp', ['jsonrpc' => '2.0', 'id' => 1, 'method' => $method, 'params' => (object) $params]);
}
describe('admin token scoped to one project', function () {
beforeEach(function () {
$this->token = scopedTokenTestToken($this->admin, $this->team, ['read', 'write'], [['scope' => 'project', 'uuid' => $this->shop->uuid]]);
});
test('lists only the applications and resources of the project', function () {
expect(scopedTokenTestApi($this->token, 'GET', '/api/v1/applications')->assertOk()->json('*.name'))
->toEqualCanonicalizing(['shop-api', 'shop-worker'])
->and(scopedTokenTestApi($this->token, 'GET', '/api/v1/resources')->assertOk()->json('*.name'))
->toEqualCanonicalizing(['shop-api', 'shop-worker'])
->and(scopedTokenTestApi($this->token, 'GET', '/api/v1/projects')->assertOk()->json('*.name'))
->toBe(['shop']);
});
test('does not find an application in another project', function () {
scopedTokenTestApi($this->token, 'GET', "/api/v1/applications/{$this->blogWeb->uuid}")->assertNotFound();
scopedTokenTestApi($this->token, 'GET', "/api/v1/projects/{$this->blog->uuid}")->assertNotFound();
});
test('cannot use team-level endpoints or create projects', function () {
scopedTokenTestApi($this->token, 'GET', '/api/v1/servers')
->assertForbidden()
->assertExactJson(['message' => 'This API token is limited to selected projects, environments, or resources, so it cannot use this endpoint.']);
scopedTokenTestApi($this->token, 'GET', '/api/v1/security/keys')->assertForbidden();
scopedTokenTestApi($this->token, 'GET', '/api/v1/team')->assertForbidden();
scopedTokenTestApi($this->token, 'POST', '/api/v1/projects', ['name' => 'new'])->assertForbidden();
expect(Project::query()->where('name', 'new')->exists())->toBeFalse();
});
test('lists only open deployments of the project', function () {
foreach ([$this->shopApi, $this->blogWeb] as $application) {
ApplicationDeploymentQueue::create([
'application_id' => (string) $application->id,
'deployment_uuid' => 'deploy-'.$application->name,
'server_id' => $this->server->id,
'status' => 'queued',
]);
}
expect(scopedTokenTestApi($this->token, 'GET', '/api/v1/deployments')->assertOk()->json('*.deployment_uuid'))
->toBe(['deploy-shop-api']);
scopedTokenTestApi($this->token, 'GET', '/api/v1/deployments/deploy-blog-web')->assertNotFound();
});
test('can create an application in the project', function () {
scopedTokenTestApi($this->token, 'POST', '/api/v1/applications/dockerimage', [
'project_uuid' => $this->shop->uuid,
'environment_uuid' => $this->shopProduction->uuid,
'server_uuid' => $this->server->uuid,
'docker_registry_image_name' => 'nginx',
'ports_exposes' => '80',
])->assertCreated();
expect($this->shopProduction->applications()->count())->toBe(3);
});
});
describe('token scoped to one resource', function () {
test('deploys the resource with the deploy ability', function () {
$token = scopedTokenTestToken($this->admin, $this->team, ['deploy'], [['scope' => 'resource', 'uuid' => $this->shopApi->uuid]]);
scopedTokenTestApi($token, 'POST', "/api/v1/deploy?uuid={$this->shopApi->uuid}")
->assertOk()
->assertJsonPath('deployments.0.resource_uuid', $this->shopApi->uuid);
scopedTokenTestApi($token, 'POST', "/api/v1/deploy?uuid={$this->shopWorker->uuid}")->assertNotFound();
expect(ApplicationDeploymentQueue::query()->pluck('application_id')->all())->toBe([(string) $this->shopApi->id]);
Queue::assertPushed(ApplicationDeploymentJob::class);
});
test('cannot create an application in the environment of the resource', function () {
$token = scopedTokenTestToken($this->admin, $this->team, ['read', 'write'], [['scope' => 'resource', 'uuid' => $this->shopApi->uuid]]);
scopedTokenTestApi($token, 'POST', '/api/v1/applications/dockerimage', [
'project_uuid' => $this->shop->uuid,
'environment_uuid' => $this->shopProduction->uuid,
'server_uuid' => $this->server->uuid,
'docker_registry_image_name' => 'nginx',
'ports_exposes' => '80',
])->assertForbidden();
expect(Application::withoutGlobalScope(VisibleThroughAccessGrants::class)->where('environment_id', $this->shopProduction->id)->count())->toBe(2);
});
});
describe('delete ability', function () {
test('a write token without delete cannot delete an application', function () {
$token = scopedTokenTestToken($this->admin, $this->team, ['read', 'write'], null);
scopedTokenTestApi($token, 'DELETE', "/api/v1/applications/{$this->shopApi->uuid}")
->assertForbidden()
->assertExactJson(['message' => 'Missing required permissions: delete']);
expect(Application::query()->whereKey($this->shopApi->id)->exists())->toBeTrue();
});
test('a token with delete can delete an application', function () {
$token = scopedTokenTestToken($this->admin, $this->team, ['read', 'write', 'delete'], null);
scopedTokenTestApi($token, 'DELETE', "/api/v1/applications/{$this->shopApi->uuid}")->assertOk();
expect(Application::query()->whereKey($this->shopApi->id)->exists())->toBeFalse();
});
});
describe('member tokens', function () {
test('a legacy member can use a read token but not a write token', function () {
$member = scopedTokenTestMember($this->team, null);
expect(scopedTokenTestApi(scopedTokenTestToken($member, $this->team, ['read'], null), 'GET', '/api/v1/applications')->assertOk()->json('*.name'))
->toEqualCanonicalizing(['shop-api', 'shop-worker', 'blog-web']);
scopedTokenTestApi(scopedTokenTestToken($member, $this->team, ['read', 'write'], null), 'GET', '/api/v1/applications')
->assertForbidden()
->assertExactJson(['message' => 'This API token has more access than you have in this team. Revoke it and create a new token.']);
});
test('a member with a project grant can use a write token limited to that project', function () {
$member = scopedTokenTestMember($this->team, [['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['write', 'deploy']]]);
$token = scopedTokenTestToken($member, $this->team, ['read', 'write'], [['scope' => 'project', 'uuid' => $this->shop->uuid]]);
scopedTokenTestApi($token, 'PATCH', "/api/v1/applications/{$this->shopApi->uuid}", ['name' => 'renamed'])->assertOk();
scopedTokenTestApi($token, 'PATCH', "/api/v1/applications/{$this->blogWeb->uuid}", ['name' => 'renamed'])->assertNotFound();
expect($this->shopApi->fresh()->name)->toBe('renamed')
->and($this->blogWeb->fresh()->name)->toBe('blog-web');
});
test('a member with a project grant cannot use a team-wide write token', function () {
$member = scopedTokenTestMember($this->team, [['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['write', 'deploy']]]);
scopedTokenTestApi(scopedTokenTestToken($member, $this->team, ['read', 'write'], null), 'GET', '/api/v1/applications')->assertForbidden();
scopedTokenTestApi(scopedTokenTestToken($member, $this->team, ['delete'], [['scope' => 'project', 'uuid' => $this->shop->uuid]]), 'GET', '/api/v1/applications')->assertForbidden();
});
test('a member with a write grant can create project and environment shared variables', function () {
$member = scopedTokenTestMember($this->team, [['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['write']]]);
$token = scopedTokenTestToken($member, $this->team, ['read', 'write'], [['scope' => 'project', 'uuid' => $this->shop->uuid]]);
scopedTokenTestApi($token, 'POST', "/api/v1/projects/{$this->shop->uuid}/envs", ['key' => 'PROJECT_KEY', 'value' => 'v'])->assertCreated();
scopedTokenTestApi($token, 'POST', "/api/v1/projects/{$this->shop->uuid}/environments/{$this->shopProduction->uuid}/envs", ['key' => 'ENVIRONMENT_KEY', 'value' => 'v'])->assertCreated();
scopedTokenTestApi($token, 'POST', "/api/v1/projects/{$this->blog->uuid}/envs", ['key' => 'OTHER_KEY', 'value' => 'v'])->assertNotFound();
expect(SharedEnvironmentVariable::query()->whereIn('key', ['PROJECT_KEY', 'ENVIRONMENT_KEY', 'OTHER_KEY'])->pluck('key')->all())->toEqualCanonicalizing(['PROJECT_KEY', 'ENVIRONMENT_KEY']);
});
test('a member with a read grant cannot create project or environment shared variables', function () {
$member = scopedTokenTestMember($this->team, [['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['read']]]);
$token = scopedTokenTestToken($member, $this->team, ['read'], [['scope' => 'project', 'uuid' => $this->shop->uuid]]);
$writeToken = scopedTokenTestToken($member, $this->team, ['read', 'write'], [['scope' => 'project', 'uuid' => $this->shop->uuid]]);
foreach ([$token, $writeToken] as $candidate) {
scopedTokenTestApi($candidate, 'POST', "/api/v1/projects/{$this->shop->uuid}/envs", ['key' => 'PROJECT_KEY', 'value' => 'v'])->assertForbidden();
scopedTokenTestApi($candidate, 'POST', "/api/v1/projects/{$this->shop->uuid}/environments/{$this->shopProduction->uuid}/envs", ['key' => 'ENVIRONMENT_KEY', 'value' => 'v'])->assertForbidden();
}
expect(SharedEnvironmentVariable::query()->whereIn('key', ['PROJECT_KEY', 'ENVIRONMENT_KEY'])->exists())->toBeFalse();
});
});
describe('mcp', function () {
test('a scoped token lists only scoped applications and does not get team-level tools', function () {
$token = scopedTokenTestToken($this->admin, $this->team, ['read'], [['scope' => 'project', 'uuid' => $this->shop->uuid]]);
$listed = json_decode((string) scopedTokenTestMcp($token, 'tools/call', ['name' => 'list_applications', 'arguments' => (object) []])->json('result.content.0.text'), true);
$tools = scopedTokenTestMcp($token, 'tools/list')->json('result.tools.*.name');
expect(collect($listed['data'])->pluck('name')->all())->toEqualCanonicalizing(['shop-api', 'shop-worker'])
->and($tools)->toContain('list_applications')
->and($tools)->not->toContain('list_servers')
->and($tools)->not->toContain('manage_private_key');
expect(scopedTokenTestMcp($token, 'tools/call', ['name' => 'list_servers', 'arguments' => (object) []])->json('error'))->not->toBeNull();
});
test('a team-wide token still gets team-level tools', function () {
$tools = scopedTokenTestMcp(scopedTokenTestToken($this->admin, $this->team, ['read'], null), 'tools/list')->json('result.tools.*.name');
expect($tools)->toContain('list_servers');
});
});
describe('token form', function () {
beforeEach(function () {
session(['currentTeam' => $this->team]);
});
test('creates a token limited to the selected project', function () {
$this->actingAs($this->admin);
Livewire::test(ApiTokens::class)
->set('form.description', 'shop deploys')
->set('form.permissions', ['deploy'])
->set('form.scopeMode', 'selected')
->set('form.selectedScopes', ["project:{$this->shop->uuid}"])
->call('addNewToken')
->assertHasNoErrors()
->assertNotDispatched('error');
$token = PersonalAccessToken::query()->sole();
expect($token->abilities)->toBe(['deploy'])
->and($token->scopes)->toBe([['scope' => 'project', 'uuid' => $this->shop->uuid]])
->and((int) $token->team_id)->toBe($this->team->id);
});
test('rejects a scope from another team', function () {
$otherTeam = Team::factory()->create();
$otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
$this->actingAs($this->admin);
Livewire::test(ApiTokens::class)
->set('form.description', 'foreign')
->set('form.permissions', ['read'])
->set('form.scopeMode', 'selected')
->set('form.selectedScopes', ["project:{$otherProject->uuid}"])
->call('addNewToken')
->assertDispatched('error');
expect(PersonalAccessToken::query()->count())->toBe(0);
});
test('a member can create a token only within their grants', function () {
$member = scopedTokenTestMember($this->team, [['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['write']]]);
$this->actingAs($member);
$form = Livewire::test(ApiTokens::class)->assertSet('form.scopeMode', 'selected');
expect(collect($form->instance()->scopeOptions())->pluck('value')->filter(fn ($value) => str_starts_with($value, 'project:'))->values()->all())
->toBe(["project:{$this->shop->uuid}"]);
$form->set('form.description', 'too wide')
->set('form.permissions', ['write'])
->set('form.scopeMode', 'team')
->call('addNewToken')
->assertDispatched('error');
$form->set('form.description', 'blog')
->set('form.scopeMode', 'selected')
->set('form.selectedScopes', ["project:{$this->blog->uuid}"])
->call('addNewToken')
->assertDispatched('error');
$form->set('form.description', 'shop')
->set('form.selectedScopes', ["project:{$this->shop->uuid}"])
->call('addNewToken');
expect($member->tokens()->pluck('name')->all())->toBe(['shop']);
});
test('edits a team-wide token to one project, and the same token value then sees only that project', function () {
$plainToken = scopedTokenTestToken($this->admin, $this->team, ['read'], null);
$token = PersonalAccessToken::query()->sole();
$this->actingAs($this->admin);
expect(ApiTokenForm::valuesOf($token))->toMatchArray([
'description' => 'scoped-test',
'permissions' => ['read'],
'scopeMode' => 'team',
'expiresInDays' => 'keep',
]);
scopedTokenTestEdit(Livewire::test(ApiTokens::class), $token)
->set('editForm.scopeMode', 'selected')
->set('editForm.selectedScopes', ["project:{$this->shop->uuid}"])
->call('updateToken', $token->id)
->assertHasNoErrors()
->assertNotDispatched('error')
->assertDispatched('api-token-updated');
expect($token->fresh()->scopes)->toBe([['scope' => 'project', 'uuid' => $this->shop->uuid]])
->and(scopedTokenTestApi($plainToken, 'GET', '/api/v1/projects')->assertOk()->json('*.name'))->toBe(['shop']);
});
test('edits permissions and keeps the expiry, then sets a new expiry', function () {
$plainToken = scopedTokenTestToken($this->admin, $this->team, ['read', 'write'], null);
$token = PersonalAccessToken::query()->sole();
$expiresAt = now()->addDays(10)->startOfSecond();
$token->forceFill(['expires_at' => $expiresAt, 'api_token_expiration_warning_sent_at' => now()])->save();
$this->actingAs($this->admin);
$form = scopedTokenTestEdit(Livewire::test(ApiTokens::class), $token)
->set('editForm.permissions', ['read'])
->call('updateToken', $token->id)
->assertHasNoErrors();
$token->refresh();
expect($token->abilities)->toBe(['read'])
->and($token->expires_at->equalTo($expiresAt))->toBeTrue()
->and($token->api_token_expiration_warning_sent_at)->not->toBeNull();
scopedTokenTestApi($plainToken, 'POST', '/api/v1/projects', ['name' => 'new'])->assertForbidden();
scopedTokenTestEdit($form, $token)
->set('editForm.expiresInDays', 90)
->call('updateToken', $token->id)
->assertHasNoErrors();
$token->refresh();
expect(round(now()->diffInDays($token->expires_at)))->toEqual(90)
->and($token->api_token_expiration_warning_sent_at)->toBeNull();
});
test('a member cannot give a token more access than their grants by editing it', function () {
$member = scopedTokenTestMember($this->team, [['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['read']]]);
scopedTokenTestToken($member, $this->team, ['read'], [['scope' => 'project', 'uuid' => $this->shop->uuid]]);
$token = PersonalAccessToken::query()->sole();
$this->actingAs($member);
scopedTokenTestEdit(Livewire::test(ApiTokens::class), $token)
->set('editForm.selectedScopes', ["project:{$this->blog->uuid}"])
->call('updateToken', $token->id)
->assertDispatched('error');
expect($token->fresh()->scopes)->toBe([['scope' => 'project', 'uuid' => $this->shop->uuid]]);
});
test('cannot edit a token of another user or of another team', function () {
$otherAdmin = User::factory()->create();
$this->team->members()->attach($otherAdmin->id, ['role' => 'admin']);
scopedTokenTestToken($otherAdmin, $this->team, ['read'], null);
$otherTeam = Team::factory()->create();
$otherTeam->members()->attach($this->admin->id, ['role' => 'admin']);
scopedTokenTestToken($this->admin, $otherTeam, ['read'], null);
$this->actingAs($this->admin);
foreach (PersonalAccessToken::query()->get() as $token) {
scopedTokenTestEdit(Livewire::test(ApiTokens::class), $token)
->set('editForm.description', 'taken over')
->call('updateToken', $token->id)
->assertStatus(404);
expect($token->fresh()->name)->toBe('scoped-test');
}
});
test('a paused token is rejected by the API and MCP until it is resumed', function () {
$plainToken = scopedTokenTestToken($this->admin, $this->team, ['read'], null);
$token = PersonalAccessToken::query()->sole();
$this->actingAs($this->admin);
Livewire::test(ApiTokens::class)
->call('setPaused', $token->id, true)
->assertDispatched('success', 'API token paused.');
expect($token->fresh()->isPaused())->toBeTrue();
// A stale page that still shows "Pause" must not resume the token.
Livewire::test(ApiTokens::class)->call('setPaused', $token->id, true);
expect($token->fresh()->isPaused())->toBeTrue();
scopedTokenTestApi($plainToken, 'GET', '/api/v1/projects')->assertUnauthorized();
scopedTokenTestMcp($plainToken, 'tools/list')->assertUnauthorized();
$this->actingAs($this->admin);
Livewire::test(ApiTokens::class)
->call('setPaused', $token->id, false)
->assertDispatched('success', 'API token resumed.');
expect($token->fresh()->isPaused())->toBeFalse();
scopedTokenTestApi($plainToken, 'GET', '/api/v1/projects')->assertOk();
scopedTokenTestMcp($plainToken, 'tools/list')->assertOk();
});
test('cannot pause a token of another user', function () {
$otherAdmin = User::factory()->create();
$this->team->members()->attach($otherAdmin->id, ['role' => 'admin']);
scopedTokenTestToken($otherAdmin, $this->team, ['read'], null);
$token = PersonalAccessToken::query()->sole();
$this->actingAs($this->admin);
Livewire::test(ApiTokens::class)->call('setPaused', $token->id, true);
expect($token->fresh()->isPaused())->toBeFalse();
});
});