mirror of
https://github.com/coollabsio/coolify.git
synced 2026-08-24 02:24:11 -05:00
Add Doppler, Infisical, and Vault integrations with per-resource secret links, autocomplete, and deploy-time resolution for applications, services, and databases without persisting remote values.
409 lines
14 KiB
PHP
409 lines
14 KiB
PHP
<?php
|
|
|
|
use App\Exceptions\DeploymentException;
|
|
use App\Jobs\ApplicationDeploymentJob;
|
|
use App\Livewire\Security\IntegrationTokens;
|
|
use App\Models\Application;
|
|
use App\Models\ApplicationDeploymentQueue;
|
|
use App\Models\Environment;
|
|
use App\Models\InstanceSettings;
|
|
use App\Models\IntegrationToken;
|
|
use App\Models\Project;
|
|
use App\Models\SecretManagerLink;
|
|
use App\Models\Server;
|
|
use App\Models\Service;
|
|
use App\Models\StandaloneClickhouse;
|
|
use App\Models\StandaloneDragonfly;
|
|
use App\Models\StandaloneKeydb;
|
|
use App\Models\StandaloneMariadb;
|
|
use App\Models\StandaloneMongodb;
|
|
use App\Models\StandaloneMysql;
|
|
use App\Models\StandalonePostgresql;
|
|
use App\Models\StandaloneRedis;
|
|
use App\Models\Team;
|
|
use App\Models\User;
|
|
use App\Traits\HasSecretManager;
|
|
use Illuminate\Database\QueryException;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Http;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
if (! InstanceSettings::query()->whereKey(0)->exists()) {
|
|
$settings = new InstanceSettings;
|
|
$settings->id = 0;
|
|
$settings->save();
|
|
}
|
|
|
|
$this->team = Team::factory()->create();
|
|
$this->user = User::factory()->create();
|
|
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
|
session(['currentTeam' => $this->team]);
|
|
$this->actingAs($this->user);
|
|
|
|
$server = Server::factory()->create(['team_id' => $this->team->id]);
|
|
$destination = $server->standaloneDockers()->firstOrFail();
|
|
$project = Project::factory()->create(['team_id' => $this->team->id]);
|
|
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
|
|
|
$this->application = Application::factory()->create([
|
|
'environment_id' => $environment->id,
|
|
'destination_id' => $destination->id,
|
|
'destination_type' => $destination->getMorphClass(),
|
|
]);
|
|
});
|
|
|
|
function createSecretManagerLink(string $provider, array $settings = [], array $metadata = []): SecretManagerLink
|
|
{
|
|
$token = IntegrationToken::query()->create([
|
|
'team_id' => test()->team->id,
|
|
'provider' => $provider,
|
|
'name' => ucfirst($provider).' token',
|
|
'token' => 'the-secret-token',
|
|
'capabilities' => ['secrets'],
|
|
'metadata' => $metadata ?: null,
|
|
]);
|
|
|
|
return test()->application->secretManagerLink()->create([
|
|
'integration_token_id' => $token->id,
|
|
'settings' => $settings ?: null,
|
|
]);
|
|
}
|
|
|
|
function makeDeploymentJobForSecrets(): ApplicationDeploymentJob
|
|
{
|
|
$job = (new ReflectionClass(ApplicationDeploymentJob::class))->newInstanceWithoutConstructor();
|
|
|
|
$queue = ApplicationDeploymentQueue::create([
|
|
'application_id' => test()->application->id,
|
|
'deployment_uuid' => 'secrets-test-'.fake()->uuid(),
|
|
'status' => 'in_progress',
|
|
'server_id' => test()->application->destination->server->id,
|
|
'destination_id' => test()->application->destination->id,
|
|
'commit' => 'HEAD',
|
|
'pull_request_id' => 0,
|
|
]);
|
|
|
|
$properties = [
|
|
'application' => test()->application,
|
|
'application_deployment_queue' => $queue,
|
|
'mainServer' => test()->application->destination->server,
|
|
'pull_request_id' => 0,
|
|
];
|
|
|
|
foreach ($properties as $property => $value) {
|
|
$reflection = new ReflectionProperty($job, $property);
|
|
$reflection->setValue($job, $value);
|
|
}
|
|
|
|
return $job;
|
|
}
|
|
|
|
function resolveEnvOnJob(ApplicationDeploymentJob $job, $env): ?string
|
|
{
|
|
return (new ReflectionMethod($job, 'resolve_environment_variable'))->invoke($job, $env);
|
|
}
|
|
|
|
function deploymentHasRemoteBuildtimeReferences(ApplicationDeploymentJob $job): bool
|
|
{
|
|
return (new ReflectionMethod($job, 'has_remote_buildtime_secret_references'))->invoke($job);
|
|
}
|
|
|
|
test('remote build-time secret references prevent same-commit image reuse', function (string $reference) {
|
|
$this->application->environment_variables()->create([
|
|
'key' => 'BUILD_SECRET',
|
|
'value' => $reference,
|
|
'is_buildtime' => true,
|
|
]);
|
|
|
|
expect(deploymentHasRemoteBuildtimeReferences(makeDeploymentJobForSecrets()))->toBeTrue();
|
|
})->with([
|
|
'provider-neutral reference' => '{{vault.BUILD_SECRET}}',
|
|
]);
|
|
|
|
test('runtime-only remote secret references still allow same-commit image reuse', function () {
|
|
$this->application->environment_variables()->create([
|
|
'key' => 'RUNTIME_SECRET',
|
|
'value' => '{{vault.RUNTIME_SECRET}}',
|
|
'is_buildtime' => false,
|
|
]);
|
|
|
|
expect(deploymentHasRemoteBuildtimeReferences(makeDeploymentJobForSecrets()))->toBeFalse();
|
|
});
|
|
|
|
test('a doppler link fetches secrets with the stored token', function () {
|
|
Http::fake([
|
|
'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
|
|
'DB_PASSWORD' => 's3cret',
|
|
]),
|
|
]);
|
|
|
|
$link = createSecretManagerLink('doppler', ['project' => 'proj', 'config' => 'prd']);
|
|
|
|
expect($link->fetchSecrets())->toBe(['DB_PASSWORD' => 's3cret']);
|
|
|
|
Http::assertSent(fn ($request) => $request->hasHeader('Authorization', 'Bearer the-secret-token')
|
|
&& str_contains($request->url(), 'project=proj'));
|
|
});
|
|
|
|
test('a vault link uses the base url and namespace from the token metadata', function () {
|
|
Http::fake([
|
|
'https://vault.internal:8200/v1/kv/data/apps/web' => Http::response([
|
|
'data' => ['data' => ['KEY' => 'value']],
|
|
]),
|
|
]);
|
|
|
|
$link = createSecretManagerLink('vault',
|
|
['mount' => 'kv', 'path' => 'apps/web'],
|
|
['base_url' => 'https://vault.internal:8200', 'namespace' => 'team-a'],
|
|
);
|
|
|
|
expect($link->fetchSecrets())->toBe(['KEY' => 'value']);
|
|
|
|
Http::assertSent(fn ($request) => $request->hasHeader('X-Vault-Namespace', 'team-a'));
|
|
});
|
|
|
|
test('services resolve environment variables from their configured secret manager', function () {
|
|
Http::fake([
|
|
'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
|
|
'API_KEY' => 'remote-service-value',
|
|
]),
|
|
]);
|
|
|
|
$service = Service::factory()->create([
|
|
'environment_id' => $this->application->environment_id,
|
|
'destination_id' => $this->application->destination_id,
|
|
'destination_type' => $this->application->destination_type,
|
|
]);
|
|
$token = IntegrationToken::query()->create([
|
|
'team_id' => $this->team->id,
|
|
'provider' => 'doppler',
|
|
'name' => 'Service secrets',
|
|
'token' => 'the-secret-token',
|
|
'capabilities' => ['secrets'],
|
|
]);
|
|
$service->secretManagerLink()->create(['integration_token_id' => $token->id]);
|
|
$environmentVariable = $service->environment_variables()->create([
|
|
'key' => 'API_KEY',
|
|
'value' => '{{vault.API_KEY}}',
|
|
]);
|
|
|
|
expect($service->resolveSecretManagerEnvironmentVariable($environmentVariable))->toBe('remote-service-value');
|
|
});
|
|
|
|
test('all deployable environment-variable resources support secret managers', function (string $resourceClass) {
|
|
expect(class_uses_recursive($resourceClass))->toContain(HasSecretManager::class);
|
|
})->with([
|
|
Application::class,
|
|
Service::class,
|
|
StandalonePostgresql::class,
|
|
StandaloneMysql::class,
|
|
StandaloneMariadb::class,
|
|
StandaloneMongodb::class,
|
|
StandaloneRedis::class,
|
|
StandaloneKeydb::class,
|
|
StandaloneDragonfly::class,
|
|
StandaloneClickhouse::class,
|
|
]);
|
|
|
|
test('an application has at most one secret manager source', function () {
|
|
createSecretManagerLink('doppler');
|
|
|
|
$secondToken = IntegrationToken::query()->create([
|
|
'team_id' => $this->team->id,
|
|
'provider' => 'vault',
|
|
'name' => 'Vault token',
|
|
'token' => 'other-token',
|
|
'capabilities' => ['secrets'],
|
|
'metadata' => ['base_url' => 'https://vault.internal:8200'],
|
|
]);
|
|
|
|
expect(fn () => $this->application->secretManagerLink()->create([
|
|
'integration_token_id' => $secondToken->id,
|
|
]))->toThrow(QueryException::class);
|
|
});
|
|
|
|
test('a secret reference is substituted at deploy time and formatted as a dotenv literal', function () {
|
|
Http::fake([
|
|
'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
|
|
'DB_PASSWORD' => 'p4$$word',
|
|
]),
|
|
]);
|
|
|
|
createSecretManagerLink('doppler');
|
|
|
|
$env = $this->application->environment_variables()->create([
|
|
'key' => 'DATABASE_URL',
|
|
'value' => 'postgres://app:{{vault.DB_PASSWORD}}@db:5432/app',
|
|
]);
|
|
|
|
$job = makeDeploymentJobForSecrets();
|
|
|
|
expect(resolveEnvOnJob($job, $env))->toBe("'postgres://app:p4\$\$word@db:5432/app'");
|
|
});
|
|
|
|
test('provider alias references resolve against the single source', function () {
|
|
Http::fake([
|
|
'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
|
|
'API_KEY' => 'abc',
|
|
]),
|
|
]);
|
|
|
|
createSecretManagerLink('doppler');
|
|
|
|
$env = $this->application->environment_variables()->create([
|
|
'key' => 'API_KEY',
|
|
'value' => '{{vault.API_KEY}}',
|
|
]);
|
|
|
|
expect(resolveEnvOnJob(makeDeploymentJobForSecrets(), $env))->toBe("'abc'");
|
|
});
|
|
|
|
test('the fetch happens once per deployment even with many references', function () {
|
|
Http::fake([
|
|
'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
|
|
'A' => '1',
|
|
'B' => '2',
|
|
]),
|
|
]);
|
|
|
|
createSecretManagerLink('doppler');
|
|
|
|
$first = $this->application->environment_variables()->create(['key' => 'A', 'value' => '{{vault.A}}']);
|
|
$second = $this->application->environment_variables()->create(['key' => 'B', 'value' => '{{vault.B}}']);
|
|
|
|
$job = makeDeploymentJobForSecrets();
|
|
resolveEnvOnJob($job, $first);
|
|
resolveEnvOnJob($job, $second);
|
|
|
|
Http::assertSentCount(1);
|
|
});
|
|
|
|
test('variables without references never contact the secret manager', function () {
|
|
Http::fake();
|
|
|
|
createSecretManagerLink('doppler');
|
|
|
|
$env = $this->application->environment_variables()->create([
|
|
'key' => 'PLAIN',
|
|
'value' => 'plain-value',
|
|
]);
|
|
|
|
expect(resolveEnvOnJob(makeDeploymentJobForSecrets(), $env))->toBe('plain-value');
|
|
|
|
Http::assertNothingSent();
|
|
});
|
|
|
|
test('a missing secret key fails the deployment and names the variable', function () {
|
|
Http::fake([
|
|
'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
|
|
'OTHER' => 'value',
|
|
]),
|
|
]);
|
|
|
|
createSecretManagerLink('doppler');
|
|
|
|
$env = $this->application->environment_variables()->create([
|
|
'key' => 'DB_PASSWORD',
|
|
'value' => '{{vault.GONE_KEY}}',
|
|
]);
|
|
|
|
expect(fn () => resolveEnvOnJob(makeDeploymentJobForSecrets(), $env))
|
|
->toThrow(DeploymentException::class, 'Missing secret keys: GONE_KEY (referenced by DB_PASSWORD).');
|
|
});
|
|
|
|
test('a reference without a configured source fails the deployment', function () {
|
|
Http::fake();
|
|
|
|
$env = $this->application->environment_variables()->create([
|
|
'key' => 'DB_PASSWORD',
|
|
'value' => '{{vault.DB_PASSWORD}}',
|
|
]);
|
|
|
|
expect(fn () => resolveEnvOnJob(makeDeploymentJobForSecrets(), $env))
|
|
->toThrow(DeploymentException::class, 'no secret manager source is configured');
|
|
|
|
Http::assertNothingSent();
|
|
});
|
|
|
|
test('a fetch failure stops the deployment with a clear error', function () {
|
|
Http::fake([
|
|
'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
|
|
'messages' => ['Invalid Auth token'],
|
|
], 401),
|
|
]);
|
|
|
|
createSecretManagerLink('doppler');
|
|
|
|
$env = $this->application->environment_variables()->create([
|
|
'key' => 'DB_PASSWORD',
|
|
'value' => '{{vault.DB_PASSWORD}}',
|
|
]);
|
|
|
|
expect(fn () => resolveEnvOnJob(makeDeploymentJobForSecrets(), $env))
|
|
->toThrow(DeploymentException::class, 'Could not fetch secrets from Doppler.');
|
|
});
|
|
|
|
test('import creates reference variables for missing keys only', function () {
|
|
Http::fake([
|
|
'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
|
|
'EXISTING' => 'value-a',
|
|
'NEW_KEY' => 'value-b',
|
|
]),
|
|
]);
|
|
|
|
createSecretManagerLink('doppler');
|
|
$this->application->environment_variables()->create(['key' => 'EXISTING', 'value' => 'local']);
|
|
|
|
$imported = $this->application->secretManagerLink->importMissingReferences();
|
|
|
|
expect($imported)->toBe(['NEW_KEY']);
|
|
|
|
$created = $this->application->environment_variables()->where('key', 'NEW_KEY')->firstOrFail();
|
|
expect($created->value)->toBe('{{vault.NEW_KEY}}')
|
|
->and($this->application->environment_variables()->where('key', 'EXISTING')->firstOrFail()->value)->toBe('local');
|
|
});
|
|
|
|
test('secret references are not marked as shared variables', function () {
|
|
$secretRef = $this->application->environment_variables()->create([
|
|
'key' => 'A',
|
|
'value' => '{{vault.A}}',
|
|
]);
|
|
$sharedRef = $this->application->environment_variables()->create([
|
|
'key' => 'B',
|
|
'value' => '{{team.B}}',
|
|
]);
|
|
|
|
expect($secretRef->refresh()->is_shared)->toBeFalse()
|
|
->and($sharedRef->refresh()->is_shared)->toBeTrue();
|
|
});
|
|
|
|
test('remote secret values are formatted as dotenv literals', function () {
|
|
$job = (new ReflectionClass(ApplicationDeploymentJob::class))->newInstanceWithoutConstructor();
|
|
$format = fn (string $value) => (new ReflectionMethod($job, 'format_remote_secret_value'))->invoke($job, $value);
|
|
|
|
expect($format('simple'))->toBe("'simple'")
|
|
->and($format('with $dollar and spaces'))->toBe("'with \$dollar and spaces'")
|
|
->and($format("it's quoted"))->toBe('"it\'s quoted"')
|
|
->and($format('{"json": true}'))->toBe('{"json": true}');
|
|
});
|
|
|
|
test('deleting an integration token is blocked while links exist', function () {
|
|
$link = createSecretManagerLink('doppler');
|
|
|
|
Livewire\Livewire::test(IntegrationTokens::class)
|
|
->call('deleteToken', $link->integration_token_id)
|
|
->assertDispatched('error');
|
|
|
|
expect(IntegrationToken::query()->whereKey($link->integration_token_id)->exists())->toBeTrue();
|
|
|
|
$link->delete();
|
|
|
|
Livewire\Livewire::test(IntegrationTokens::class)
|
|
->call('deleteToken', $link->integration_token_id)
|
|
->assertDispatched('success');
|
|
|
|
expect(IntegrationToken::query()->whereKey($link->integration_token_id)->exists())->toBeFalse();
|
|
});
|