Merge remote-tracking branch 'origin/main' into next

This commit is contained in:
github-actions[bot]
2026-10-06 05:43:18 +00:00
12 changed files with 284 additions and 20 deletions
@@ -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(),
]);
}
}
+3 -3
View File
@@ -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();
+14 -1
View File
@@ -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;
+14 -1
View File
@@ -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;
+18 -3
View File
@@ -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',
+1 -1
View File
@@ -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']);