mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-04 10:42:23 +00:00
Require deploy permission for API changes that trigger server side effects, including resource deletion, storage access, provider operations, and database imports. Also escape Docker cleanup filters and sanitize log drain server names.
294 lines
13 KiB
PHP
294 lines
13 KiB
PHP
<?php
|
|
|
|
use App\Jobs\DatabaseBackupJob;
|
|
use App\Models\Application;
|
|
use App\Models\CloudProviderToken;
|
|
use App\Models\InstanceSettings;
|
|
use App\Models\LocalPersistentVolume;
|
|
use App\Models\PrivateKey;
|
|
use App\Models\Project;
|
|
use App\Models\ScheduledDatabaseBackup;
|
|
use App\Models\ScheduledVolumeBackup;
|
|
use App\Models\Server;
|
|
use App\Models\Service;
|
|
use App\Models\ServiceDatabase;
|
|
use App\Models\StandaloneDocker;
|
|
use App\Models\StandalonePostgresql;
|
|
use App\Models\Team;
|
|
use App\Models\User;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Queue;
|
|
use Illuminate\Support\Str;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
config()->set('cache.default', 'array');
|
|
config()->set('app.maintenance.store', 'array');
|
|
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
|
|
|
|
$this->team = Team::factory()->create();
|
|
$this->user = User::factory()->create();
|
|
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
|
session(['currentTeam' => $this->team]);
|
|
|
|
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
|
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail();
|
|
$this->environment = Project::factory()->create(['team_id' => $this->team->id])->environments()->first();
|
|
$this->destinationAttributes = [
|
|
'environment_id' => $this->environment->id,
|
|
'destination_id' => $this->destination->id,
|
|
'destination_type' => $this->destination->getMorphClass(),
|
|
];
|
|
|
|
$this->database = StandalonePostgresql::create($this->destinationAttributes + [
|
|
'name' => 'deploy-ability-db',
|
|
'image' => 'postgres:17-alpine',
|
|
'postgres_user' => 'postgres',
|
|
'postgres_password' => 'password',
|
|
'postgres_db' => 'postgres',
|
|
'is_public' => false,
|
|
]);
|
|
|
|
Queue::fake();
|
|
Http::fake();
|
|
});
|
|
|
|
function deployAbilityHeaders(User $user, int $teamId, array $abilities): array
|
|
{
|
|
$plainTextToken = Str::random(40);
|
|
$token = $user->tokens()->create([
|
|
'name' => 'cloud-init-public-backup-ability-test',
|
|
'token' => hash('sha256', $plainTextToken),
|
|
'abilities' => $abilities,
|
|
'team_id' => $teamId,
|
|
]);
|
|
|
|
return ['Authorization' => 'Bearer '.$token->getKey().'|'.$plainTextToken];
|
|
}
|
|
|
|
dataset('cloud providers', [
|
|
'hetzner' => ['hetzner', ['location' => 'nbg1', 'server_type' => 'cx11', 'image' => 15512617], 'hetzner_ssh_key_ids', [123]],
|
|
'vultr' => ['vultr', ['region' => 'ewr', 'plan' => 'vc2-1c-1gb', 'os_id' => 2284], 'vultr_ssh_key_ids', ['key-1']],
|
|
'digitalocean' => ['digitalocean', ['region' => 'nyc1', 'size' => 's-1vcpu-1gb', 'image' => 'ubuntu-24-04-x64'], 'digitalocean_ssh_key_ids', [123]],
|
|
]);
|
|
|
|
function cloudServerPayload(object $context, string $provider, array $providerFields, ?string $tokenUuid = null): array
|
|
{
|
|
$token = CloudProviderToken::create([
|
|
'team_id' => $context->team->id,
|
|
'provider' => $provider,
|
|
'name' => 'Test token',
|
|
'token' => 'test-api-token',
|
|
]);
|
|
$privateKey = PrivateKey::create([
|
|
'team_id' => $context->team->id,
|
|
'name' => 'Test Key',
|
|
'private_key' => '-----BEGIN OPENSSH PRIVATE KEY-----
|
|
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
|
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
|
|
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
|
|
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
|
|
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
|
|
-----END OPENSSH PRIVATE KEY-----',
|
|
]);
|
|
|
|
return $providerFields + [
|
|
'cloud_provider_token_uuid' => $tokenUuid ?? $token->uuid,
|
|
'name' => 'deploy-ability-server',
|
|
'private_key_uuid' => $privateKey->uuid,
|
|
];
|
|
}
|
|
|
|
test('write-only token cannot create a cloud server with a cloud-init script or extra SSH keys', function (string $provider, array $providerFields, string $sshKeyField, array $sshKeyIds, string $extra) {
|
|
$payload = cloudServerPayload($this, $provider, $providerFields);
|
|
$payload += $extra === 'cloud_init_script'
|
|
? ['cloud_init_script' => "#cloud-config\nruncmd:\n - echo hello\n"]
|
|
: [$sshKeyField => $sshKeyIds];
|
|
$serverCount = Server::query()->count();
|
|
|
|
$this->withHeaders(deployAbilityHeaders($this->user, $this->team->id, ['write']))
|
|
->postJson("/api/v1/servers/{$provider}", $payload)
|
|
->assertForbidden()
|
|
->assertJsonPath('message', 'Missing required permissions: deploy. A cloud-init script or extra SSH keys run as root on the new server, so it needs a token with the deploy permission.');
|
|
|
|
Http::assertNothingSent();
|
|
expect(Server::query()->count())->toBe($serverCount);
|
|
})->with('cloud providers')->with(['cloud_init_script', 'ssh_key_ids']);
|
|
|
|
test('write and deploy token passes the cloud-init check', function (string $provider, array $providerFields, string $sshKeyField, array $sshKeyIds) {
|
|
// An unknown provider token stops the request after the ability check, before any provider call.
|
|
$payload = cloudServerPayload($this, $provider, $providerFields, 'missing-token') + [
|
|
'cloud_init_script' => "#cloud-config\nruncmd:\n - echo hello\n",
|
|
$sshKeyField => $sshKeyIds,
|
|
];
|
|
|
|
$this->withHeaders(deployAbilityHeaders($this->user, $this->team->id, ['write', 'deploy']))
|
|
->postJson("/api/v1/servers/{$provider}", $payload)
|
|
->assertNotFound();
|
|
|
|
Http::assertNothingSent();
|
|
})->with('cloud providers');
|
|
|
|
test('write-only token can create a cloud server request without cloud-init or extra SSH keys', function (string $provider, array $providerFields) {
|
|
$this->withHeaders(deployAbilityHeaders($this->user, $this->team->id, ['write']))
|
|
->postJson("/api/v1/servers/{$provider}", cloudServerPayload($this, $provider, $providerFields, 'missing-token'))
|
|
->assertNotFound();
|
|
})->with('cloud providers');
|
|
|
|
test('write-only token cannot make a database public', function () {
|
|
$this->withHeaders(deployAbilityHeaders($this->user, $this->team->id, ['write']))
|
|
->patchJson("/api/v1/databases/{$this->database->uuid}", ['is_public' => true, 'public_port' => 25432])
|
|
->assertForbidden()
|
|
->assertJsonPath('message', 'Missing required permissions: deploy. Making a database public or private starts or stops its proxy on the server, so it needs a token with the deploy permission.');
|
|
|
|
expect((bool) $this->database->fresh()->is_public)->toBeFalse();
|
|
Queue::assertNothingPushed();
|
|
});
|
|
|
|
test('write-only token can send an unchanged is_public value', function () {
|
|
$this->withHeaders(deployAbilityHeaders($this->user, $this->team->id, ['write']))
|
|
->patchJson("/api/v1/databases/{$this->database->uuid}", ['is_public' => false, 'name' => 'renamed-db'])
|
|
->assertOk();
|
|
|
|
expect($this->database->fresh()->name)->toBe('renamed-db');
|
|
});
|
|
|
|
test('write and deploy token can make a database public', function () {
|
|
$this->withHeaders(deployAbilityHeaders($this->user, $this->team->id, ['write', 'deploy']))
|
|
->patchJson("/api/v1/databases/{$this->database->uuid}", ['is_public' => true, 'public_port' => 25432])
|
|
->assertOk();
|
|
|
|
expect((bool) $this->database->fresh()->is_public)->toBeTrue();
|
|
});
|
|
|
|
test('database update rejects a public port outside 1024-65535', function (int $port) {
|
|
$this->withHeaders(deployAbilityHeaders($this->user, $this->team->id, ['write', 'deploy']))
|
|
->patchJson("/api/v1/databases/{$this->database->uuid}", ['public_port' => $port])
|
|
->assertUnprocessable()
|
|
->assertJsonValidationErrors('public_port');
|
|
|
|
expect($this->database->fresh()->public_port)->toBeNull();
|
|
})->with([80, 70000]);
|
|
|
|
test('service database is_public change needs the deploy permission', function (array $abilities, int $status) {
|
|
$service = Service::factory()->create($this->destinationAttributes + [
|
|
'server_id' => $this->server->id,
|
|
'docker_compose_raw' => "services:\n postgres:\n image: postgres:17-alpine\n",
|
|
]);
|
|
$serviceDatabase = ServiceDatabase::create([
|
|
'uuid' => (string) Str::uuid(),
|
|
'name' => 'postgres',
|
|
'service_id' => $service->id,
|
|
'image' => 'postgres:17-alpine',
|
|
'is_public' => false,
|
|
]);
|
|
|
|
$response = $this->withHeaders(deployAbilityHeaders($this->user, $this->team->id, $abilities))
|
|
->patchJson("/api/v1/services/{$service->uuid}/databases/{$serviceDatabase->uuid}", ['is_public' => true, 'public_port' => 25433])
|
|
->assertStatus($status);
|
|
|
|
if ($status === 403) {
|
|
$response->assertJsonPath('message', 'Missing required permissions: deploy. Making a database public or private starts or stops its proxy on the server, so it needs a token with the deploy permission.');
|
|
}
|
|
expect((bool) $serviceDatabase->fresh()->is_public)->toBe($status === 200);
|
|
})->with([
|
|
'write only' => [['write'], 403],
|
|
'write and deploy' => [['write', 'deploy'], 200],
|
|
]);
|
|
|
|
test('backup_now needs the deploy permission when creating a database backup', function (array $abilities, int $status) {
|
|
$response = $this->withHeaders(deployAbilityHeaders($this->user, $this->team->id, $abilities))
|
|
->postJson("/api/v1/databases/{$this->database->uuid}/backups", ['frequency' => 'daily', 'backup_now' => true])
|
|
->assertStatus($status);
|
|
|
|
if ($status === 403) {
|
|
$response->assertJsonPath('message', 'Missing required permissions: deploy. A backup that runs now copies the database on the server, so it needs a token with the deploy permission.');
|
|
expect(ScheduledDatabaseBackup::query()->count())->toBe(0);
|
|
Queue::assertNotPushed(DatabaseBackupJob::class);
|
|
} else {
|
|
Queue::assertPushed(DatabaseBackupJob::class);
|
|
}
|
|
})->with([
|
|
'write only' => [['write'], 403],
|
|
'write and deploy' => [['write', 'deploy'], 201],
|
|
]);
|
|
|
|
test('write-only token can create a database backup schedule without backup_now', function () {
|
|
$this->withHeaders(deployAbilityHeaders($this->user, $this->team->id, ['write']))
|
|
->postJson("/api/v1/databases/{$this->database->uuid}/backups", ['frequency' => 'daily'])
|
|
->assertCreated();
|
|
|
|
Queue::assertNotPushed(DatabaseBackupJob::class);
|
|
});
|
|
|
|
test('backup_now needs the deploy permission when updating a database backup', function (array $abilities, int $status) {
|
|
$backup = ScheduledDatabaseBackup::create([
|
|
'frequency' => 'daily',
|
|
'database_id' => $this->database->id,
|
|
'database_type' => $this->database->getMorphClass(),
|
|
'team_id' => $this->team->id,
|
|
]);
|
|
|
|
$response = $this->withHeaders(deployAbilityHeaders($this->user, $this->team->id, $abilities))
|
|
->patchJson("/api/v1/databases/{$this->database->uuid}/backups/{$backup->uuid}", ['frequency' => 'hourly', 'backup_now' => true])
|
|
->assertStatus($status);
|
|
|
|
if ($status === 403) {
|
|
$response->assertJsonPath('message', 'Missing required permissions: deploy. A backup that runs now copies the database on the server, so it needs a token with the deploy permission.');
|
|
expect($backup->fresh()->frequency)->toBe('daily');
|
|
Queue::assertNotPushed(DatabaseBackupJob::class);
|
|
} else {
|
|
expect($backup->fresh()->frequency)->toBe('hourly');
|
|
Queue::assertPushed(DatabaseBackupJob::class);
|
|
}
|
|
})->with([
|
|
'write only' => [['write'], 403],
|
|
'write and deploy' => [['write', 'deploy'], 200],
|
|
]);
|
|
|
|
test('stop_during_backup needs the deploy permission for a volume backup schedule', function (array $abilities, bool $stopDuringBackup, int $status) {
|
|
$application = Application::factory()->create($this->destinationAttributes);
|
|
$volume = LocalPersistentVolume::create([
|
|
'name' => 'deploy-ability-volume',
|
|
'mount_path' => '/data',
|
|
'resource_id' => $application->id,
|
|
'resource_type' => $application->getMorphClass(),
|
|
]);
|
|
|
|
$response = $this->withHeaders(deployAbilityHeaders($this->user, $this->team->id, $abilities))
|
|
->putJson("/api/v1/applications/{$application->uuid}/storages/{$volume->uuid}/backups", [
|
|
'frequency' => '0 2 * * *',
|
|
'stop_during_backup' => $stopDuringBackup,
|
|
])
|
|
->assertStatus($status);
|
|
|
|
if ($status === 403) {
|
|
$response->assertJsonPath('message', 'Missing required permissions: deploy. A backup that stops the containers needs a token with the deploy permission.');
|
|
expect(ScheduledVolumeBackup::query()->count())->toBe(0);
|
|
} else {
|
|
expect(ScheduledVolumeBackup::query()->sole()->stop_during_backup)->toBe($stopDuringBackup);
|
|
}
|
|
})->with([
|
|
'write only, stop' => [['write'], true, 403],
|
|
'write only, no stop' => [['write'], false, 201],
|
|
'write and deploy, stop' => [['write', 'deploy'], true, 201],
|
|
]);
|
|
|
|
test('write-only token cannot run a volume backup now', function () {
|
|
$application = Application::factory()->create($this->destinationAttributes);
|
|
$volume = LocalPersistentVolume::create([
|
|
'name' => 'deploy-ability-volume',
|
|
'mount_path' => '/data',
|
|
'resource_id' => $application->id,
|
|
'resource_type' => $application->getMorphClass(),
|
|
]);
|
|
|
|
$this->withHeaders(deployAbilityHeaders($this->user, $this->team->id, ['write']))
|
|
->postJson("/api/v1/applications/{$application->uuid}/storages/{$volume->uuid}/backups/run")
|
|
->assertForbidden();
|
|
|
|
Queue::assertNothingPushed();
|
|
});
|