mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
Merge remote-tracking branch 'origin/main' into next
This commit is contained in:
@@ -20,11 +20,15 @@ use App\Models\S3Storage;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandaloneDragonfly;
|
||||
use App\Models\StandaloneKeydb;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\StandaloneRedis;
|
||||
use App\Models\StandaloneSqlite;
|
||||
use App\Models\SwarmDocker;
|
||||
use App\Support\ResourceStartActivity;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -275,6 +279,14 @@ class DatabasesController extends Controller
|
||||
$databases = $databases->merge($project->databases($databaseRelations));
|
||||
}
|
||||
|
||||
if ($request->attributes->get('can_read_sensitive', false) === true) {
|
||||
// Their connection URLs read REDIS_PASSWORD; load the variables once per type instead of per database.
|
||||
$databases
|
||||
->filter(fn ($database) => $database instanceof StandaloneRedis || $database instanceof StandaloneKeydb || $database instanceof StandaloneDragonfly)
|
||||
->groupBy(fn ($database) => $database::class)
|
||||
->each(fn ($group) => (new EloquentCollection($group->all()))->load('runtime_environment_variables'));
|
||||
}
|
||||
|
||||
$databaseIds = $databases->pluck('id')->toArray();
|
||||
|
||||
$backupConfigs = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->with('latest_log')
|
||||
@@ -284,6 +296,7 @@ class DatabasesController extends Controller
|
||||
|
||||
$databases = $databases->map(function ($database) use ($backupConfigs) {
|
||||
$database->backup_configs = $backupConfigs->get($database->getMorphClass().':'.$database->id, collect())->values();
|
||||
$database->makeHidden('runtime_environment_variables');
|
||||
|
||||
return $this->removeSensitiveData($database);
|
||||
});
|
||||
|
||||
@@ -167,10 +167,12 @@ class ProjectController extends Controller
|
||||
if (! $environment) {
|
||||
return response()->json(['message' => 'Environment not found.'], 404);
|
||||
}
|
||||
$environment = $environment->load(['applications', 'postgresqls', 'redis', 'mongodbs', 'mysqls', 'mariadbs', 'services']);
|
||||
// Redis connection URLs read REDIS_PASSWORD and REDIS_USERNAME; load the variables once, not per database.
|
||||
$environment = $environment->load(['applications', 'postgresqls', 'redis.runtime_environment_variables', 'mongodbs', 'mysqls', 'mariadbs', 'services']);
|
||||
collect(['applications', 'postgresqls', 'redis', 'mongodbs', 'mysqls', 'mariadbs', 'services'])
|
||||
->flatMap(fn (string $relation) => $environment->{$relation})
|
||||
->each(fn ($resource) => exposeSensitiveFields($resource));
|
||||
$environment->redis->each(fn ($redis) => $redis->makeHidden('runtime_environment_variables'));
|
||||
|
||||
return response()->json(serializeApiResponse($environment));
|
||||
}
|
||||
|
||||
@@ -13,14 +13,10 @@ class CloudProviderTokens extends Component
|
||||
|
||||
public $tokens;
|
||||
|
||||
public function mount()
|
||||
public function mount(): void
|
||||
{
|
||||
try {
|
||||
$this->authorize('viewAny', CloudProviderToken::class);
|
||||
$this->loadTokens();
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
$this->authorize('viewAny', CloudProviderToken::class);
|
||||
$this->loadTokens();
|
||||
}
|
||||
|
||||
public function getListeners()
|
||||
@@ -153,6 +149,8 @@ class CloudProviderTokens extends Component
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.security.cloud-provider-tokens');
|
||||
return view('livewire.security.cloud-provider-tokens', [
|
||||
'tokens' => $this->tokens ?? collect(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@ namespace App\Livewire;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\OauthSetting;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Livewire\Component;
|
||||
use Livewire\Features\SupportRedirects\Redirector;
|
||||
|
||||
class SettingsOauth extends Component
|
||||
{
|
||||
@@ -57,10 +57,10 @@ class SettingsOauth extends Component
|
||||
return $rules;
|
||||
}
|
||||
|
||||
public function mount(?string $provider = null): ?RedirectResponse
|
||||
public function mount(?string $provider = null): ?Redirector
|
||||
{
|
||||
if (! isInstanceAdmin()) {
|
||||
return redirect()->route('home');
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
|
||||
$this->settings = instanceSettings();
|
||||
|
||||
@@ -369,6 +369,19 @@ class StandaloneDragonfly extends BaseModel
|
||||
return $this->morphMany(EnvironmentVariable::class, 'resourceable');
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses the loaded runtime_environment_variables relation when present, so lists that eager
|
||||
* load it do not run a query per database.
|
||||
*/
|
||||
private function runtimeEnvironmentVariable(string $key): ?EnvironmentVariable
|
||||
{
|
||||
if ($this->relationLoaded('runtime_environment_variables')) {
|
||||
return $this->runtime_environment_variables->firstWhere('key', $key);
|
||||
}
|
||||
|
||||
return $this->runtime_environment_variables()->where('key', $key)->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* The REDIS_PASSWORD variable that sets the server password instead of the stored password.
|
||||
* Databases created before this release (legacy_password_quoting) keep their v4.3.23 server
|
||||
@@ -376,7 +389,7 @@ class StandaloneDragonfly extends BaseModel
|
||||
*/
|
||||
public function serverPasswordEnvironmentVariable(): ?EnvironmentVariable
|
||||
{
|
||||
$environmentVariable = $this->runtime_environment_variables()->where('key', 'REDIS_PASSWORD')->first();
|
||||
$environmentVariable = $this->runtimeEnvironmentVariable('REDIS_PASSWORD');
|
||||
|
||||
if (! $environmentVariable) {
|
||||
return null;
|
||||
|
||||
@@ -370,6 +370,19 @@ class StandaloneKeydb extends BaseModel
|
||||
return $this->morphMany(EnvironmentVariable::class, 'resourceable');
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses the loaded runtime_environment_variables relation when present, so lists that eager
|
||||
* load it do not run a query per database.
|
||||
*/
|
||||
private function runtimeEnvironmentVariable(string $key): ?EnvironmentVariable
|
||||
{
|
||||
if ($this->relationLoaded('runtime_environment_variables')) {
|
||||
return $this->runtime_environment_variables->firstWhere('key', $key);
|
||||
}
|
||||
|
||||
return $this->runtime_environment_variables()->where('key', $key)->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* The REDIS_PASSWORD variable that sets the server password instead of the stored password.
|
||||
* Databases created before this release (legacy_password_quoting) keep their v4.3.23 server
|
||||
@@ -377,7 +390,7 @@ class StandaloneKeydb extends BaseModel
|
||||
*/
|
||||
public function serverPasswordEnvironmentVariable(): ?EnvironmentVariable
|
||||
{
|
||||
$environmentVariable = $this->runtime_environment_variables()->where('key', 'REDIS_PASSWORD')->first();
|
||||
$environmentVariable = $this->runtimeEnvironmentVariable('REDIS_PASSWORD');
|
||||
|
||||
if (! $environmentVariable) {
|
||||
return null;
|
||||
|
||||
@@ -384,6 +384,19 @@ class StandaloneRedis extends BaseModel
|
||||
return $this->morphMany(EnvironmentVariable::class, 'resourceable');
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses the loaded runtime_environment_variables relation when present, so lists that eager
|
||||
* load it do not run a query per database.
|
||||
*/
|
||||
private function runtimeEnvironmentVariable(string $key): ?EnvironmentVariable
|
||||
{
|
||||
if ($this->relationLoaded('runtime_environment_variables')) {
|
||||
return $this->runtime_environment_variables->firstWhere('key', $key);
|
||||
}
|
||||
|
||||
return $this->runtime_environment_variables()->where('key', $key)->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* The REDIS_PASSWORD variable whose resolved value sets the server password. Databases created
|
||||
* before this release (legacy_password_quoting) keep their v4.3.23 server password, the stored
|
||||
@@ -391,7 +404,7 @@ class StandaloneRedis extends BaseModel
|
||||
*/
|
||||
public function serverPasswordEnvironmentVariable(): ?EnvironmentVariable
|
||||
{
|
||||
$environmentVariable = $this->runtime_environment_variables()->where('key', 'REDIS_PASSWORD')->first();
|
||||
$environmentVariable = $this->runtimeEnvironmentVariable('REDIS_PASSWORD');
|
||||
|
||||
if (! $environmentVariable) {
|
||||
return null;
|
||||
@@ -444,7 +457,7 @@ class StandaloneRedis extends BaseModel
|
||||
{
|
||||
return new Attribute(
|
||||
get: function () {
|
||||
$password = $this->runtime_environment_variables()->where('key', 'REDIS_PASSWORD')->first();
|
||||
$password = $this->runtimeEnvironmentVariable('REDIS_PASSWORD');
|
||||
if (! $password) {
|
||||
return null;
|
||||
}
|
||||
@@ -459,7 +472,9 @@ class StandaloneRedis extends BaseModel
|
||||
{
|
||||
return new Attribute(
|
||||
get: function () {
|
||||
$username = $this->runtime_environment_variables()->where('key', 'REDIS_USERNAME')->first();
|
||||
// A loaded relation does not hold a variable created by an earlier read, so check the table before creating one.
|
||||
$username = $this->runtimeEnvironmentVariable('REDIS_USERNAME')
|
||||
?? $this->runtime_environment_variables()->where('key', 'REDIS_USERNAME')->first();
|
||||
if (! $username) {
|
||||
$this->runtime_environment_variables()->create([
|
||||
'key' => 'REDIS_USERNAME',
|
||||
|
||||
@@ -1425,7 +1425,7 @@ html[data-theme="custom"] [class~="dark:hover:text-coollabs"]:hover {
|
||||
}
|
||||
|
||||
/* Light custom theme reaches raw neutral utilities too. Dark mode tints
|
||||
everything through the pervasive dark:bg-*/dark:border-* utilities (which
|
||||
everything through the pervasive dark:bg-* and dark:border-* utilities (which
|
||||
resolve to --color-* vars the block overrides); light mode has no equivalent,
|
||||
so components fall back to hardcoded neutrals. Remap the dominant light
|
||||
surface/border utilities to the tinted custom vars so the color reaches the
|
||||
|
||||
@@ -134,7 +134,7 @@
|
||||
<div class="flex items-end gap-2">
|
||||
<x-forms.input id="email" label="Email" readonly />
|
||||
<x-forms.button @click="openEmailModal()" type="button"
|
||||
:disabled="$uses_sso" x-bind:disabled="emailModalOpen || @js($uses_sso)">
|
||||
:disabled="$uses_sso" x-bind:disabled="emailModalOpen || {{ $uses_sso ? 'true' : 'false' }}">
|
||||
Change
|
||||
</x-forms.button>
|
||||
</div>
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
{{-- Open modal immediately (Alpine); decrypt value in a follow-up Livewire request. --}}
|
||||
<x-modal-input title="Edit environment variable" :closeOutside="false" :wireIgnore="false"
|
||||
wireOpen="editorOpen"
|
||||
@environment-variable-updated.window="if ($event.detail.envId === @js($env->id)) modalOpen = false">
|
||||
@environment-variable-updated.window="if ($event.detail.envId === {{ (int) $env->id }}) modalOpen = false">
|
||||
<x-slot:content>
|
||||
<button type="button" wire:click="loadValues" data-env-settings-trigger class="icon-button shrink-0"
|
||||
title="Edit environment variable" aria-label="Edit environment variable">
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Environment;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandaloneDragonfly;
|
||||
use App\Models\StandaloneKeydb;
|
||||
use App\Models\StandaloneRedis;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0], ['is_api_enabled' => true]));
|
||||
Server::flushIdentityMap();
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
$this->team->members()->attach($user->id, ['role' => 'owner']);
|
||||
session(['currentTeam' => $this->team]);
|
||||
$this->bearerToken = $user->createToken('test-token', ['root'])->plainTextToken;
|
||||
|
||||
$server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail();
|
||||
$project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
});
|
||||
|
||||
function createRedisFamilyDatabasesForQueryCount(int $environmentId, StandaloneDocker $destination, int $count): void
|
||||
{
|
||||
foreach (range(1, $count) as $index) {
|
||||
$public = ['is_public' => true, 'public_port' => 30000 + $index];
|
||||
|
||||
create_standalone_redis($environmentId, $destination, $public + ['image' => 'redis:7.2']);
|
||||
|
||||
foreach (['create_standalone_keydb', 'create_standalone_dragonfly'] as $create) {
|
||||
$database = $create($environmentId, $destination, $public);
|
||||
EnvironmentVariable::create([
|
||||
'key' => 'REDIS_PASSWORD',
|
||||
'value' => "variable-password-{$index}",
|
||||
'resourceable_type' => $database->getMorphClass(),
|
||||
'resourceable_id' => $database->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts the queries of one database list request. The REDIS_USERNAME lookup of the StandaloneRedis
|
||||
* "retrieved" hook runs per model before any relation can be eager loaded, so it is not counted.
|
||||
* Pass $onlyTable to count only the queries that read that table.
|
||||
*
|
||||
* @return array{0: int, 1: array<int, array<string, mixed>>}
|
||||
*/
|
||||
function countRedisFamilyDatabaseListQueries(string $bearerToken, string $uri = '/api/v1/databases', ?string $onlyTable = null): array
|
||||
{
|
||||
DB::flushQueryLog();
|
||||
DB::enableQueryLog();
|
||||
|
||||
$response = test()->withHeaders(['Authorization' => 'Bearer '.$bearerToken])
|
||||
->getJson($uri)
|
||||
->assertOk();
|
||||
|
||||
DB::disableQueryLog();
|
||||
|
||||
$queries = collect(DB::getQueryLog())
|
||||
->reject(fn (array $query) => str_contains($query['query'], 'audit_events') || in_array('REDIS_USERNAME', $query['bindings'], true))
|
||||
->filter(fn (array $query) => $onlyTable === null || str_contains($query['query'], "from \"{$onlyTable}\""))
|
||||
->count();
|
||||
|
||||
return [$queries, $response->json()];
|
||||
}
|
||||
|
||||
it('does not query environment variables per Redis, KeyDB and Dragonfly database in the database list', function () {
|
||||
createRedisFamilyDatabasesForQueryCount($this->environment->id, $this->destination, 1);
|
||||
// The first request also authenticates the token; measure warm requests only.
|
||||
countRedisFamilyDatabaseListQueries($this->bearerToken);
|
||||
[$queriesForOneEach] = countRedisFamilyDatabaseListQueries($this->bearerToken);
|
||||
|
||||
createRedisFamilyDatabasesForQueryCount($this->environment->id, $this->destination, 4);
|
||||
[$queriesForFiveEach, $databases] = countRedisFamilyDatabaseListQueries($this->bearerToken);
|
||||
|
||||
expect($databases)->toHaveCount(15)
|
||||
->and($queriesForFiveEach)->toBe($queriesForOneEach);
|
||||
|
||||
$modelsByUuid = collect([StandaloneRedis::class, StandaloneKeydb::class, StandaloneDragonfly::class])
|
||||
->flatMap(fn (string $model) => $model::query()->get())
|
||||
->keyBy('uuid');
|
||||
|
||||
foreach ($databases as $database) {
|
||||
$model = $modelsByUuid[$database['uuid']];
|
||||
|
||||
expect($database)->not->toHaveKey('runtime_environment_variables')
|
||||
->and($database['internal_db_url'])->toBe($model->internal_db_url)
|
||||
->and($database['external_db_url'])->toBe($model->external_db_url)
|
||||
->and($database['internal_db_url'])->toStartWith('redis://')
|
||||
->and($database['external_db_url'])->toStartWith('redis://');
|
||||
|
||||
if (str_starts_with($database['name'], 'redis-database-')) {
|
||||
expect($database['internal_db_url'])->toStartWith('redis://default:'.rawurlencode($model->redis_password).'@');
|
||||
} else {
|
||||
expect($database['internal_db_url'])->toMatch('/^redis:\/\/:variable-password-\d@/');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('does not query environment variables per Redis database in the environment details', function () {
|
||||
$uri = "/api/v1/projects/{$this->environment->project->uuid}/{$this->environment->uuid}";
|
||||
|
||||
createRedisFamilyDatabasesForQueryCount($this->environment->id, $this->destination, 1);
|
||||
countRedisFamilyDatabaseListQueries($this->bearerToken, $uri);
|
||||
// The environment details lazy load each database's destination for external URLs; only variable reads are checked here.
|
||||
[$queriesForOne] = countRedisFamilyDatabaseListQueries($this->bearerToken, $uri, 'environment_variables');
|
||||
|
||||
createRedisFamilyDatabasesForQueryCount($this->environment->id, $this->destination, 4);
|
||||
[$queriesForFive, $environment] = countRedisFamilyDatabaseListQueries($this->bearerToken, $uri, 'environment_variables');
|
||||
|
||||
expect($environment['redis'])->toHaveCount(5)
|
||||
->and($queriesForFive)->toBe($queriesForOne);
|
||||
|
||||
foreach ($environment['redis'] as $database) {
|
||||
$model = StandaloneRedis::query()->where('uuid', $database['uuid'])->firstOrFail();
|
||||
|
||||
expect($database)->not->toHaveKey('runtime_environment_variables')
|
||||
->and($database['internal_db_url'])->toBe($model->internal_db_url)
|
||||
->and($database['external_db_url'])->toBe($model->external_db_url);
|
||||
}
|
||||
});
|
||||
|
||||
it('creates a missing Redis username variable only once when the variables are loaded', function () {
|
||||
$redis = create_standalone_redis($this->environment->id, $this->destination);
|
||||
$redis->runtime_environment_variables()->where('key', 'REDIS_USERNAME')->delete();
|
||||
$redis->load('runtime_environment_variables');
|
||||
|
||||
expect($redis->redis_username)->toBe('default')
|
||||
->and($redis->redis_username)->toBe('default')
|
||||
->and($redis->runtime_environment_variables()->where('key', 'REDIS_USERNAME')->count())->toBe(1);
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\OauthSetting;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Once;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->withoutVite();
|
||||
config()->set('app.maintenance.store', 'array');
|
||||
|
||||
InstanceSettings::forceCreate(['id' => 0]);
|
||||
Once::flush();
|
||||
OauthSetting::create(['provider' => 'authentik']);
|
||||
|
||||
$this->rootTeam = Team::forceCreate(['id' => 0, 'name' => 'Root Team', 'personal_team' => false, 'show_boarding' => false]);
|
||||
});
|
||||
|
||||
function actingAsRootTeamMemberWithRole(string $role): User
|
||||
{
|
||||
$team = Team::find(0);
|
||||
$user = User::factory()->create(['email_verified_at' => now()]);
|
||||
$user->teams()->detach();
|
||||
$user->teams()->attach($team, ['role' => $role]);
|
||||
Team::query()->update(['show_boarding' => false]);
|
||||
Cache::flush();
|
||||
|
||||
session(['currentTeam' => $team]);
|
||||
test()->actingAs($user);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
it('forbids team members from the cloud tokens page', function () {
|
||||
actingAsRootTeamMemberWithRole('member');
|
||||
|
||||
$this->get(route('security.cloud-tokens'))->assertForbidden();
|
||||
});
|
||||
|
||||
it('shows the cloud tokens page to team admins and owners', function (string $role) {
|
||||
actingAsRootTeamMemberWithRole($role);
|
||||
|
||||
$this->get(route('security.cloud-tokens'))
|
||||
->assertSuccessful()
|
||||
->assertSee('No cloud tokens');
|
||||
})->with(['admin', 'owner']);
|
||||
|
||||
it('redirects team members from the oauth settings page to the dashboard', function (string $routeName, array $parameters) {
|
||||
actingAsRootTeamMemberWithRole('member');
|
||||
|
||||
$this->get(route($routeName, $parameters))->assertRedirect(route('dashboard'));
|
||||
})->with([
|
||||
'oauth settings' => ['settings.oauth', []],
|
||||
'oauth provider settings' => ['settings.oauth.provider', ['provider' => 'authentik']],
|
||||
]);
|
||||
|
||||
it('shows the oauth settings page to instance admins', function (string $role) {
|
||||
actingAsRootTeamMemberWithRole($role);
|
||||
|
||||
$this->get(route('settings.oauth'))->assertSuccessful()->assertSee('Authentik');
|
||||
})->with(['admin', 'owner']);
|
||||
Reference in New Issue
Block a user