mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-12 07:43:37 -04:00
fix(access): apply API token limits in more permission checks
- Use the limits of the API token in more permission checks. - Show a clearer message when a token limit blocks a change. - Describe API tokens with limits in SECURITY.md.
This commit is contained in:
+1
-1
@@ -33,7 +33,7 @@ Please read this section before you send a report. The severity of a report depe
|
||||
- **Team member**: read-only access to the resources of the team. A member must not see secrets or change anything.
|
||||
- **Member with access grants**: an admin can give a member more levels (such as write or deploy) on the whole team, on projects, on environments, or on resources. A member with write access on a resource can deploy any Docker Compose file for it, including privileged containers and bind mounts of the host file system. That member therefore has root access to the servers where these resources run. In the root team, this includes the Coolify host when a resource runs on the localhost server. Give write access in the root team only to people that you trust as instance admins.
|
||||
- **Team admin / owner**: full control of the team's servers. An admin can open a terminal on these servers, deploy any Docker Compose file (including privileged containers and bind mounts of the host file system), run scheduled commands, and read all team secrets. An admin already has root access to every server of the team.
|
||||
- **API tokens**: a token has the permissions of its abilities. A token with `write` or `root` ability is equal to a team admin.
|
||||
- **API tokens**: a token has the permissions of its abilities. A token with `write` or `root` ability is equal to a team admin. A token limited to projects, environments, or resources is limited like a member with access grants: with `write`, it has root access to the servers where the resources inside its limit run.
|
||||
- **Instance admin** (admin or owner of the root team): already controls the Coolify instance and the server it runs on.
|
||||
- **Repository and Compose content**: Coolify runs what the configured repository, Dockerfile, or Compose file tells it to run. People who can change this content are trusted for the deployments that use it.
|
||||
|
||||
|
||||
@@ -759,13 +759,29 @@ class EnvironmentVariable extends BaseModel
|
||||
|
||||
$scope = self::sharedVariableScope($resource, $reference['type']);
|
||||
if ($scope === null || Gate::forUser($user)->denies('viewSensitive', $scope)) {
|
||||
$name = "{$reference['type']}.{$reference['key']}";
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
$field => "You cannot reference the shared variable {$reference['type']}.{$reference['key']}, because you cannot read its value.",
|
||||
$field => self::isBlockedOnlyByTokenScope($user, $scope)
|
||||
? "This API token is limited to selected projects, environments, or resources, so it cannot reference the {$reference['type']} shared variable {$name}. Use a token for the whole team."
|
||||
: "You cannot reference the shared variable {$name}, because you cannot read its value.",
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the user is an admin who can read team and server variables, but the API token of the request
|
||||
* is limited to selected projects, environments, or resources.
|
||||
*/
|
||||
private static function isBlockedOnlyByTokenScope(User $user, ?SharedEnvironmentVariable $scope): bool
|
||||
{
|
||||
return $scope !== null
|
||||
&& in_array($scope->type, ['team', 'server'], true)
|
||||
&& data_get($user->currentAccessToken(), 'scopes') !== null
|
||||
&& $user->isAdminOfTeam((int) $scope->team_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* An unsaved shared variable of the given type for the resource. The policy decides by the
|
||||
* scope, so a reference to a key that does not exist yet is checked like an existing one.
|
||||
|
||||
@@ -124,7 +124,7 @@ class DatabasePolicy
|
||||
private function allows(User $user, AccessLevel $level, $database): bool
|
||||
{
|
||||
if ($this->isInstanceDatabase($database)) {
|
||||
return $user->isAdminOfTeam(0);
|
||||
return $user->isAdminOfTeam(0) && $user->canAccess($level, $database);
|
||||
}
|
||||
|
||||
return $user->canAccess($level, $database);
|
||||
|
||||
@@ -8,7 +8,8 @@ use App\Models\User;
|
||||
use App\Services\Access\AccessResolver;
|
||||
|
||||
/**
|
||||
* Team and server variables need team-wide access (owners and admins to change them).
|
||||
* Team and server variables need team-wide access (owners and admins to read their values or change them).
|
||||
* The API token of the request must also cover the whole team, so a token limited to some projects cannot use them.
|
||||
* Project and environment variables follow the access level on their project or environment.
|
||||
*/
|
||||
class SharedEnvironmentVariablePolicy
|
||||
@@ -95,8 +96,11 @@ class SharedEnvironmentVariablePolicy
|
||||
return $target !== null && $user->canAccess($level, $target);
|
||||
}
|
||||
|
||||
return $level === AccessLevel::Read
|
||||
? app(AccessResolver::class)->allowsTeamWide($user, AccessLevel::Read, $sharedEnvironmentVariable->team_id)
|
||||
: $user->isAdminOfTeam($sharedEnvironmentVariable->team_id);
|
||||
$teamId = (int) $sharedEnvironmentVariable->team_id;
|
||||
if ($level !== AccessLevel::Read && ! $user->isAdminOfTeam($teamId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return app(AccessResolver::class)->allowsTeamWide($user, $level, $teamId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\SharedEnvironmentVariable;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Once;
|
||||
|
||||
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]);
|
||||
$settings->id = 0;
|
||||
$settings->save();
|
||||
Once::flush();
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->admin = User::factory()->create();
|
||||
$this->team->members()->attach($this->admin->id, ['role' => 'admin']);
|
||||
|
||||
$project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->staging = Environment::factory()->create(['project_id' => $project->id]);
|
||||
$server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
|
||||
$this->application = Application::factory()->create([
|
||||
'environment_id' => $this->staging->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
SharedEnvironmentVariable::create(['key' => 'TEAM_SECRET', 'value' => 'team-secret-value', 'type' => 'team', 'team_id' => $this->team->id]);
|
||||
SharedEnvironmentVariable::create(['key' => 'SERVER_SECRET', 'value' => 'server-secret-value', 'type' => 'server', 'team_id' => $this->team->id, 'server_id' => $server->id]);
|
||||
SharedEnvironmentVariable::create(['key' => 'STAGING_SECRET', 'value' => 'staging-secret-value', 'type' => 'environment', 'team_id' => $this->team->id, 'project_id' => $project->id, 'environment_id' => $this->staging->id]);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
Server::flushIdentityMap();
|
||||
});
|
||||
|
||||
function scopedSharedVariablesToken(User $user, Team $team, ?array $scopes): string
|
||||
{
|
||||
$token = $user->createToken('scoped-shared-variables', ['read', 'read:sensitive', 'write', 'deploy'], null, $team->id);
|
||||
$token->accessToken->forceFill(['scopes' => $scopes])->save();
|
||||
|
||||
return $token->plainTextToken;
|
||||
}
|
||||
|
||||
function scopedSharedVariablesApi(string $token, string $method, string $uri, array $data = [])
|
||||
{
|
||||
app('auth')->forgetGuards();
|
||||
|
||||
return test()->withHeaders(['Authorization' => 'Bearer '.$token])->json($method, $uri, $data);
|
||||
}
|
||||
|
||||
test('an admin token scoped to one environment cannot reference team or server variables', function (string $reference) {
|
||||
$token = scopedSharedVariablesToken($this->admin, $this->team, [['scope' => 'environment', 'uuid' => $this->staging->uuid]]);
|
||||
|
||||
scopedSharedVariablesApi($token, 'POST', "/api/v1/applications/{$this->application->uuid}/envs", [
|
||||
'key' => 'LEAK',
|
||||
'value' => $reference,
|
||||
])->assertUnprocessable()->assertJsonValidationErrors([
|
||||
'value' => 'This API token is limited to selected projects, environments, or resources, so it cannot reference',
|
||||
]);
|
||||
|
||||
expect($this->application->environment_variables()->where('key', 'LEAK')->exists())->toBeFalse();
|
||||
})->with(['{{team.TEAM_SECRET}}', '{{server.SERVER_SECRET}}']);
|
||||
|
||||
test('an admin token scoped to one environment does not see team variable values that the application already references', function () {
|
||||
$this->application->environment_variables()->create(['key' => 'FROM_TEAM', 'value' => '{{team.TEAM_SECRET}}']);
|
||||
$token = scopedSharedVariablesToken($this->admin, $this->team, [['scope' => 'environment', 'uuid' => $this->staging->uuid]]);
|
||||
|
||||
$response = scopedSharedVariablesApi($token, 'GET', "/api/v1/applications/{$this->application->uuid}/envs")->assertOk();
|
||||
|
||||
expect($response->getContent())->not->toContain('team-secret-value');
|
||||
expect(collect($response->json())->firstWhere('key', 'FROM_TEAM')['real_value'])->toBe('{{team.TEAM_SECRET}}');
|
||||
});
|
||||
|
||||
test('an admin token scoped to one environment can reference the variables of that environment', function () {
|
||||
$token = scopedSharedVariablesToken($this->admin, $this->team, [['scope' => 'environment', 'uuid' => $this->staging->uuid]]);
|
||||
|
||||
scopedSharedVariablesApi($token, 'POST', "/api/v1/applications/{$this->application->uuid}/envs", [
|
||||
'key' => 'FROM_STAGING',
|
||||
'value' => '{{environment.STAGING_SECRET}}',
|
||||
])->assertCreated();
|
||||
});
|
||||
|
||||
test('an admin token for the whole team can reference team and server variables', function () {
|
||||
$token = scopedSharedVariablesToken($this->admin, $this->team, null);
|
||||
|
||||
scopedSharedVariablesApi($token, 'POST', "/api/v1/applications/{$this->application->uuid}/envs", [
|
||||
'key' => 'FROM_TEAM',
|
||||
'value' => '{{team.TEAM_SECRET}}',
|
||||
])->assertCreated();
|
||||
scopedSharedVariablesApi($token, 'POST', "/api/v1/applications/{$this->application->uuid}/envs", [
|
||||
'key' => 'FROM_SERVER',
|
||||
'value' => '{{server.SERVER_SECRET}}',
|
||||
])->assertCreated();
|
||||
|
||||
$response = scopedSharedVariablesApi($token, 'GET', "/api/v1/applications/{$this->application->uuid}/envs")->assertOk();
|
||||
expect(collect($response->json())->firstWhere('key', 'FROM_TEAM')['real_value'])->toBe('team-secret-value');
|
||||
});
|
||||
@@ -219,3 +219,15 @@ it('denies force delete for any user', function () {
|
||||
$policy = new DatabasePolicy;
|
||||
expect($policy->forceDelete($user, $database))->toBeFalse();
|
||||
});
|
||||
|
||||
it('denies a root team admin to update the instance database when the token does not cover it', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$database = Mockery::mock(StandalonePostgresql::class)->makePartial();
|
||||
$database->id = 0;
|
||||
|
||||
$user->shouldReceive('isAdminOfTeam')->with(0)->andReturn(true);
|
||||
$user->shouldReceive('canAccess')->with(AccessLevel::Write, $database)->andReturn(false);
|
||||
|
||||
$policy = new DatabasePolicy;
|
||||
expect($policy->update($user, $database)->allowed())->toBeFalse();
|
||||
});
|
||||
|
||||
@@ -66,10 +66,29 @@ it('allows team admin to update shared environment variable', function () {
|
||||
|
||||
$model = mockSharedEnvironmentVariable(teamId: 1);
|
||||
|
||||
$resolver = Mockery::mock(AccessResolver::class);
|
||||
$resolver->shouldReceive('allowsTeamWide')->with($user, AccessLevel::Write, 1)->andReturn(true);
|
||||
Container::getInstance()->instance(AccessResolver::class, $resolver);
|
||||
|
||||
$policy = new SharedEnvironmentVariablePolicy;
|
||||
expect($policy->update($user, $model))->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies team admin whose token does not cover the whole team to read or update a team variable', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
|
||||
|
||||
$model = mockSharedEnvironmentVariable(teamId: 1);
|
||||
|
||||
$resolver = Mockery::mock(AccessResolver::class);
|
||||
$resolver->shouldReceive('allowsTeamWide')->andReturn(false);
|
||||
Container::getInstance()->instance(AccessResolver::class, $resolver);
|
||||
|
||||
$policy = new SharedEnvironmentVariablePolicy;
|
||||
expect($policy->viewSensitive($user, $model))->toBeFalse()
|
||||
->and($policy->update($user, $model))->toBeFalse();
|
||||
});
|
||||
|
||||
it('denies team member to update shared environment variable', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
|
||||
@@ -86,6 +105,10 @@ it('allows team admin to delete shared environment variable', function () {
|
||||
|
||||
$model = mockSharedEnvironmentVariable(teamId: 1);
|
||||
|
||||
$resolver = Mockery::mock(AccessResolver::class);
|
||||
$resolver->shouldReceive('allowsTeamWide')->with($user, AccessLevel::Write, 1)->andReturn(true);
|
||||
Container::getInstance()->instance(AccessResolver::class, $resolver);
|
||||
|
||||
$policy = new SharedEnvironmentVariablePolicy;
|
||||
expect($policy->delete($user, $model))->toBeTrue();
|
||||
});
|
||||
@@ -124,6 +147,10 @@ it('allows team admin to manage environment', function () {
|
||||
|
||||
$model = mockSharedEnvironmentVariable(teamId: 1);
|
||||
|
||||
$resolver = Mockery::mock(AccessResolver::class);
|
||||
$resolver->shouldReceive('allowsTeamWide')->with($user, AccessLevel::Write, 1)->andReturn(true);
|
||||
Container::getInstance()->instance(AccessResolver::class, $resolver);
|
||||
|
||||
$policy = new SharedEnvironmentVariablePolicy;
|
||||
expect($policy->manageEnvironment($user, $model))->toBeTrue();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user