Merge remote-tracking branch 'origin/next' into automation/sync-main-to-next

# Conflicts:
#	app/Actions/Database/StartDatabase.php
#	app/Jobs/ApplicationDeploymentJob.php
#	app/Livewire/Project/Shared/EnvironmentVariable/Show.php
#	app/Models/Application.php
#	app/Models/Service.php
#	app/Models/StandaloneClickhouse.php
#	app/Models/StandaloneDragonfly.php
#	app/Models/StandaloneKeydb.php
#	app/Models/StandaloneMariadb.php
#	app/Models/StandaloneMongodb.php
#	app/Models/StandaloneMysql.php
#	app/Models/StandalonePostgresql.php
#	app/Models/StandaloneRedis.php
#	resources/views/livewire/project/application/heading.blade.php
#	resources/views/livewire/project/service/heading.blade.php
#	tests/Feature/PersistentStorageVolumesLayoutTest.php
This commit is contained in:
Andras Bacsai
2026-09-02 21:19:57 +02:00
260 changed files with 11693 additions and 1634 deletions
-7
View File
@@ -1,7 +0,0 @@
# Lessons
## Alpine x-transition + tw-animate-css exit animations flash at the end
- Symptom: a modal/overlay fades out, then flashes fully visible for 1-2 frames before it disappears.
- Cause: `animate-out` keyframes default to `animation-fill-mode: none`. The element snaps back to its natural state when the keyframe ends. Alpine hides the element (display: none) only after its own timer (read from `transition-duration`), which starts ~2 rAF later than the animation. The gap shows the element at full opacity.
- Rule: every `x-transition:leave` that uses tw-animate-css `animate-out` MUST also include `fill-mode-forwards`.
- Rule: when a user reports UI flicker, check ALL layers of the animation stack (state reset timing, spinner flash, keyframe fill mode, focus restore) before you report the fix as complete. My first fix covered state reset and spinner only; the fill-mode snap was the visible one.
+1
View File
@@ -1,6 +1,7 @@
APP_ENV=testing
APP_KEY=base64:8VEfVNVkXQ9mH2L33WBWNMF4eQ0BWD5CTzB8mIxcl+k=
APP_DEBUG=true
APP_MAINTENANCE_DRIVER=file
DB_CONNECTION=testing
+20 -5
View File
@@ -3,12 +3,14 @@
namespace App\Actions\Database;
use App\Models\StandaloneClickhouse;
use App\Traits\ExecutesDatabaseStartCommands;
use Lorisleiva\Actions\Concerns\AsAction;
use Spatie\Activitylog\Models\Activity;
use Symfony\Component\Yaml\Yaml;
class StartClickhouse
{
use AsAction;
use AsAction, ExecutesDatabaseStartCommands;
public StandaloneClickhouse $database;
@@ -16,7 +18,11 @@ class StartClickhouse
public string $configuration_dir;
public function handle(StandaloneClickhouse $database)
private string $resolvedClickhouseUser;
private string $resolvedClickhousePassword;
public function handle(StandaloneClickhouse $database, ?Activity $activity = null)
{
$this->database = $database;
@@ -51,7 +57,7 @@ class StartClickhouse
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => $this->database->healthCheckConfiguration([
'CMD', 'clickhouse-client', '--user', (string) $this->database->clickhouse_admin_user, '--password', (string) $this->database->clickhouse_admin_password, '--query', 'SELECT 1',
'CMD', 'clickhouse-client', '--user', $this->resolvedClickhouseUser, '--password', $this->resolvedClickhousePassword, '--query', 'SELECT 1',
]),
'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap,
@@ -109,7 +115,7 @@ class StartClickhouse
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
return $this->executeDatabaseStartCommands($this->commands, $database, $activity);
}
private function generate_local_persistent_volumes()
@@ -147,8 +153,17 @@ class StartClickhouse
private function generate_environment_variables()
{
$environment_variables = collect();
$this->resolvedClickhouseUser = (string) $this->database->clickhouse_admin_user;
$this->resolvedClickhousePassword = (string) $this->database->clickhouse_admin_password;
foreach ($this->database->runtime_environment_variables as $env) {
$environment_variables->push("$env->key=$env->real_value");
$rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env);
$resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue);
$environment_variables->push($env->key.'='.$resolvedValue);
if ($env->key === 'CLICKHOUSE_USER') {
$this->resolvedClickhouseUser = $rawValue;
} elseif ($env->key === 'CLICKHOUSE_PASSWORD') {
$this->resolvedClickhousePassword = $rawValue;
}
}
if ($environment_variables->filter(fn ($env) => str($env)->contains('CLICKHOUSE_USER'))->isEmpty()) {
+31 -26
View File
@@ -2,6 +2,9 @@
namespace App\Actions\Database;
use App\Enums\ActivityTypes;
use App\Enums\ProcessStatus;
use App\Jobs\DatabaseStartJob;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
@@ -12,6 +15,7 @@ use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use Lorisleiva\Actions\Concerns\AsAction;
use Lorisleiva\Actions\Decorators\JobDecorator;
use Spatie\Activitylog\Models\Activity;
class StartDatabase
{
@@ -22,39 +26,40 @@ class StartDatabase
$job->onQueue(deployment_queue());
}
public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database)
public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database): Activity|string
{
$server = $database->destination->server;
if (! $server->isFunctional()) {
return 'Server is not functional';
}
$database->resetRestartLimit();
switch ($database->getMorphClass()) {
case StandalonePostgresql::class:
$activity = StartPostgresql::run($database);
break;
case StandaloneRedis::class:
$activity = StartRedis::run($database);
break;
case StandaloneMongodb::class:
$activity = StartMongodb::run($database);
break;
case StandaloneMysql::class:
$activity = StartMysql::run($database);
break;
case StandaloneMariadb::class:
$activity = StartMariadb::run($database);
break;
case StandaloneKeydb::class:
$activity = StartKeydb::run($database);
break;
case StandaloneDragonfly::class:
$activity = StartDragonfly::run($database);
break;
case StandaloneClickhouse::class:
$activity = StartClickhouse::run($database);
break;
$activity = activity()
->withProperties([
'server_uuid' => $server->uuid,
'type' => ActivityTypes::INLINE->value,
'type_uuid' => $database->uuid,
'status' => ProcessStatus::QUEUED->value,
'team_id' => $server->team_id,
'operation' => 'database-start',
])
->performedOn($database)
->event(ActivityTypes::INLINE->value)
->log('[]');
if ($activity === null) {
return 'Database start could not be queued because activity logging is disabled.';
}
DatabaseStartJob::dispatch(
$database->getMorphClass(),
(int) $database->getKey(),
(int) $database->team()->id,
(int) $activity->getKey(),
auth()->id(),
);
if ($database->is_public && $database->public_port) {
StartDatabaseProxy::dispatch($database);
}
+17 -6
View File
@@ -5,12 +5,14 @@ namespace App\Actions\Database;
use App\Helpers\SslHelper;
use App\Models\SslCertificate;
use App\Models\StandaloneDragonfly;
use App\Traits\ExecutesDatabaseStartCommands;
use Lorisleiva\Actions\Concerns\AsAction;
use Spatie\Activitylog\Models\Activity;
use Symfony\Component\Yaml\Yaml;
class StartDragonfly
{
use AsAction;
use AsAction, ExecutesDatabaseStartCommands;
public StandaloneDragonfly $database;
@@ -20,7 +22,9 @@ class StartDragonfly
private ?SslCertificate $ssl_certificate = null;
public function handle(StandaloneDragonfly $database)
private string $resolvedRedisPassword;
public function handle(StandaloneDragonfly $database, ?Activity $activity = null)
{
$this->database = $database;
@@ -107,7 +111,7 @@ class StartDragonfly
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => $this->database->healthCheckConfiguration([
'CMD', 'redis-cli', '-a', (string) $this->database->dragonfly_password, 'ping',
'CMD', 'redis-cli', '-a', $this->resolvedRedisPassword, 'ping',
]),
'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap,
@@ -196,12 +200,13 @@ class StartDragonfly
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
return $this->executeDatabaseStartCommands($this->commands, $database, $activity);
}
private function buildStartCommand(): string
{
$command = "dragonfly --requirepass {$this->database->dragonfly_password}";
$escapedRedisPassword = escapeshellarg($this->resolvedRedisPassword);
$command = "dragonfly --requirepass {$escapedRedisPassword}";
if ($this->database->enable_ssl) {
$sslArgs = [
@@ -251,8 +256,14 @@ class StartDragonfly
private function generate_environment_variables()
{
$environment_variables = collect();
$this->resolvedRedisPassword = (string) $this->database->dragonfly_password;
foreach ($this->database->runtime_environment_variables as $env) {
$environment_variables->push("$env->key=$env->real_value");
$rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env);
$resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue);
$environment_variables->push($env->key.'='.$resolvedValue);
if ($env->key === 'REDIS_PASSWORD') {
$this->resolvedRedisPassword = $rawValue;
}
}
if ($environment_variables->filter(fn ($env) => str($env)->contains('REDIS_PASSWORD'))->isEmpty()) {
+18 -7
View File
@@ -5,12 +5,14 @@ namespace App\Actions\Database;
use App\Helpers\SslHelper;
use App\Models\SslCertificate;
use App\Models\StandaloneKeydb;
use App\Traits\ExecutesDatabaseStartCommands;
use Lorisleiva\Actions\Concerns\AsAction;
use Spatie\Activitylog\Models\Activity;
use Symfony\Component\Yaml\Yaml;
class StartKeydb
{
use AsAction;
use AsAction, ExecutesDatabaseStartCommands;
public StandaloneKeydb $database;
@@ -20,7 +22,9 @@ class StartKeydb
private ?SslCertificate $ssl_certificate = null;
public function handle(StandaloneKeydb $database)
private string $resolvedRedisPassword;
public function handle(StandaloneKeydb $database, ?Activity $activity = null)
{
$this->database = $database;
@@ -109,7 +113,7 @@ class StartKeydb
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => $this->database->healthCheckConfiguration([
'CMD', 'keydb-cli', '--pass', (string) $this->database->keydb_password, 'ping',
'CMD', 'keydb-cli', '--pass', $this->resolvedRedisPassword, 'ping',
]),
'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap,
@@ -214,7 +218,7 @@ class StartKeydb
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
return $this->executeDatabaseStartCommands($this->commands, $database, $activity);
}
private function generate_local_persistent_volumes()
@@ -252,8 +256,14 @@ class StartKeydb
private function generate_environment_variables()
{
$environment_variables = collect();
$this->resolvedRedisPassword = (string) $this->database->keydb_password;
foreach ($this->database->runtime_environment_variables as $env) {
$environment_variables->push("$env->key=$env->real_value");
$rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env);
$resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue);
$environment_variables->push($env->key.'='.$resolvedValue);
if ($env->key === 'REDIS_PASSWORD') {
$this->resolvedRedisPassword = $rawValue;
}
}
if ($environment_variables->filter(fn ($env) => str($env)->contains('REDIS_PASSWORD'))->isEmpty()) {
@@ -280,6 +290,7 @@ class StartKeydb
{
$hasKeydbConf = ! is_null($this->database->keydb_conf) && ! empty($this->database->keydb_conf);
$keydbConfPath = '/etc/keydb/keydb.conf';
$escapedRedisPassword = escapeshellarg($this->resolvedRedisPassword);
if ($hasKeydbConf) {
$confContent = $this->database->keydb_conf;
@@ -288,10 +299,10 @@ class StartKeydb
if ($hasRequirePass) {
$command = "keydb-server $keydbConfPath";
} else {
$command = "keydb-server $keydbConfPath --requirepass {$this->database->keydb_password}";
$command = "keydb-server $keydbConfPath --requirepass {$escapedRedisPassword}";
}
} else {
$command = "keydb-server --requirepass {$this->database->keydb_password} --appendonly yes";
$command = "keydb-server --requirepass {$escapedRedisPassword} --appendonly yes";
}
if ($this->database->enable_ssl) {
+6 -4
View File
@@ -5,12 +5,14 @@ namespace App\Actions\Database;
use App\Helpers\SslHelper;
use App\Models\SslCertificate;
use App\Models\StandaloneMariadb;
use App\Traits\ExecutesDatabaseStartCommands;
use Lorisleiva\Actions\Concerns\AsAction;
use Spatie\Activitylog\Models\Activity;
use Symfony\Component\Yaml\Yaml;
class StartMariadb
{
use AsAction;
use AsAction, ExecutesDatabaseStartCommands;
public StandaloneMariadb $database;
@@ -20,7 +22,7 @@ class StartMariadb
private ?SslCertificate $ssl_certificate = null;
public function handle(StandaloneMariadb $database)
public function handle(StandaloneMariadb $database, ?Activity $activity = null)
{
$this->database = $database;
@@ -216,7 +218,7 @@ class StartMariadb
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
return $this->executeDatabaseStartCommands($this->commands, $database, $activity);
}
private function generate_local_persistent_volumes()
@@ -255,7 +257,7 @@ class StartMariadb
{
$environment_variables = collect();
foreach ($this->database->runtime_environment_variables as $env) {
$environment_variables->push("$env->key=$env->real_value");
$environment_variables->push($env->key.'='.$this->database->resolveSecretManagerEnvironmentVariable($env));
}
if ($environment_variables->filter(fn ($env) => str($env)->contains('MARIADB_ROOT_PASSWORD'))->isEmpty()) {
+27 -7
View File
@@ -5,12 +5,14 @@ namespace App\Actions\Database;
use App\Helpers\SslHelper;
use App\Models\SslCertificate;
use App\Models\StandaloneMongodb;
use App\Traits\ExecutesDatabaseStartCommands;
use Lorisleiva\Actions\Concerns\AsAction;
use Spatie\Activitylog\Models\Activity;
use Symfony\Component\Yaml\Yaml;
class StartMongodb
{
use AsAction;
use AsAction, ExecutesDatabaseStartCommands;
public StandaloneMongodb $database;
@@ -20,7 +22,13 @@ class StartMongodb
private ?SslCertificate $ssl_certificate = null;
public function handle(StandaloneMongodb $database)
private string $resolvedMongoUsername;
private string $resolvedMongoPassword;
private string $resolvedMongoDatabase;
public function handle(StandaloneMongodb $database, ?Activity $activity = null)
{
$this->database = $database;
@@ -265,7 +273,7 @@ class StartMongodb
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
return $this->executeDatabaseStartCommands($this->commands, $database, $activity);
}
private function generate_local_persistent_volumes()
@@ -303,8 +311,20 @@ class StartMongodb
private function generate_environment_variables()
{
$environment_variables = collect();
$this->resolvedMongoUsername = (string) $this->database->mongo_initdb_root_username;
$this->resolvedMongoPassword = (string) $this->database->mongo_initdb_root_password;
$this->resolvedMongoDatabase = (string) $this->database->mongo_initdb_database;
foreach ($this->database->runtime_environment_variables as $env) {
$environment_variables->push("$env->key=$env->real_value");
$rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env);
$resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue);
$environment_variables->push($env->key.'='.$resolvedValue);
if ($env->key === 'MONGO_INITDB_ROOT_USERNAME') {
$this->resolvedMongoUsername = $rawValue;
} elseif ($env->key === 'MONGO_INITDB_ROOT_PASSWORD') {
$this->resolvedMongoPassword = $rawValue;
} elseif ($env->key === 'MONGO_INITDB_DATABASE') {
$this->resolvedMongoDatabase = $rawValue;
}
}
if ($environment_variables->filter(fn ($env) => str($env)->contains('MONGO_INITDB_ROOT_USERNAME'))->isEmpty()) {
@@ -337,9 +357,9 @@ class StartMongodb
private function add_default_database()
{
$dbJson = json_encode($this->database->mongo_initdb_database, JSON_UNESCAPED_SLASHES);
$userJson = json_encode($this->database->mongo_initdb_root_username, JSON_UNESCAPED_SLASHES);
$pwdJson = json_encode($this->database->mongo_initdb_root_password, JSON_UNESCAPED_SLASHES);
$dbJson = json_encode($this->resolvedMongoDatabase, JSON_UNESCAPED_SLASHES);
$userJson = json_encode($this->resolvedMongoUsername, JSON_UNESCAPED_SLASHES);
$pwdJson = json_encode($this->resolvedMongoPassword, JSON_UNESCAPED_SLASHES);
$content = "db = db.getSiblingDB({$dbJson});db.createCollection('init_collection');db.createUser({user: {$userJson}, pwd: {$pwdJson}, roles: [{role:\"readWrite\",db:{$dbJson}}]});";
$content_base64 = base64_encode($content);
$this->commands[] = "mkdir -p $this->configuration_dir/docker-entrypoint-initdb.d";
+15 -5
View File
@@ -5,12 +5,14 @@ namespace App\Actions\Database;
use App\Helpers\SslHelper;
use App\Models\SslCertificate;
use App\Models\StandaloneMysql;
use App\Traits\ExecutesDatabaseStartCommands;
use Lorisleiva\Actions\Concerns\AsAction;
use Spatie\Activitylog\Models\Activity;
use Symfony\Component\Yaml\Yaml;
class StartMysql
{
use AsAction;
use AsAction, ExecutesDatabaseStartCommands;
public StandaloneMysql $database;
@@ -20,7 +22,9 @@ class StartMysql
private ?SslCertificate $ssl_certificate = null;
public function handle(StandaloneMysql $database)
private string $resolvedMysqlRootPassword;
public function handle(StandaloneMysql $database, ?Activity $activity = null)
{
$this->database = $database;
@@ -104,7 +108,7 @@ class StartMysql
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => $this->database->healthCheckConfiguration([
'CMD', 'mysqladmin', 'ping', '-h', 'localhost', '-u', 'root', "-p{$this->database->mysql_root_password}",
'CMD', 'mysqladmin', 'ping', '-h', 'localhost', '-u', 'root', "-p{$this->resolvedMysqlRootPassword}",
]),
'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap,
@@ -218,7 +222,7 @@ class StartMysql
$this->commands[] = "echo 'Database started.'";
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
return $this->executeDatabaseStartCommands($this->commands, $database, $activity);
}
private function generate_local_persistent_volumes()
@@ -256,8 +260,14 @@ class StartMysql
private function generate_environment_variables()
{
$environment_variables = collect();
$this->resolvedMysqlRootPassword = (string) $this->database->mysql_root_password;
foreach ($this->database->runtime_environment_variables as $env) {
$environment_variables->push("$env->key=$env->real_value");
$rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env);
$resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue);
$environment_variables->push($env->key.'='.$resolvedValue);
if ($env->key === 'MYSQL_ROOT_PASSWORD') {
$this->resolvedMysqlRootPassword = $rawValue;
}
}
if ($environment_variables->filter(fn ($env) => str($env)->contains('MYSQL_ROOT_PASSWORD'))->isEmpty()) {
+20 -5
View File
@@ -5,12 +5,14 @@ namespace App\Actions\Database;
use App\Helpers\SslHelper;
use App\Models\SslCertificate;
use App\Models\StandalonePostgresql;
use App\Traits\ExecutesDatabaseStartCommands;
use Lorisleiva\Actions\Concerns\AsAction;
use Spatie\Activitylog\Models\Activity;
use Symfony\Component\Yaml\Yaml;
class StartPostgresql
{
use AsAction;
use AsAction, ExecutesDatabaseStartCommands;
public StandalonePostgresql $database;
@@ -22,7 +24,11 @@ class StartPostgresql
private ?SslCertificate $ssl_certificate = null;
public function handle(StandalonePostgresql $database)
private string $resolvedPostgresUser;
private string $resolvedPostgresDatabase;
public function handle(StandalonePostgresql $database, ?Activity $activity = null)
{
$this->database = $database;
$container_name = $this->database->uuid;
@@ -111,7 +117,7 @@ class StartPostgresql
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => $this->database->healthCheckConfiguration([
'CMD', 'psql', '-U', (string) $this->database->postgres_user, '-d', (string) $this->database->postgres_db, '-c', 'SELECT 1',
'CMD', 'psql', '-U', $this->resolvedPostgresUser, '-d', $this->resolvedPostgresDatabase, '-c', 'SELECT 1',
]),
'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap,
@@ -227,7 +233,7 @@ class StartPostgresql
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
return $this->executeDatabaseStartCommands($this->commands, $database, $activity);
}
private function generate_local_persistent_volumes()
@@ -265,8 +271,17 @@ class StartPostgresql
private function generate_environment_variables()
{
$environment_variables = collect();
$this->resolvedPostgresUser = (string) $this->database->postgres_user;
$this->resolvedPostgresDatabase = (string) $this->database->postgres_db;
foreach ($this->database->runtime_environment_variables as $env) {
$environment_variables->push("$env->key=$env->real_value");
$rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env);
$resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue);
$environment_variables->push($env->key.'='.$resolvedValue);
if ($env->key === 'POSTGRES_USER') {
$this->resolvedPostgresUser = $rawValue;
} elseif ($env->key === 'POSTGRES_DB') {
$this->resolvedPostgresDatabase = $rawValue;
}
}
if ($environment_variables->filter(fn ($env) => str($env)->contains('POSTGRES_USER'))->isEmpty()) {
+35 -11
View File
@@ -5,12 +5,14 @@ namespace App\Actions\Database;
use App\Helpers\SslHelper;
use App\Models\SslCertificate;
use App\Models\StandaloneRedis;
use App\Traits\ExecutesDatabaseStartCommands;
use Lorisleiva\Actions\Concerns\AsAction;
use Spatie\Activitylog\Models\Activity;
use Symfony\Component\Yaml\Yaml;
class StartRedis
{
use AsAction;
use AsAction, ExecutesDatabaseStartCommands;
public StandaloneRedis $database;
@@ -20,7 +22,11 @@ class StartRedis
private ?SslCertificate $ssl_certificate = null;
public function handle(StandaloneRedis $database)
private ?string $resolvedRedisPassword = null;
private ?string $resolvedRedisUsername = null;
public function handle(StandaloneRedis $database, ?Activity $activity = null)
{
$this->database = $database;
@@ -209,7 +215,7 @@ class StartRedis
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
return $this->executeDatabaseStartCommands($this->commands, $database, $activity);
}
private function generate_local_persistent_volumes()
@@ -249,23 +255,40 @@ class StartRedis
$environment_variables = collect();
foreach ($this->database->runtime_environment_variables as $env) {
$usesSecretManager = $this->database->environmentVariableUsesSecretManager($env);
if ($env->is_shared) {
$environment_variables->push("$env->key=$env->real_value");
$environment_variables->push($env->key.'='.$this->database->resolveSecretManagerEnvironmentVariable($env));
if ($env->key === 'REDIS_PASSWORD') {
$this->database->update(['redis_password' => $env->real_value]);
$this->resolvedRedisPassword = $this->database->resolveSecretManagerEnvironmentVariableValue($env);
if (! $usesSecretManager) {
$this->database->update(['redis_password' => $this->resolvedRedisPassword]);
}
}
if ($env->key === 'REDIS_USERNAME') {
$this->database->update(['redis_username' => $env->real_value]);
$this->resolvedRedisUsername = $this->database->resolveSecretManagerEnvironmentVariableValue($env);
if (! $usesSecretManager) {
$this->database->update(['redis_username' => $this->resolvedRedisUsername]);
}
}
} else {
if ($env->key === 'REDIS_PASSWORD') {
if ($env->key === 'REDIS_PASSWORD' && ! $usesSecretManager) {
$env->update(['value' => $this->database->redis_password]);
} elseif ($env->key === 'REDIS_USERNAME') {
} elseif ($env->key === 'REDIS_USERNAME' && ! $usesSecretManager) {
$env->update(['value' => $this->database->redis_username]);
}
$environment_variables->push("$env->key=$env->real_value");
if ($env->key === 'REDIS_PASSWORD') {
$this->resolvedRedisPassword = $this->database->resolveSecretManagerEnvironmentVariableValue($env);
} elseif ($env->key === 'REDIS_USERNAME') {
$this->resolvedRedisUsername = $this->database->resolveSecretManagerEnvironmentVariableValue($env);
}
$environment_variables->push($env->key.'='.$this->database->resolveSecretManagerEnvironmentVariable($env));
}
}
@@ -276,6 +299,7 @@ class StartRedis
private function buildStartCommand(): string
{
$redisPassword = $this->resolvedRedisPassword ?? $this->database->redis_password;
$hasRedisConf = ! is_null($this->database->redis_conf) && ! empty($this->database->redis_conf);
$redisConfPath = '/usr/local/etc/redis/redis.conf';
@@ -286,10 +310,10 @@ class StartRedis
if ($hasRequirePass) {
$command = "redis-server $redisConfPath";
} else {
$command = "redis-server $redisConfPath --requirepass {$this->database->redis_password}";
$command = "redis-server $redisConfPath --requirepass {$redisPassword}";
}
} else {
$command = "redis-server --requirepass {$this->database->redis_password} --appendonly yes";
$command = "redis-server --requirepass {$redisPassword} --appendonly yes";
}
if ($this->database->enable_ssl) {
+1 -1
View File
@@ -30,7 +30,7 @@ class CreateNewUser implements CreatesNewUsers
public function create(array $input): User
{
$settings = instanceSettings();
if (! $settings->is_registration_enabled) {
if (! $settings->isPasswordRegistrationAllowed()) {
abort(403);
}
+39 -1
View File
@@ -3,6 +3,7 @@
namespace App\Actions\Server;
use App\Models\Server;
use Illuminate\Support\Facades\Log;
use Lorisleiva\Actions\Concerns\AsAction;
class CheckUpdates
@@ -106,6 +107,15 @@ class CheckUpdates
$out['osId'] = $osId;
$out['package_manager'] = $packageManager;
return $out;
case 'apk':
instant_remote_process(['apk update -q'], $server);
$output = instant_remote_process(['LANG=C apk list --upgradable 2>/dev/null'], $server);
$out = $this->parseApkOutput($output);
$out['osId'] = $osId;
$out['package_manager'] = $packageManager;
return $out;
default:
return [
@@ -266,11 +276,39 @@ class CheckUpdates
// Include unparsed lines in the result for debugging if any exist
if (! empty($unparsedLines)) {
$result['unparsed_lines'] = $unparsedLines;
\Illuminate\Support\Facades\Log::debug('Pacman output contained unparsed lines', [
Log::debug('Pacman output contained unparsed lines', [
'unparsed_lines' => $unparsedLines,
]);
}
return $result;
}
private function parseApkOutput(string $output): array
{
$updates = [];
$lines = explode("\n", $output);
foreach ($lines as $line) {
// Skip empty lines
if (empty($line)) {
continue;
}
// Example line: docker-cli-compose-2.31.0-r5 x86_64 {docker-cli-compose} (Apache-2.0) [upgradable from: docker-cli-compose-2.31.0-r4]
if (preg_match('/^(.+)-([0-9]\S*) (\S+) \{\S+\} \([^)]+\) \[upgradable from: .+?-([0-9][^\]]+)\]$/', $line, $matches)) {
$updates[] = [
'package' => $matches[1],
'new_version' => $matches[2],
'architecture' => $matches[3],
'current_version' => $matches[4],
];
}
}
return [
'total_updates' => count($updates),
'updates' => $updates,
];
}
}
+25 -2
View File
@@ -79,6 +79,8 @@ class InstallDocker
$command = $command->merge([$this->getSuseDockerInstallCommand()]);
} elseif ($supported_os_type->contains('arch')) {
$command = $command->merge([$this->getArchDockerInstallCommand()]);
} elseif ($supported_os_type->contains('alpine')) {
$command = $command->merge([$this->getAlpineDockerInstallCommand()]);
} else {
$command = $command->merge([$this->getGenericDockerInstallCommand()]);
}
@@ -93,9 +95,8 @@ class InstallDocker
"jq -s '.[0] * .[1]' /etc/docker/daemon.json.coolify /etc/docker/daemon.json | tee /etc/docker/daemon.json.appended > /dev/null",
'mv /etc/docker/daemon.json.appended /etc/docker/daemon.json',
"echo 'Restarting Docker Engine...'",
'systemctl enable docker >/dev/null 2>&1 || true',
'systemctl restart docker',
]);
$command = $command->merge($this->getDockerServiceCommands($supported_os_type->contains('alpine')));
if ($server->isSwarm()) {
$command = $command->merge([
'docker network create --attachable --driver overlay coolify-overlay >/dev/null 2>&1 || true',
@@ -154,6 +155,28 @@ class InstallDocker
'systemctl start docker.service';
}
private function getAlpineDockerInstallCommand(): string
{
return 'apk update && '.
'apk add docker docker-cli-buildx docker-cli-compose && '.
'mkdir -p /etc/docker';
}
private function getDockerServiceCommands(bool $usesOpenRc): array
{
if ($usesOpenRc) {
return [
'rc-update add docker default',
'rc-service docker restart',
];
}
return [
'systemctl enable docker >/dev/null 2>&1 || true',
'systemctl restart docker',
];
}
private function getGenericDockerInstallCommand(): string
{
return 'curl -fsSL https://get.docker.com | sh';
@@ -53,6 +53,8 @@ class InstallPrerequisites
"echo 'Installing Prerequisites for Arch Linux...'",
'pacman -Syu --noconfirm --needed curl wget git jq',
]);
} elseif ($supported_os_type->contains('alpine')) {
$command = $command->merge($this->getAlpinePrerequisiteCommands());
} else {
throw new \Exception('Unsupported OS type for prerequisites installation');
}
@@ -61,4 +63,18 @@ class InstallPrerequisites
return remote_process($command, $server);
}
private function getAlpinePrerequisiteCommands(): array
{
return [
"echo 'Installing Prerequisites for Alpine Linux...'",
"sed -i '/^#.*\\/community/s/^#//' /etc/apk/repositories 2>/dev/null || true",
'apk update',
'command -v bash >/dev/null || apk add bash',
'command -v curl >/dev/null || apk add curl',
'command -v wget >/dev/null || apk add wget',
'command -v git >/dev/null || apk add git',
'command -v jq >/dev/null || apk add jq',
];
}
}
+4
View File
@@ -58,6 +58,10 @@ class UpdatePackage
$commandAll = 'pacman -Syu --noconfirm';
$commandInstall = 'pacman -S --noconfirm '.$sanitizedPackage;
break;
case 'apk':
$commandAll = 'apk update && apk upgrade';
$commandInstall = 'apk upgrade '.$sanitizedPackage;
break;
default:
return [
'error' => 'OS not supported',
@@ -0,0 +1,5 @@
<?php
namespace App\Auth\Oidc\Exceptions;
class OidcDiscoveryException extends OidcException {}
@@ -0,0 +1,7 @@
<?php
namespace App\Auth\Oidc\Exceptions;
use RuntimeException;
class OidcException extends RuntimeException {}
@@ -0,0 +1,5 @@
<?php
namespace App\Auth\Oidc\Exceptions;
class OidcJwksException extends OidcException {}
@@ -0,0 +1,5 @@
<?php
namespace App\Auth\Oidc\Exceptions;
class OidcSigningKeyNotFoundException extends OidcTokenException {}
@@ -0,0 +1,5 @@
<?php
namespace App\Auth\Oidc\Exceptions;
class OidcTokenException extends OidcException {}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace App\Auth\Oidc;
use App\Models\OauthSetting;
final readonly class OidcConfig
{
/**
* @param array<int, string> $scopes
*/
public function __construct(
public string $issuerUrl,
public string $clientId,
public string $clientSecret,
public string $redirectUri,
public array $scopes = ['openid', 'email', 'profile'],
public bool $usePkce = true,
public int $clockSkewSeconds = 60,
) {}
public static function fromOauthSetting(OauthSetting $setting): self
{
return new self(
issuerUrl: rtrim((string) $setting->base_url, '/'),
clientId: (string) $setting->client_id,
clientSecret: (string) $setting->client_secret,
redirectUri: filled($setting->redirect_uri) ? $setting->redirect_uri : route('auth.callback', 'oidc'),
scopes: $setting->scopeList(),
usePkce: $setting->use_pkce ?? true,
clockSkewSeconds: $setting->clock_skew_seconds ?? 60,
);
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace App\Auth\Oidc;
use App\Auth\Oidc\Exceptions\OidcDiscoveryException;
final readonly class OidcDiscoveryDocument
{
/**
* @param array<int, string> $supportedScopes
* @param array<int, string> $supportedClaims
* @param array<int, string> $idTokenSigningAlgValuesSupported
*/
public function __construct(
public string $issuer,
public string $authorizationEndpoint,
public string $tokenEndpoint,
public string $userinfoEndpoint,
public string $jwksUri,
public ?string $endSessionEndpoint = null,
public array $supportedScopes = [],
public array $supportedClaims = [],
public array $idTokenSigningAlgValuesSupported = [],
) {}
/**
* @param array<string, mixed> $payload
*/
public static function fromArray(array $payload): self
{
foreach (['issuer', 'authorization_endpoint', 'token_endpoint', 'userinfo_endpoint', 'jwks_uri'] as $field) {
if (! is_string($payload[$field] ?? null) || trim($payload[$field]) === '') {
throw new OidcDiscoveryException("Discovery document is missing required field: {$field}");
}
}
return new self(
issuer: $payload['issuer'],
authorizationEndpoint: $payload['authorization_endpoint'],
tokenEndpoint: $payload['token_endpoint'],
userinfoEndpoint: $payload['userinfo_endpoint'],
jwksUri: $payload['jwks_uri'],
endSessionEndpoint: is_string($payload['end_session_endpoint'] ?? null) ? $payload['end_session_endpoint'] : null,
supportedScopes: self::stringList($payload['scopes_supported'] ?? []),
supportedClaims: self::stringList($payload['claims_supported'] ?? []),
idTokenSigningAlgValuesSupported: self::stringList($payload['id_token_signing_alg_values_supported'] ?? []),
);
}
/**
* @return array<int, string>
*/
private static function stringList(mixed $value): array
{
if (! is_array($value)) {
return [];
}
return array_values(array_map('strval', $value));
}
}
+97
View File
@@ -0,0 +1,97 @@
<?php
namespace App\Auth\Oidc;
use App\Auth\Oidc\Exceptions\OidcDiscoveryException;
use App\Auth\Oidc\Exceptions\OidcJwksException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Throwable;
class OidcDiscoveryService
{
public function discover(string $issuerUrl): OidcDiscoveryDocument
{
$this->assertHttpsUrl($issuerUrl, new OidcDiscoveryException('Issuer URL must be an absolute HTTPS URL.'));
$issuerUrl = rtrim($issuerUrl, '/');
$cacheKey = 'oidc:discovery:'.hash('sha256', $issuerUrl);
return Cache::remember($cacheKey, 3600, function () use ($issuerUrl): OidcDiscoveryDocument {
$url = $issuerUrl.'/.well-known/openid-configuration';
try {
$response = Http::timeout(5)->connectTimeout(3)->acceptJson()->get($url);
} catch (Throwable $e) {
throw new OidcDiscoveryException("Failed to fetch discovery document: {$e->getMessage()}", previous: $e);
}
if ($response->failed()) {
throw new OidcDiscoveryException("Discovery endpoint returned HTTP {$response->status()}");
}
$json = $response->json();
if (! is_array($json) || $json === []) {
throw new OidcDiscoveryException('Discovery endpoint returned invalid JSON.');
}
$discovery = OidcDiscoveryDocument::fromArray($json);
if (rtrim($discovery->issuer, '/') !== $issuerUrl) {
throw new OidcDiscoveryException('Discovery issuer does not match the configured issuer URL.');
}
return $discovery;
});
}
/**
* Fetch the JWKS for the given URI.
*
* When $forceRefresh is true the cached document is bypassed so freshly
* rotated signing keys become visible immediately. A short cooldown still
* prevents a flood of upstream requests if many logins miss the same kid.
*
* @return array<string, mixed>
*/
public function jwks(string $jwksUri, bool $forceRefresh = false): array
{
$this->assertHttpsUrl($jwksUri, new OidcJwksException('JWKS URI must be an absolute HTTPS URL.'));
$cacheKey = 'oidc:jwks:'.hash('sha256', $jwksUri);
if ($forceRefresh) {
$cooldownKey = $cacheKey.':refresh';
if (Cache::add($cooldownKey, true, 60)) {
Cache::forget($cacheKey);
}
}
return Cache::remember($cacheKey, 21600, function () use ($jwksUri): array {
try {
$response = Http::timeout(5)->connectTimeout(3)->acceptJson()->get($jwksUri);
} catch (Throwable $e) {
throw new OidcJwksException("Failed to fetch JWKS: {$e->getMessage()}", previous: $e);
}
if ($response->failed()) {
throw new OidcJwksException("JWKS endpoint returned HTTP {$response->status()}");
}
$json = $response->json();
if (! is_array($json) || ! is_array($json['keys'] ?? null)) {
throw new OidcJwksException("JWKS endpoint returned an invalid payload without 'keys'.");
}
return $json;
});
}
private function assertHttpsUrl(string $url, Throwable $exception): void
{
$parts = parse_url($url);
if (($parts['scheme'] ?? null) !== 'https' || ! is_string($parts['host'] ?? null) || $parts['host'] === '') {
throw $exception;
}
}
}
+199
View File
@@ -0,0 +1,199 @@
<?php
namespace App\Auth\Oidc;
use App\Auth\Oidc\Exceptions\OidcSigningKeyNotFoundException;
use App\Auth\Oidc\Exceptions\OidcTokenException;
use Firebase\JWT\JWK;
use Firebase\JWT\JWT;
use Throwable;
class OidcTokenValidator
{
/**
* Algorithms we accept for id_token signatures. RS256 only — this is the
* OIDC baseline and a strict allowlist prevents algorithm-confusion and
* "none" attacks.
*/
private const ALLOWED_ALGORITHM = 'RS256';
/**
* @param array<string, mixed> $jwks
* @return array<string, mixed>
*/
public function validate(
string $idToken,
OidcDiscoveryDocument $discovery,
array $jwks,
string $clientId,
?string $expectedNonce = null,
int $clockSkewSeconds = 60,
): array {
$kid = $this->extractKid($idToken);
try {
$keys = JWK::parseKeySet($this->signingKeysOnly($jwks), self::ALLOWED_ALGORITHM);
} catch (Throwable $e) {
throw new OidcTokenException("Unable to parse JWKS: {$e->getMessage()}", previous: $e);
}
// Surface an unknown signing key distinctly so the caller can refresh
// the JWKS once (key rotation) before giving up.
if (! array_key_exists($kid, $keys)) {
throw new OidcSigningKeyNotFoundException('No matching JWKS key found for id_token kid.');
}
$previousLeeway = JWT::$leeway;
JWT::$leeway = $clockSkewSeconds;
try {
// Validates signature, header alg against the key alg (RS256),
// exp, nbf and iat. Throws on any failure.
$claims = (array) JWT::decode($idToken, $keys);
} catch (OidcTokenException $e) {
throw $e;
} catch (Throwable $e) {
throw new OidcTokenException("id_token validation failed: {$e->getMessage()}", previous: $e);
} finally {
JWT::$leeway = $previousLeeway;
}
$this->assertExpiry($claims);
$this->assertIssuer($claims, $discovery->issuer);
$this->assertAudience($claims, $clientId);
$this->assertNonce($claims, $expectedNonce);
$this->assertSubject($claims);
return $claims;
}
/**
* Drop JWKS entries explicitly marked for anything other than signing
* (e.g. "use":"enc") so they can never verify an id_token signature.
* firebase/php-jwt does not honour the "use" parameter on its own.
*
* @param array<string, mixed> $jwks
* @return array<string, mixed>
*/
private function signingKeysOnly(array $jwks): array
{
$keys = array_values(array_filter(
$jwks['keys'] ?? [],
fn ($jwk): bool => is_array($jwk) && (! isset($jwk['use']) || $jwk['use'] === 'sig'),
));
return ['keys' => $keys];
}
/**
* Decode just the JWT header to read the kid before signature
* verification, so an unknown key can be reported as a rotation miss.
*/
private function extractKid(string $idToken): string
{
$segments = explode('.', $idToken);
if (count($segments) !== 3) {
throw new OidcTokenException('Malformed id_token.');
}
$header = json_decode($this->base64UrlDecode($segments[0]), true);
if (! is_array($header)) {
throw new OidcTokenException('id_token header contains invalid JSON.');
}
if (($header['alg'] ?? null) !== self::ALLOWED_ALGORITHM) {
throw new OidcTokenException('id_token uses a disallowed algorithm.');
}
$kid = $header['kid'] ?? null;
if (! is_string($kid) || $kid === '') {
throw new OidcTokenException('id_token header is missing kid.');
}
return $kid;
}
private function base64UrlDecode(string $value): string
{
$remainder = strlen($value) % 4;
if ($remainder !== 0) {
$value .= str_repeat('=', 4 - $remainder);
}
$decoded = base64_decode(strtr($value, '-_', '+/'), true);
if ($decoded === false) {
throw new OidcTokenException('Invalid base64url value in id_token header.');
}
return $decoded;
}
/**
* @param array<string, mixed> $claims
*/
private function assertExpiry(array $claims): void
{
// Firebase enforces the exp window when present; OIDC requires it to exist.
if (! is_numeric($claims['exp'] ?? null)) {
throw new OidcTokenException('id_token is missing the exp claim.');
}
}
/**
* @param array<string, mixed> $claims
*/
private function assertSubject(array $claims): void
{
$subject = $claims['sub'] ?? null;
if (! is_string($subject) || $subject === '') {
throw new OidcTokenException('id_token subject is missing or invalid.');
}
}
/**
* @param array<string, mixed> $claims
*/
private function assertIssuer(array $claims, string $expectedIssuer): void
{
if (($claims['iss'] ?? null) !== $expectedIssuer) {
throw new OidcTokenException('id_token issuer does not match discovery issuer.');
}
}
/**
* @param array<string, mixed> $claims
*/
private function assertAudience(array $claims, string $clientId): void
{
$audience = $claims['aud'] ?? null;
if (is_string($audience)) {
$audience = [$audience];
}
if (! is_array($audience) || ! in_array($clientId, $audience, true)) {
throw new OidcTokenException('id_token audience does not include configured client id.');
}
if (count($audience) > 1 && (! isset($claims['azp']) || $claims['azp'] !== $clientId)) {
throw new OidcTokenException('id_token azp is required when aud contains multiple values and must match configured client id.');
}
if (isset($claims['azp']) && $claims['azp'] !== $clientId) {
throw new OidcTokenException('id_token azp does not match configured client id.');
}
}
/**
* @param array<string, mixed> $claims
*/
private function assertNonce(array $claims, ?string $expectedNonce): void
{
if ($expectedNonce === null) {
return;
}
if (($claims['nonce'] ?? null) !== $expectedNonce) {
throw new OidcTokenException('id_token nonce does not match.');
}
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Auth\Oidc;
use Laravel\Socialite\Two\User as SocialiteUser;
class OidcUser extends SocialiteUser
{
public ?string $issuer = null;
public ?string $subject = null;
public bool $emailVerified = false;
/**
* @var array<string, mixed>
*/
public array $idTokenClaims = [];
/**
* @param array<string, mixed> $claims
*/
public function setIdTokenClaims(array $claims): self
{
$this->idTokenClaims = $claims;
$this->issuer = is_string($claims['iss'] ?? null) ? $claims['iss'] : null;
$this->subject = is_string($claims['sub'] ?? null) ? $claims['sub'] : null;
$this->emailVerified = ($claims['email_verified'] ?? false) === true;
return $this;
}
}
+299
View File
@@ -0,0 +1,299 @@
<?php
namespace App\Auth\Oidc\Socialite;
use App\Auth\Oidc\Exceptions\OidcException;
use App\Auth\Oidc\Exceptions\OidcSigningKeyNotFoundException;
use App\Auth\Oidc\OidcConfig;
use App\Auth\Oidc\OidcDiscoveryDocument;
use App\Auth\Oidc\OidcDiscoveryService;
use App\Auth\Oidc\OidcTokenValidator;
use App\Auth\Oidc\OidcUser;
use GuzzleHttp\RequestOptions;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Laravel\Socialite\Two\AbstractProvider;
use Laravel\Socialite\Two\InvalidStateException;
use Laravel\Socialite\Two\ProviderInterface;
class OidcProvider extends AbstractProvider implements ProviderInterface
{
private const int OIDC_FLOW_TTL_MINUTES = 10;
/**
* @var array<int, string>
*/
protected $scopes = ['openid', 'email', 'profile'];
protected $scopeSeparator = ' ';
protected ?OidcConfig $oidcConfig = null;
protected ?OidcDiscoveryDocument $discovery = null;
public function __construct(
Request $request,
protected OidcDiscoveryService $discoveryService,
protected OidcTokenValidator $tokenValidator,
string $clientId,
string $clientSecret,
string $redirectUrl,
) {
parent::__construct($request, $clientId, $clientSecret, $redirectUrl);
}
public function setConfig(OidcConfig $config): self
{
$this->oidcConfig = $config;
$this->clientId = $config->clientId;
$this->clientSecret = $config->clientSecret;
$this->redirectUrl = $config->redirectUri;
$this->scopes = $config->scopes;
$this->discovery = null;
return $this;
}
public function getConfig(): OidcConfig
{
if ($this->oidcConfig === null) {
throw new OidcException('OIDC provider config is not set.');
}
return $this->oidcConfig;
}
protected function getAuthUrl($state): string
{
$config = $this->getConfig();
$nonce = Str::random(40);
$this->putOidcFlowValue($this->nonceSessionKey($state), $nonce);
$extra = ['nonce' => $nonce];
if ($config->usePkce) {
$verifier = $this->generateCodeVerifier();
$this->putOidcFlowValue($this->verifierSessionKey($state), $verifier);
$extra['code_challenge'] = $this->codeChallenge($verifier);
$extra['code_challenge_method'] = 'S256';
}
return $this->buildAuthUrlFromBase($this->resolveDiscovery()->authorizationEndpoint, $state)
.'&'.http_build_query($extra, '', '&', $this->encodingType);
}
protected function getTokenUrl(): string
{
return $this->resolveDiscovery()->tokenEndpoint;
}
/**
* @return array<string, mixed>
*/
protected function getUserByToken($token): array
{
$response = $this->getHttpClient()->get($this->resolveDiscovery()->userinfoEndpoint, [
RequestOptions::HEADERS => [
'Accept' => 'application/json',
'Authorization' => 'Bearer '.$token,
],
RequestOptions::CONNECT_TIMEOUT => 5,
RequestOptions::TIMEOUT => 10,
]);
$decoded = json_decode((string) $response->getBody(), true);
return is_array($decoded) ? $decoded : [];
}
/**
* @param array<string, mixed> $user
*/
protected function mapUserToObject(array $user)
{
return (new OidcUser)->setRaw($user)->map([
'id' => $user['sub'] ?? null,
'nickname' => $user['preferred_username'] ?? null,
'name' => $this->resolveName($user),
'email' => $user['email'] ?? null,
'avatar' => $user['picture'] ?? null,
]);
}
public function user()
{
if ($this->user) {
return $this->user;
}
if ($this->hasInvalidState()) {
throw new InvalidStateException;
}
$tokenResponse = $this->getAccessTokenResponse($this->getCode());
$accessToken = Arr::get($tokenResponse, 'access_token');
$idToken = Arr::get($tokenResponse, 'id_token');
if (! is_string($accessToken) || $accessToken === '' || ! is_string($idToken) || $idToken === '') {
throw new OidcException('OIDC token endpoint did not return required tokens.');
}
$discovery = $this->resolveDiscovery();
$config = $this->getConfig();
$expectedNonce = $this->pullOidcFlowValue($this->nonceSessionKey((string) $this->request->input('state')));
if ($expectedNonce === null) {
throw new OidcException('OIDC login session expired. Please try again.');
}
$claims = $this->validateIdToken($idToken, $discovery, $config, $expectedNonce);
$userinfo = $this->getUserByToken($accessToken);
// OIDC core §5.3.2: the userinfo sub MUST match the id_token sub.
// Reject the response rather than trust unsigned userinfo claims.
$userinfoSub = $userinfo['sub'] ?? null;
if (is_string($userinfoSub) && $userinfoSub !== '' && $userinfoSub !== ($claims['sub'] ?? null)) {
throw new OidcException('OIDC userinfo subject does not match the id_token subject.');
}
$merged = array_merge($userinfo, $claims);
/** @var OidcUser $user */
$user = $this->mapUserToObject($merged);
$user->setIdTokenClaims($claims)
->setToken($accessToken)
->setRefreshToken(Arr::get($tokenResponse, 'refresh_token'))
->setExpiresIn(Arr::get($tokenResponse, 'expires_in'));
return $this->user = $user;
}
/**
* Validate the id_token, retrying once against a freshly fetched JWKS when
* the signing key is unknown. This keeps logins working immediately after
* the IdP rotates keys instead of failing until the JWKS cache expires.
*
* @return array<string, mixed>
*/
protected function validateIdToken(
string $idToken,
OidcDiscoveryDocument $discovery,
OidcConfig $config,
?string $expectedNonce,
): array {
foreach ([false, true] as $forceRefresh) {
try {
return $this->tokenValidator->validate(
idToken: $idToken,
discovery: $discovery,
jwks: $this->discoveryService->jwks($discovery->jwksUri, $forceRefresh),
clientId: $config->clientId,
expectedNonce: $expectedNonce,
clockSkewSeconds: $config->clockSkewSeconds,
);
} catch (OidcSigningKeyNotFoundException $e) {
if ($forceRefresh) {
throw $e;
}
}
}
throw new OidcSigningKeyNotFoundException('No matching JWKS key found for id_token kid.');
}
/**
* @return array<string, mixed>
*/
public function getAccessTokenResponse($code)
{
$fields = $this->getTokenFields($code);
if ($this->getConfig()->usePkce) {
$verifier = $this->pullOidcFlowValue($this->verifierSessionKey((string) $this->request->input('state')));
if ($verifier === null) {
throw new OidcException('OIDC login session expired. Please try again.');
}
$fields['code_verifier'] = $verifier;
}
$response = $this->getHttpClient()->post($this->getTokenUrl(), [
RequestOptions::HEADERS => ['Accept' => 'application/json'],
RequestOptions::FORM_PARAMS => $fields,
RequestOptions::CONNECT_TIMEOUT => 5,
RequestOptions::TIMEOUT => 10,
]);
$decoded = json_decode((string) $response->getBody(), true);
return is_array($decoded) ? $decoded : [];
}
protected function resolveDiscovery(): OidcDiscoveryDocument
{
return $this->discovery ??= $this->discoveryService->discover($this->getConfig()->issuerUrl);
}
protected function generateCodeVerifier(): string
{
return rtrim(strtr(base64_encode(random_bytes(64)), '+/', '-_'), '=');
}
protected function codeChallenge(string $verifier): string
{
return rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
}
/**
* @param array<string, mixed> $user
*/
protected function resolveName(array $user): ?string
{
if (is_string($user['name'] ?? null) && $user['name'] !== '') {
return $user['name'];
}
$name = trim(((string) ($user['given_name'] ?? '')).' '.((string) ($user['family_name'] ?? '')));
return $name === '' ? null : $name;
}
protected function putOidcFlowValue(string $key, string $value): void
{
$this->request->session()->put($key, [
'value' => $value,
'expires_at' => now()->addMinutes(self::OIDC_FLOW_TTL_MINUTES)->timestamp,
]);
}
protected function pullOidcFlowValue(string $key): ?string
{
$entry = $this->request->session()->pull($key);
if (! is_array($entry)) {
return null;
}
$value = $entry['value'] ?? null;
$expiresAt = $entry['expires_at'] ?? null;
if (! is_string($value) || $value === '' || ! is_int($expiresAt)) {
return null;
}
if ($expiresAt < now()->timestamp) {
return null;
}
return $value;
}
protected function nonceSessionKey(string $state): string
{
return "oidc.nonce.{$state}";
}
protected function verifierSessionKey(string $state): string
{
return "oidc.code_verifier.{$state}";
}
}
+7
View File
@@ -2,6 +2,7 @@
namespace App\Console\Commands;
use App\Models\AuditEvent;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
@@ -49,6 +50,12 @@ class CleanupDatabase extends Command
$activity_log->delete();
}
$count = DB::table('audit_events')->where('created_at', '<', now()->subDays(90))->count();
echo "Delete $count entries from audit_events.\n";
if ($this->option('yes')) {
AuditEvent::pruneExpired();
}
// Cleanup application_deployment_queues table
$application_deployment_queues = DB::table('application_deployment_queues')->where('created_at', '<', now()->subDays($keep_days))->orderBy('created_at', 'desc')->skip(10);
$count = $application_deployment_queues->count();
+7 -1
View File
@@ -243,12 +243,18 @@ class SshMultiplexingHelper
$delimiter = base64_encode(Hash::make($command));
$command = str_replace($delimiter, '', $command);
$remoteShellCommand = self::remoteShellCommand();
return $sshCommand.self::escapedUserAtHost($server)." 'bash -se' << \\$delimiter".PHP_EOL
return $sshCommand.self::escapedUserAtHost($server)." '{$remoteShellCommand}' << \\$delimiter".PHP_EOL
.$command.PHP_EOL
.$delimiter;
}
private static function remoteShellCommand(): string
{
return 'if command -v bash >/dev/null 2>&1; then exec bash -se; else exec sh -se; fi';
}
public static function getConnectionTimeout(Server $server): int
{
$timeout = data_get($server, 'settings.connection_timeout');
@@ -0,0 +1,123 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Application;
use App\Models\IntegrationToken;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
class ApplicationSecretManagerController extends Controller
{
#[OA\Patch(
summary: 'Configure Application Secret Manager',
description: 'Configure the secret manager source used by an application.',
path: '/applications/{uuid}/secret-manager',
operationId: 'configure-application-secret-manager',
security: [['bearerAuth' => []]],
tags: ['Secret Managers'],
parameters: [new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string'))],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['integration_token_uuid'],
properties: [
new OA\Property(property: 'integration_token_uuid', type: 'string'),
new OA\Property(property: 'settings', type: 'object'),
],
),
),
responses: [
new OA\Response(response: 200, description: 'Secret manager configured.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
],
)]
public function update(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
return $return;
}
$application = Application::ownedByCurrentTeamAPI($teamId)
->where('uuid', $request->route('uuid'))
->first();
if (! $application) {
return response()->json(['message' => 'Application not found.'], 404);
}
$this->authorize('update', $application);
$body = $request->json()->all();
$token = IntegrationToken::query()
->where('team_id', $teamId)
->where('uuid', $body['integration_token_uuid'] ?? '')
->whereIn('provider', IntegrationToken::SECRET_MANAGER_PROVIDERS)
->first();
if (! $token || ! in_array('secrets', $token->capabilities ?? [], true)) {
return response()->json(['message' => 'Secret manager integration token not found.'], 404);
}
$rules = [
'integration_token_uuid' => ['required', 'string'],
'settings' => ['sometimes', 'array'],
];
$rules += match ($token->provider) {
'doppler' => $token->dopplerTokenType() === 'service_account' ? [
'settings.project' => ['required', 'string'],
'settings.config' => ['required', 'string'],
] : [],
'infisical' => [
'settings.project_id' => ['required', 'string'],
'settings.environment' => ['required', 'string'],
'settings.secret_path' => ['nullable', 'string'],
],
'vault' => [
'settings.mount' => ['required', 'string'],
'settings.path' => ['required', 'string'],
],
default => [],
};
$validator = customApiValidator($body, $rules);
$extraFields = array_diff(array_keys($body), ['integration_token_uuid', 'settings']);
if ($validator->fails() || $extraFields !== []) {
$errors = $validator->errors();
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422);
}
$settings = array_filter($validator->validated()['settings'] ?? [], fn ($value) => filled($value));
$application->secretManagerLink()->updateOrCreate([], [
'integration_token_id' => $token->id,
'settings' => $settings ?: null,
]);
auditLog('api.application.secret_manager.updated', [
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'integration_token_uuid' => $token->uuid,
]);
return response()->json([
'integration_token_uuid' => $token->uuid,
'provider' => $token->provider,
'settings' => $settings ?: null,
]);
}
}
@@ -3381,7 +3381,7 @@ class ApplicationsController extends Controller
if ($application->settings->is_container_label_readonly_enabled && ($requestHasDomains || $requestHasNoindexDomains || $requestHasHttpBasicAuth) && $server->isProxyShouldRun()) {
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
}
$application->save();
$application->withoutAuditLogging(fn () => $application->save());
auditLog('api.application.updated', [
'team_id' => $teamId,
@@ -5889,14 +5889,6 @@ class ApplicationsController extends Controller
return response()->json(['message' => $result['message']], 200);
}
auditLog('api.application.rollback', [
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'deployment_uuid' => $deployment_uuid,
'commit' => $commit,
]);
return response()->json([
'message' => 'Rollback deployment queued.',
'deployment_uuid' => $deployment_uuid,
@@ -0,0 +1,80 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\AuditEvent;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
class AuditEventsController extends Controller
{
public function index(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
if (! $request->user()->isAdminOfTeam($teamId)) {
return response()->json(['message' => 'Only team admins and owners can view audit logs.'], 403);
}
$validator = Validator::make($request->all(), [
'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
'page' => ['sometimes', 'integer', 'min:1'],
'search' => ['sometimes', 'nullable', 'string', 'max:255'],
'action' => ['sometimes', 'nullable', 'string', 'max:255'],
'source' => ['sometimes', 'nullable', 'string', Rule::in(['all', 'ui', 'api', 'mcp', 'webhook', 'system', 'scheduler'])],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$validated = $validator->validated();
$perPage = (int) ($validated['per_page'] ?? 25);
$search = trim((string) ($validated['search'] ?? ''));
$canReadSensitive = $request->attributes->get('can_read_sensitive', false) === true;
$events = AuditEvent::query()
->select([
'id',
'team_id',
'event',
'source',
'action',
'actor_type',
'actor_id',
'actor_name',
'resource_type',
'resource_uuid',
'resource_name',
'description',
'created_at',
])
->when($canReadSensitive, fn ($query) => $query->addSelect([
'actor_email',
'actor_token_id',
'actor_token_name',
'metadata',
'ip_address',
'user_agent',
]))
->visibleToTeam($teamId)
->filtered(
search: $search,
action: (string) ($validated['action'] ?? 'all'),
source: (string) ($validated['source'] ?? 'all'),
searchSensitiveFields: $canReadSensitive,
)
->latestFirst()
->paginate($perPage);
return response()->json(serializeApiResponse($events));
}
}
@@ -0,0 +1,108 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\IntegrationToken;
use App\Services\IntegrationTokenValidator;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
class IntegrationTokensController extends Controller
{
#[OA\Post(
summary: 'Create Secret Manager Token',
description: 'Create and validate a Doppler, Infisical, or Vault integration token.',
path: '/security/integration-tokens',
operationId: 'create-secret-manager-integration-token',
security: [['bearerAuth' => []]],
tags: ['Secret Managers'],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['provider', 'name', 'token'],
properties: [
new OA\Property(property: 'provider', type: 'string', enum: ['doppler', 'infisical', 'vault']),
new OA\Property(property: 'name', type: 'string'),
new OA\Property(property: 'token', type: 'string'),
new OA\Property(property: 'metadata', type: 'object'),
],
),
),
responses: [
new OA\Response(response: 201, description: 'Integration token created.'),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
],
)]
public function store(Request $request, IntegrationTokenValidator $tokenValidator): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$this->authorize('create', IntegrationToken::class);
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
return $return;
}
$body = $request->json()->all();
$rules = [
'provider' => ['required', 'string', 'in:'.implode(',', IntegrationToken::SECRET_MANAGER_PROVIDERS)],
'name' => ['required', 'string', 'max:255'],
'token' => ['required', 'string'],
'metadata' => ['sometimes', 'array'],
];
if (($body['provider'] ?? null) === 'doppler') {
$rules['token'][] = 'regex:/^dp\.(st|sa)\./';
} elseif (($body['provider'] ?? null) === 'infisical') {
$rules['metadata.base_url'] = ['required', 'url:http,https'];
$rules['metadata.client_id'] = ['required', 'string'];
} elseif (($body['provider'] ?? null) === 'vault') {
$rules['metadata.base_url'] = ['required', 'url:http,https'];
$rules['metadata.namespace'] = ['nullable', 'string'];
}
$validator = customApiValidator($body, $rules);
$extraFields = array_diff(array_keys($body), ['provider', 'name', 'token', 'metadata']);
if ($validator->fails() || $extraFields !== []) {
$errors = $validator->errors();
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422);
}
$validated = $validator->validated();
$metadata = array_filter($validated['metadata'] ?? [], fn ($value) => filled($value));
if (! $tokenValidator->validate($validated['provider'], $validated['token'], ['secrets'], $metadata)) {
return response()->json(['message' => $tokenValidator->errorMessage($validated['provider'])], 400);
}
$integrationToken = IntegrationToken::query()->create([
'team_id' => $teamId,
'provider' => $validated['provider'],
'name' => $validated['name'],
'token' => $validated['token'],
'capabilities' => ['secrets'],
'metadata' => $metadata ?: null,
]);
auditLog('api.integration_token.created', [
'team_id' => $teamId,
'integration_token_uuid' => $integrationToken->uuid,
'provider' => $integrationToken->provider,
]);
return response()->json(['uuid' => $integrationToken->uuid], 201);
}
}
@@ -271,12 +271,6 @@ class ProjectController extends Controller
'team_id' => $teamId,
]);
auditLog('api.project.created', [
'team_id' => $teamId,
'project_uuid' => $project->uuid,
'project_name' => $project->name,
]);
return response()->json([
'uuid' => $project->uuid,
])->setStatusCode(201);
@@ -396,13 +390,6 @@ class ProjectController extends Controller
$project->update($request->only($allowedFields));
auditLog('api.project.updated', [
'team_id' => $teamId,
'project_uuid' => $project->uuid,
'project_name' => $project->name,
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
]);
return response()->json([
'uuid' => $project->uuid,
'name' => $project->name,
@@ -482,16 +469,8 @@ class ProjectController extends Controller
return response()->json(['message' => 'Project has resources, so it cannot be deleted.'], 400);
}
$projectUuid = $project->uuid;
$projectName = $project->name;
$project->delete();
auditLog('api.project.deleted', [
'team_id' => $teamId,
'project_uuid' => $projectUuid,
'project_name' => $projectName,
]);
return response()->json(['message' => 'Project deleted.']);
}
+37 -24
View File
@@ -2,47 +2,60 @@
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use App\Models\OauthSetting;
use App\Services\Auth\OauthLoginService;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpKernel\Exception\HttpException;
class OauthController extends Controller
{
public function redirect(string $provider)
{
$socialite_provider = get_socialite_provider($provider);
$oauthSetting = $this->enabledProvider($provider);
$socialiteProvider = get_socialite_provider($oauthSetting->provider);
return $socialite_provider->redirect();
return $socialiteProvider->redirect();
}
public function callback(string $provider)
public function callback(string $provider, OauthLoginService $oauthLoginService)
{
try {
$oauthUser = get_socialite_provider($provider)->user();
$email = trim((string) $oauthUser->email);
if ($email === '') {
abort(403, 'OAuth provider did not return an email address');
}
$email = strtolower($email);
$user = User::whereEmail($email)->first();
if (! $user) {
$settings = instanceSettings();
if (! $settings->is_registration_enabled) {
abort(403, 'Registration is disabled');
}
$user = User::create([
'name' => $oauthUser->name,
'email' => $email,
]);
}
Auth::login($user);
$oauthSetting = $this->enabledProvider($provider);
$oauthUser = get_socialite_provider($oauthSetting->provider)->user();
$oauthLoginService->login($oauthSetting->provider, $oauthUser, $oauthSetting);
return redirect('/');
} catch (\Exception $e) {
$this->logCallbackFailure($provider, $e);
$errorCode = $e instanceof HttpException ? 'auth.failed' : 'auth.failed.callback';
return redirect()->route('login')->withErrors([__($errorCode)]);
}
}
private function logCallbackFailure(string $provider, \Throwable $exception): void
{
Log::error('OAuth callback failed.', [
'provider' => $provider,
'exception_class' => $exception::class,
'exception_message' => $exception->getMessage(),
'request_error' => request()->query('error'),
'request_error_description' => request()->query('error_description'),
'has_code' => request()->query->has('code'),
'has_state' => request()->query->has('state'),
'ip' => request()->ip(),
'exception' => $exception,
]);
}
private function enabledProvider(string $provider): OauthSetting
{
$oauthSetting = OauthSetting::where('provider', $provider)->first();
if (! $oauthSetting || ! $oauthSetting->enabled || ! $oauthSetting->couldBeEnabled()) {
throw new HttpException(403, 'OAuth provider is not enabled');
}
return $oauthSetting;
}
}
+2
View File
@@ -29,6 +29,7 @@ use Illuminate\Auth\Middleware\RequirePassword;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
use Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull;
use Illuminate\Foundation\Http\Middleware\InvokeDeferredCallbacks;
use Illuminate\Foundation\Http\Middleware\ValidatePostSize;
use Illuminate\Http\Middleware\HandleCors;
use Illuminate\Http\Middleware\SetCacheHeaders;
@@ -59,6 +60,7 @@ class Kernel extends HttpKernel
ValidatePostSize::class,
TrimStrings::class,
ConvertEmptyStringsToNull::class,
InvokeDeferredCallbacks::class,
];
+160 -18
View File
@@ -19,6 +19,7 @@ use App\Models\StandaloneDocker;
use App\Models\SwarmDocker;
use App\Notifications\Application\DeploymentFailed;
use App\Notifications\Application\DeploymentSuccess;
use App\Support\RemoteSecretReferences;
use App\Support\ValidationPatterns;
use App\Traits\EnvironmentVariableAnalyzer;
use App\Traits\ExecuteRemoteCommand;
@@ -147,6 +148,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private $env_args;
/** @var array<string, string>|null */
private ?array $remote_secrets_cache = null;
private $env_nixpacks_args;
private $env_railpack_args;
@@ -1287,6 +1291,11 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
return true;
}
if ($this->has_remote_buildtime_secret_references()) {
$this->application_deployment_queue->addLogEntry('Remote build-time secrets are configured. Running the build to check for updated values.');
return false;
}
$configurationDiff = $this->application->pendingDeploymentConfigurationDiff();
if (! $configurationDiff->requiresBuild()) {
$this->application_deployment_queue->addLogEntry("No build configuration changed & image found ({$this->production_image_name}) with the same Git Commit SHA. Build step skipped.");
@@ -1314,6 +1323,18 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
return false;
}
private function has_remote_buildtime_secret_references(): bool
{
$environmentVariables = $this->pull_request_id === 0
? $this->application->environment_variables()
: $this->application->environment_variables_preview();
return $environmentVariables
->where('is_buildtime', true)
->get(['value'])
->contains(fn (EnvironmentVariable $environmentVariable) => RemoteSecretReferences::containsReference($environmentVariable->value));
}
private function check_image_locally_or_remotely()
{
$this->execute_remote_command([
@@ -1335,6 +1356,101 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
}
/**
* Fetch the secrets from the application's secret manager source. Values
* live only in memory during the deployment and in the generated .env on
* the server — they are never persisted in the Coolify database. Fetched
* lazily (only when a variable references a secret), once per deployment.
* A fetch failure fails the deployment.
*
* @return array<string, string>
*/
private function remote_secrets(): array
{
if ($this->remote_secrets_cache !== null) {
return $this->remote_secrets_cache;
}
$link = $this->application->secretManagerLink()->with('integrationToken')->first();
if (! $link) {
throw new DeploymentException('Environment variables reference remote secrets ({{vault.KEY}}), but no secret manager source is configured for this application.');
}
$provider = $link->integrationToken->providerName();
$tokenName = $link->integrationToken->name;
try {
$secrets = $link->fetchSecrets();
} catch (Throwable $e) {
$this->application_deployment_queue->addLogEntry("Failed to fetch secrets from {$provider} ({$tokenName}, {$link->sourceSummary()}): {$e->getMessage()}", 'stderr');
throw new DeploymentException("Could not fetch secrets from {$provider}. The deployment was stopped so the application does not start with missing secrets.");
}
$this->application_deployment_queue->addLogEntry('Fetched '.count($secrets)." secrets from {$provider} ({$tokenName}, {$link->sourceSummary()}).");
return $this->remote_secrets_cache = $secrets;
}
/**
* Replace {{vault.KEY}} references with values from the configured secret
* manager source. Missing keys fail the deployment with a
* list — changing the source never re-checks references, so this is the
* moment problems surface.
*/
private function substitute_remote_secrets(string $value, string $envKey): string
{
$secrets = $this->remote_secrets();
$missing = RemoteSecretReferences::missingKeys($value, $secrets);
if ($missing !== []) {
$message = 'Missing secret keys: '.implode(', ', $missing)." (referenced by {$envKey}).";
$this->application_deployment_queue->addLogEntry($message, 'stderr');
throw new DeploymentException($message.' Check the secret manager source of this application.');
}
return RemoteSecretReferences::substitute($value, $secrets);
}
/**
* Resolve shared variables, then secret references, in a raw variable value.
*/
private function resolve_environment_variable_raw(EnvironmentVariable $env): string
{
$value = $env->get_real_environment_variables_with_server($env->value, $this->application, $this->mainServer);
return $this->substitute_remote_secrets($value ?? '', $env->key);
}
/**
* Resolve a runtime variable to its dotenv representation. Values with
* secret references are substituted and written as literals.
*/
private function resolve_environment_variable(EnvironmentVariable $env): ?string
{
if (! RemoteSecretReferences::containsReference($env->value)) {
return $env->getResolvedValueWithServer($this->mainServer);
}
return $this->format_remote_secret_value($this->resolve_environment_variable_raw($env));
}
/**
* Format a remote secret value for the runtime .env file (dotenv syntax read
* by docker compose). Values are treated as literals — no interpolation.
*/
private function format_remote_secret_value(string $value): string
{
if (! str_contains($value, "'")) {
return "'".$value."'";
}
// Fall back to double quotes; $$ escapes compose interpolation.
return '"'.str_replace(['\\', '"', '$'], ['\\\\', '\\"', '$$'], $value).'"';
}
private function generate_runtime_environment_variables()
{
$envs = collect([]);
@@ -1403,7 +1519,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
});
foreach ($runtime_environment_variables as $env) {
$envs->push($env->key.'='.$env->getResolvedValueWithServer($this->mainServer));
$envs->push($env->key.'='.$this->resolve_environment_variable($env));
}
// Check for PORT environment variable mismatch with ports_exposes
@@ -1470,7 +1586,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
});
foreach ($runtime_environment_variables_preview as $env) {
$envs->push($env->key.'='.$env->getResolvedValueWithServer($this->mainServer));
$envs->push($env->key.'='.$this->resolve_environment_variable($env));
}
// Fall back to production env vars for keys not overridden by preview vars,
@@ -1484,7 +1600,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
return $env->is_runtime && ! in_array($env->key, $previewKeys);
});
foreach ($fallback_production_vars as $env) {
$envs->push($env->key.'='.$env->getResolvedValueWithServer($this->mainServer));
$envs->push($env->key.'='.$this->resolve_environment_variable($env));
}
}
@@ -1592,6 +1708,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->execute_remote_command(
[
executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee $this->workdir/.env > /dev/null"),
'skip_command_log' => true,
]
);
@@ -1610,6 +1727,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->execute_remote_command(
[
"echo '$envs_base64' | base64 -d | tee $this->configuration_dir/.env > /dev/null",
'skip_command_log' => true,
]
);
$this->server = $this->build_server;
@@ -1617,6 +1735,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->execute_remote_command(
[
"echo '$envs_base64' | base64 -d | tee $this->configuration_dir/.env > /dev/null",
'skip_command_log' => true,
]
);
}
@@ -1743,6 +1862,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
continue;
}
if (RemoteSecretReferences::containsReference($env->value)) {
$envs_dict[$env->key] = escapeBashEnvValue($this->resolve_environment_variable_raw($env));
continue;
}
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
// For literal/multiline vars, real_value includes quotes that we need to remove
if ($env->is_literal || $env->is_multiline) {
@@ -1798,6 +1923,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
continue;
}
if (RemoteSecretReferences::containsReference($env->value)) {
$envs_dict[$env->key] = escapeBashEnvValue($this->resolve_environment_variable_raw($env));
continue;
}
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
// For literal/multiline vars, real_value includes quotes that we need to remove
if ($env->is_literal || $env->is_multiline) {
@@ -1857,9 +1988,11 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private function validatedBuildtimeEnvironmentVariableKey(string $key, string $origin): string
{
try {
if (! ValidationPatterns::isValidEnvironmentVariableKey($key)) {
throw new \InvalidArgumentException('Invalid build-time environment variable key.');
}
return $key;
@@ -2766,6 +2899,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private function normalize_resolved_build_variable_value(EnvironmentVariable $environmentVariable): ?string
{
if (RemoteSecretReferences::containsReference($environmentVariable->value)) {
$resolved = $this->resolve_environment_variable_raw($environmentVariable);
return $resolved === '' ? null : $resolved;
}
$resolvedValue = $environmentVariable->getResolvedValueWithServer($this->mainServer);
if (is_null($resolvedValue) || $resolvedValue === '') {
return null;
@@ -3309,7 +3448,9 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
}
foreach ($envs as $env) {
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
$resolvedValue = RemoteSecretReferences::containsReference($env->value)
? $this->resolve_environment_variable_raw($env)
: $env->getResolvedValueWithServer($this->mainServer);
if (! is_null($resolvedValue)) {
$this->env_args->put($env->key, $resolvedValue);
}
@@ -3325,7 +3466,9 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
}
foreach ($envs as $env) {
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
$resolvedValue = RemoteSecretReferences::containsReference($env->value)
? $this->resolve_environment_variable_raw($env)
: $env->getResolvedValueWithServer($this->mainServer);
if (! is_null($resolvedValue)) {
$this->env_args->put($env->key, $resolvedValue);
}
@@ -4419,7 +4562,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
} else {
$secrets_string = $variables
->map(function ($env) {
return "{$env->key}={$env->getResolvedValueWithServer($this->mainServer)}";
return "{$env->key}={$this->resolve_environment_variable($env)}";
})
->sort()
->implode('|');
@@ -4485,7 +4628,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
if (data_get($env, 'is_multiline') === true) {
$argsToInsert->push("ARG {$env->key}");
} else {
$argsToInsert->push("ARG {$env->key}={$env->getResolvedValueWithServer($this->mainServer)}");
$argsToInsert->push("ARG {$env->key}=".escapeBashEnvValue($this->resolve_environment_variable_raw($env)));
}
}
// Add Coolify variables as ARGs
@@ -4507,7 +4650,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
if (data_get($env, 'is_multiline') === true) {
$argsToInsert->push("ARG {$env->key}");
} else {
$argsToInsert->push("ARG {$env->key}={$env->getResolvedValueWithServer($this->mainServer)}");
$argsToInsert->push("ARG {$env->key}=".escapeBashEnvValue($this->resolve_environment_variable_raw($env)));
}
}
// Add Coolify variables as ARGs
@@ -4521,6 +4664,14 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
}
}
if ($argsToInsert->isNotEmpty()) {
$environmentVariables = $envs->mapWithKeys(function ($environmentVariable) {
return [$environmentVariable->key => escapeBashEnvValue($this->resolve_environment_variable_raw($environmentVariable))];
});
$secretsHash = $this->generate_secrets_hash($environmentVariables);
$argsToInsert->push("ARG COOLIFY_BUILD_SECRETS_HASH={$secretsHash}");
}
// Development logging to show what ARGs are being injected
if (isDev()) {
$this->application_deployment_queue->addLogEntry('[DEBUG] ========================================');
@@ -4542,11 +4693,6 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
$dockerfile->splice($fromLineIndex + 1, 0, [$arg]);
}
}
$envs_mapped = $envs->mapWithKeys(function ($env) {
return [$env->key => $env->getResolvedValueWithServer($this->mainServer)];
});
$secrets_hash = $this->generate_secrets_hash($envs_mapped);
$argsToInsert->push("ARG COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}");
}
$dockerfile_base64 = base64_encode($dockerfile->implode("\n"));
@@ -4555,11 +4701,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
[
executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$this->workdir}{$this->dockerfile_location} > /dev/null"),
'hidden' => true,
],
[
executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}"),
'hidden' => true,
'ignore_errors' => true,
'skip_command_log' => true,
]);
}
+88
View File
@@ -0,0 +1,88 @@
<?php
namespace App\Jobs;
use App\Actions\Database\StartClickhouse;
use App\Actions\Database\StartDragonfly;
use App\Actions\Database\StartKeydb;
use App\Actions\Database\StartMariadb;
use App\Actions\Database\StartMongodb;
use App\Actions\Database\StartMysql;
use App\Actions\Database\StartPostgresql;
use App\Actions\Database\StartRedis;
use App\Enums\ProcessStatus;
use App\Events\DatabaseStatusChanged;
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 Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Spatie\Activitylog\Models\Activity;
use Throwable;
class DatabaseStartJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 1;
public int $timeout = 600;
public function __construct(
public string $databaseClass,
public int $databaseId,
public int $teamId,
public int $activityId,
public ?int $userId,
) {
$this->onQueue(deployment_queue());
}
public function handle(): void
{
$database = $this->databaseClass::query()->findOrFail($this->databaseId);
abort_unless((int) $database->team()->id === $this->teamId, 403);
$activity = Activity::query()->findOrFail($this->activityId);
match ($database->getMorphClass()) {
StandalonePostgresql::class => StartPostgresql::run($database, $activity),
StandaloneRedis::class => StartRedis::run($database, $activity),
StandaloneMongodb::class => StartMongodb::run($database, $activity),
StandaloneMysql::class => StartMysql::run($database, $activity),
StandaloneMariadb::class => StartMariadb::run($database, $activity),
StandaloneKeydb::class => StartKeydb::run($database, $activity),
StandaloneDragonfly::class => StartDragonfly::run($database, $activity),
StandaloneClickhouse::class => StartClickhouse::run($database, $activity),
};
event(new DatabaseStatusChanged($this->userId));
}
public function failed(?Throwable $exception): void
{
try {
$activity = Activity::query()->find($this->activityId);
if (! $activity) {
return;
}
$activity->properties = $activity->properties->merge([
'status' => ProcessStatus::ERROR->value,
'error' => 'Database start failed.',
'failed_at' => now()->toIso8601String(),
]);
$activity->save();
} finally {
event(new DatabaseStatusChanged($this->userId));
}
}
}
+24
View File
@@ -170,6 +170,30 @@ class Discord extends Component
}
}
public function toggleDiscordEnabled(): void
{
try {
$this->resetErrorBag();
if ($this->discordEnabled) {
$this->discordEnabled = false;
} else {
$this->validate([
'discordWebhookUrl' => 'required',
], [
'discordWebhookUrl.required' => 'Discord Webhook URL is required.',
]);
$this->discordEnabled = true;
}
$this->saveModel();
} catch (\Throwable $e) {
$this->syncData();
handleError($e, $this);
}
}
public function instantSave()
{
try {
+87 -31
View File
@@ -258,32 +258,59 @@ class Email extends Component
}
}
public function toggleSmtp()
{
try {
$this->resetErrorBag();
if ($this->smtpEnabled) {
$this->smtpEnabled = false;
$this->saveModel();
} else {
$this->validateSmtpSettings();
$this->smtpEnabled = true;
$this->resendEnabled = false;
$this->submitSmtp();
}
} catch (\Throwable $e) {
$this->syncData();
return handleError($e, $this);
} finally {
$this->dispatch('refresh');
}
}
public function toggleResend()
{
try {
$this->resetErrorBag();
if ($this->resendEnabled) {
$this->resendEnabled = false;
$this->saveModel();
} else {
$this->validateResendSettings();
$this->resendEnabled = true;
$this->smtpEnabled = false;
$this->submitResend();
}
} catch (\Throwable $e) {
$this->syncData();
return handleError($e, $this);
} finally {
$this->dispatch('refresh');
}
}
public function submitSmtp()
{
$this->authorize('update', $this->settings);
try {
$this->resetErrorBag();
$this->validate([
'smtpEnabled' => 'boolean',
'smtpFromAddress' => 'required|email',
'smtpFromName' => 'required|string',
'smtpHost' => 'required|string',
'smtpPort' => 'required|numeric',
'smtpEncryption' => 'required|string|in:starttls,tls,none',
'smtpUsername' => 'nullable|string',
'smtpPassword' => 'nullable|string',
'smtpTimeout' => 'nullable|numeric',
'smtpEhloDomain' => ['nullable', 'string', new ValidHostname],
], [
'smtpFromAddress.required' => 'From Address is required.',
'smtpFromAddress.email' => 'Please enter a valid email address.',
'smtpFromName.required' => 'From Name is required.',
'smtpHost.required' => 'SMTP Host is required.',
'smtpPort.required' => 'SMTP Port is required.',
'smtpPort.numeric' => 'SMTP Port must be a number.',
'smtpEncryption.required' => 'Encryption type is required.',
]);
$this->validateSmtpSettings();
if ($this->smtpEnabled) {
$this->settings->resend_enabled = $this->resendEnabled = false;
@@ -315,17 +342,7 @@ class Email extends Component
try {
$this->resetErrorBag();
$this->validate([
'resendEnabled' => 'boolean',
'resendApiKey' => $this->resendEnabled ? 'required|string' : 'nullable|string',
'smtpFromAddress' => 'required|email',
'smtpFromName' => 'required|string',
], [
'resendApiKey.required' => 'Resend API Key is required.',
'smtpFromAddress.required' => 'From Address is required.',
'smtpFromAddress.email' => 'Please enter a valid email address.',
'smtpFromName.required' => 'From Name is required.',
]);
$this->validateResendSettings();
if ($this->resendEnabled) {
$this->settings->smtp_enabled = $this->smtpEnabled = false;
}
@@ -342,6 +359,45 @@ class Email extends Component
}
}
private function validateSmtpSettings(): void
{
$this->validate([
'smtpEnabled' => 'boolean',
'smtpFromAddress' => 'required|email',
'smtpFromName' => 'required|string',
'smtpHost' => 'required|string',
'smtpPort' => 'required|numeric',
'smtpEncryption' => 'required|string|in:starttls,tls,none',
'smtpUsername' => 'nullable|string',
'smtpPassword' => 'nullable|string',
'smtpTimeout' => 'nullable|numeric',
'smtpEhloDomain' => ['nullable', 'string', new ValidHostname],
], [
'smtpFromAddress.required' => 'From Address is required.',
'smtpFromAddress.email' => 'Please enter a valid email address.',
'smtpFromName.required' => 'From Name is required.',
'smtpHost.required' => 'SMTP Host is required.',
'smtpPort.required' => 'SMTP Port is required.',
'smtpPort.numeric' => 'SMTP Port must be a number.',
'smtpEncryption.required' => 'Encryption type is required.',
]);
}
private function validateResendSettings(): void
{
$this->validate([
'resendEnabled' => 'boolean',
'resendApiKey' => $this->resendEnabled ? 'required|string' : 'nullable|string',
'smtpFromAddress' => 'required|email',
'smtpFromName' => 'required|string',
], [
'resendApiKey.required' => 'Resend API Key is required.',
'smtpFromAddress.required' => 'From Address is required.',
'smtpFromAddress.email' => 'Please enter a valid email address.',
'smtpFromName.required' => 'From Name is required.',
]);
}
public function sendTestEmail()
{
try {
+28
View File
@@ -163,6 +163,34 @@ class Pushover extends Component
}
}
public function togglePushoverEnabled()
{
try {
$this->resetErrorBag();
if ($this->pushoverEnabled) {
$this->pushoverEnabled = false;
} else {
$this->validate([
'pushoverUserKey' => 'required',
'pushoverApiToken' => 'required',
], [
'pushoverUserKey.required' => 'Pushover User Key is required.',
'pushoverApiToken.required' => 'Pushover API Token is required.',
]);
$this->pushoverEnabled = true;
}
$this->saveModel();
} catch (\Throwable $e) {
$this->syncData();
return handleError($e, $this);
} finally {
$this->dispatch('refresh');
}
}
public function instantSave()
{
try {
+26
View File
@@ -154,6 +154,32 @@ class Slack extends Component
}
}
public function toggleSlackEnabled()
{
try {
$this->resetErrorBag();
if ($this->slackEnabled) {
$this->slackEnabled = false;
} else {
$this->validate([
'slackWebhookUrl' => 'required',
], [
'slackWebhookUrl.required' => 'Slack Webhook URL is required.',
]);
$this->slackEnabled = true;
}
$this->saveModel();
} catch (\Throwable $e) {
$this->syncData();
return handleError($e, $this);
} finally {
$this->dispatch('refresh');
}
}
public function instantSave()
{
try {
+28
View File
@@ -263,6 +263,34 @@ class Telegram extends Component
}
}
public function toggleTelegramEnabled(): void
{
try {
$this->resetErrorBag();
if ($this->telegramEnabled) {
$this->telegramEnabled = false;
} else {
$this->validate([
'telegramToken' => 'required',
'telegramChatId' => 'required',
], [
'telegramToken.required' => 'Telegram Token is required.',
'telegramChatId.required' => 'Telegram Chat ID is required.',
]);
$this->telegramEnabled = true;
}
$this->saveModel();
} catch (\Throwable $e) {
$this->syncData();
handleError($e, $this);
} finally {
$this->dispatch('refresh');
}
}
public function saveModel()
{
$this->authorize('update', $this->settings);
+24
View File
@@ -148,6 +148,30 @@ class Webhook extends Component
}
}
public function toggleWebhookEnabled()
{
try {
$this->resetErrorBag();
if ($this->webhookEnabled) {
$this->webhookEnabled = false;
} else {
$this->validate([
'webhookUrl' => 'required',
], [
'webhookUrl.required' => 'Webhook URL is required.',
]);
$this->webhookEnabled = true;
}
$this->saveModel();
} catch (\Throwable $e) {
$this->syncData();
return handleError($e, $this);
}
}
public function instantSave()
{
try {
+53 -6
View File
@@ -2,19 +2,15 @@
namespace App\Livewire\Profile;
use App\Services\AvatarStorageService;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\Rules\Password;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Livewire\WithFileUploads;
class Index extends Component
{
use WithFileUploads;
public int $userId;
public string $email;
@@ -36,6 +32,10 @@ class Index extends Component
public bool $show_verification = false;
public bool $uses_sso = false;
public ?string $sso_provider_label = null;
public $avatar;
public function uploadAvatar(AvatarStorageService $avatarStorage): bool
@@ -75,8 +75,12 @@ class Index extends Component
$this->name = Auth::user()->name;
$this->email = Auth::user()->email;
$oauthIdentity = Auth::user()->oauthIdentities()->latest('id')->first();
$this->uses_sso = $oauthIdentity !== null;
$this->sso_provider_label = $oauthIdentity ? $this->providerLabel($oauthIdentity->provider) : null;
// Check if there's a pending email change
if (Auth::user()->hasEmailChangeRequest()) {
if (! $this->uses_sso && Auth::user()->hasEmailChangeRequest()) {
$this->new_email = Auth::user()->pending_email;
$this->show_verification = true;
}
@@ -101,6 +105,10 @@ class Index extends Component
public function requestEmailChange()
{
try {
if ($this->rejectSsoEmailChange()) {
return;
}
// For self-hosted, check if email is enabled
if (! isCloud()) {
$settings = instanceSettings();
@@ -159,6 +167,10 @@ class Index extends Component
public function verifyEmailChange()
{
try {
if ($this->rejectSsoEmailChange()) {
return;
}
$this->validate([
'email_verification_code' => ['required', 'string', 'size:6'],
]);
@@ -204,7 +216,6 @@ class Index extends Component
$this->show_verification = false;
$this->dispatch('success', 'Email address updated successfully.');
$this->dispatch('close-email-change-modal');
} else {
$this->dispatch('error', 'Failed to update email address.');
}
@@ -216,6 +227,10 @@ class Index extends Component
public function resendVerificationCode()
{
try {
if ($this->rejectSsoEmailChange()) {
return;
}
// Check if there's a pending request
if (! Auth::user()->hasEmailChangeRequest()) {
$this->dispatch('error', 'No pending email change request.');
@@ -269,6 +284,30 @@ class Index extends Component
$this->dispatch('success', 'Email change request cancelled.');
}
public function showEmailChangeForm()
{
if ($this->rejectSsoEmailChange()) {
return;
}
$this->show_email_change = true;
$this->new_email = '';
}
private function rejectSsoEmailChange(): bool
{
if (! Auth::user()->hasSsoIdentity()) {
return false;
}
$this->uses_sso = true;
$this->show_email_change = false;
$this->show_verification = false;
$this->dispatch('error', 'Email addresses managed by SSO cannot be changed in Coolify.');
return true;
}
public function resetPassword()
{
try {
@@ -299,6 +338,14 @@ class Index extends Component
}
}
private function providerLabel(string $provider): string
{
return match ($provider) {
'oidc' => 'OIDC',
default => str($provider)->headline()->toString(),
};
}
public function render()
{
return view('livewire.profile.index');
@@ -104,7 +104,6 @@ class DeploymentNavbar extends Component
$this->application_deployment_queue->update([
'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
]);
try {
if ($this->application->settings->is_build_server_enabled) {
$server = Server::ownedByCurrentTeam()->find($build_server_id);
@@ -156,6 +156,11 @@ class Heading extends Component
$this->dispatch('info', 'Gracefully stopping application.<br/>It could take a while depending on the application.');
StopApplication::dispatch($this->application, false, $this->docker_cleanup);
auditLog('ui.application.stopped', [
'team_id' => $this->application->team()?->id,
'application_uuid' => $this->application->uuid,
'application_name' => $this->application->name,
]);
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -284,6 +284,12 @@ class Previews extends Component
ApplicationPreview::where('application_id', $this->application->id)
->where('pull_request_id', $pull_request_id)
->update(['status' => 'exited']);
auditLog('ui.application.preview_stopped', [
'team_id' => $this->application->team()?->id,
'application_uuid' => $this->application->uuid,
'application_name' => $this->application->name,
'pull_request_id' => $pull_request_id,
]);
ServiceStatusChanged::dispatch($this->application->environment->project->team->id);
GetContainersStatus::run($server);
+8
View File
@@ -102,6 +102,14 @@ class CloneMe extends Component
if (! $selectedDestination) {
throw new \Exception('Destination not found.');
}
auditLog('ui.project.clone_started', [
'team_id' => $this->project->team_id,
'project_uuid' => $this->project->uuid,
'project_name' => $this->project->name,
'clone_type' => $type,
'new_name' => $this->newName,
'destination_uuid' => $selectedDestination->uuid,
]);
if ($type === 'project') {
$foundProject = Project::where('name', $this->newName)->first();
if ($foundProject) {
+17 -4
View File
@@ -207,10 +207,18 @@ class BackupEdit extends Component
}
}
$database = $this->backup->database;
$backupUuid = $this->backup->uuid;
$this->backup->delete();
auditLog('ui.database.backup_schedule_deleted', [
'team_id' => $database->team()?->id,
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'backup_uuid' => $backupUuid,
]);
if ($this->backup->database->getMorphClass() === ServiceDatabase::class) {
$serviceDatabase = $this->backup->database;
if ($database->getMorphClass() === ServiceDatabase::class) {
$serviceDatabase = $database;
return redirectRoute($this, 'project.service.database.backups', [
'project_uuid' => $this->parameters['project_uuid'],
@@ -238,9 +246,14 @@ class BackupEdit extends Component
$this->authorize('manageBackups', $this->backup->database);
DatabaseBackupJob::dispatch($this->backup);
$this->dispatch('success', 'Backup queued. It will be available in a few minutes.');
$database = $this->backup->database;
auditLog('ui.database.backup_started', [
'team_id' => $database->team()?->id,
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'backup_uuid' => $this->backup->uuid,
]);
$this->dispatch('success', 'Backup queued. It will be available in a few minutes.');
if ($database instanceof ServiceDatabase) {
return redirect()->route('project.service.database.backup.executions', [
@@ -18,6 +18,13 @@ class BackupNow extends Component
$this->authorize('manageBackups', $this->backup->database);
DatabaseBackupJob::dispatch($this->backup);
$database = $this->backup->database;
auditLog('ui.database.backup_started', [
'team_id' => $database->team()?->id,
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'backup_uuid' => $this->backup->uuid,
]);
$this->dispatch('success', 'Backup queued. It will be available in a few minutes.');
} catch (\Throwable $e) {
return handleError($e, $this);
+12
View File
@@ -89,6 +89,7 @@ class Heading extends Component
$this->dispatch('info', 'Gracefully stopping database.');
StopDatabase::dispatch($this->database, false, $this->docker_cleanup);
$this->auditDatabaseAction('ui.database.stopped');
} catch (\Exception $e) {
$this->dispatch('error', $e->getMessage());
}
@@ -100,6 +101,7 @@ class Heading extends Component
$this->authorize('manage', $this->database);
$activity = RestartDatabase::run($this->database);
$this->auditDatabaseAction('ui.database.restarted');
$this->js("window.dispatchEvent(new CustomEvent('startdatabase'))");
$this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class);
} catch (\Throwable $e) {
@@ -113,6 +115,7 @@ class Heading extends Component
$this->authorize('manage', $this->database);
$activity = StartDatabase::run($this->database);
$this->auditDatabaseAction('ui.database.started');
$this->js("window.dispatchEvent(new CustomEvent('startdatabase'))");
$this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class);
} catch (\Throwable $e) {
@@ -128,4 +131,13 @@ class Heading extends Component
],
]);
}
private function auditDatabaseAction(string $event): void
{
auditLog($event, [
'team_id' => $this->database->team()?->id,
'database_uuid' => $this->database->uuid,
'database_name' => $this->database->name,
]);
}
}
@@ -510,6 +510,12 @@ EOD;
// Dispatch activity to the monitor and open slide-over
$this->dispatch('activityMonitor', $activity->id);
$this->dispatch('databaserestore');
auditLog('ui.database.import_started', [
'team_id' => $this->resource->team()?->id,
'database_uuid' => $this->resource->uuid,
'database_name' => $this->resource->name,
'source' => 'file',
]);
}
} catch (\Throwable $e) {
handleError($e, $this);
@@ -768,6 +774,13 @@ EOD;
// Dispatch activity to the monitor and open slide-over
$this->dispatch('activityMonitor', $activity->id);
$this->dispatch('databaserestore');
auditLog('ui.database.restore_started', [
'team_id' => $this->resource->team()?->id,
'database_uuid' => $this->resource->uuid,
'database_name' => $this->resource->name,
'source' => 's3',
'storage_id' => $this->s3StorageId,
]);
$this->dispatch('info', 'Restoring database from S3. Progress will be shown in the activity monitor...');
} catch (\Throwable $e) {
$this->importRunning = false;
+13
View File
@@ -116,6 +116,7 @@ class Heading extends Component
try {
$this->authorizeService('deploy');
$activity = StartService::run($this->service, pullLatestImages: true);
$this->auditServiceAction('ui.service.started');
$this->js("window.dispatchEvent(new CustomEvent('startservice'))");
$this->dispatch('activityMonitor', $activity->id);
} catch (\Throwable $e) {
@@ -149,6 +150,7 @@ class Heading extends Component
try {
$this->authorizeService('stop');
StopService::dispatch($this->service, false, $this->docker_cleanup);
$this->auditServiceAction('ui.service.stopped');
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -165,6 +167,7 @@ class Heading extends Component
return;
}
$activity = StartService::run($this->service, stopBeforeStart: true);
$this->auditServiceAction('ui.service.restarted');
$this->js("window.dispatchEvent(new CustomEvent('startservice'))");
$this->dispatch('activityMonitor', $activity->id);
} catch (\Throwable $e) {
@@ -206,6 +209,7 @@ class Heading extends Component
return;
}
$activity = StartService::run($this->service, pullLatestImages: true, stopBeforeStart: true);
$this->auditServiceAction('ui.service.restarted');
$this->js("window.dispatchEvent(new CustomEvent('startservice'))");
$this->dispatch('activityMonitor', $activity->id);
} catch (\Throwable $e) {
@@ -222,6 +226,15 @@ class Heading extends Component
$this->authorize($ability, $this->service);
}
private function auditServiceAction(string $event): void
{
auditLog($event, [
'team_id' => $this->service->team()?->id,
'service_uuid' => $this->service->uuid,
'service_name' => $this->service->name,
]);
}
public function render()
{
return view('livewire.project.service.heading', [
+10 -4
View File
@@ -78,6 +78,7 @@ class Storage extends Component
$this->activeTab = $this->resolveDefaultTab();
$this->fileStorage = collect();
$this->loadFileStorageForActiveTab();
$this->name = $this->generateDefaultVolumeName();
}
public function refreshStoragesFromEvent()
@@ -208,9 +209,7 @@ class Storage extends Component
$this->validate([
'name' => ValidationPatterns::volumeNameRules(),
'mount_path' => 'required|string',
'host_path' => $this->isSwarm
? ['required', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN]
: ['nullable', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
'host_path' => ['nullable', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
], array_merge(ValidationPatterns::volumeNameMessages(), [
'host_path.regex' => 'Host path must start with / and only contain safe path characters.',
]));
@@ -343,7 +342,7 @@ class Storage extends Component
public function clearForm()
{
$this->name = '';
$this->name = $this->generateDefaultVolumeName();
$this->mount_path = '';
$this->host_path = null;
$this->file_storage_path = '';
@@ -376,6 +375,13 @@ class Storage extends Component
throw new \Exception('No valid resource type for file mount storage type!');
}
private function generateDefaultVolumeName(): string
{
$name = str($this->resource->name)->slug()->value();
return ($name ?: 'volume').'-data';
}
public function fileStoragePreviewPath(): string
{
$path = str($this->file_storage_path)->trim();
@@ -64,6 +64,13 @@ class Destination extends Component
$this->authorize('deploy', $this->resource);
$server = Server::ownedByCurrentTeam()->findOrFail($serverId);
StopApplicationOneServer::run($this->resource, $server);
auditLog('ui.application.destination_stopped', [
'team_id' => $this->resource->team()?->id,
'application_uuid' => $this->resource->uuid,
'application_name' => $this->resource->name,
'server_uuid' => $server->uuid,
'server_name' => $server->name,
]);
$this->refreshServers();
} catch (\Exception $e) {
return handleError($e, $this);
@@ -9,14 +9,27 @@ use App\Models\Server;
use App\Models\Service;
use App\Support\ValidationPatterns;
use App\Traits\EnvironmentVariableAnalyzer;
use App\Traits\HasSecretManagerAutocomplete;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Computed;
use Livewire\Component;
class Add extends Component
{
use AuthorizesRequests, EnvironmentVariableAnalyzer;
use AuthorizesRequests, EnvironmentVariableAnalyzer, HasSecretManagerAutocomplete;
protected function secretManagerResource(): ?Model
{
if ($this->shared || ! $this->resource) {
return null;
}
return $this->resource;
}
public $resource;
public $parameters;
@@ -13,7 +13,9 @@ use App\Models\SharedEnvironmentVariable;
use App\Support\ValidationPatterns;
use App\Traits\EnvironmentVariableAnalyzer;
use App\Traits\EnvironmentVariableProtection;
use App\Traits\HasSecretManagerAutocomplete;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Computed;
use Livewire\Component;
@@ -22,7 +24,12 @@ class Show extends Component
{
public bool $showEnvironmentType = true;
use AuthorizesRequests, EnvironmentVariableAnalyzer, EnvironmentVariableProtection;
use AuthorizesRequests, EnvironmentVariableAnalyzer, EnvironmentVariableProtection, HasSecretManagerAutocomplete;
protected function secretManagerResource(): ?Model
{
return $this->isSharedVariable ? null : $this->env->resourceable;
}
public $parameters;
@@ -164,7 +171,24 @@ class Show extends Component
$this->valuesLoaded = true;
}
public function copyValue(): ?string
{
if ($this->env->is_shown_once || (auth()->user()?->isMember() ?? true)) {
return null;
}
if (! $this->env instanceof ModelsEnvironmentVariable) {
return $this->env->value;
}
return $this->env->get_real_environment_variables_with_server(
$this->env->resolveReferencedValue(),
$this->env->resourceable,
);
}
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->key = ValidationPatterns::normalizeEnvironmentVariableKey($this->key);
@@ -207,7 +231,7 @@ class Show extends Component
$this->is_required = (bool) ($this->env->is_required ?? false);
// Use the stored column, not the value-based accessor (that decrypts).
$this->is_shared = (bool) ($this->env->getAttributes()['is_shared'] ?? false);
$this->isValueHidden = auth()->user()?->isMember() ?? false;
$this->isValueHidden = auth()->user()?->isMember() ?? true;
if ($this->valuesLoaded) {
$this->hydrateValueFields();
@@ -234,12 +258,12 @@ class Show extends Component
$this->is_really_required = $this->is_required && blank($this->value);
}
if ($this->env->is_shown_once || auth()->user()?->isMember()) {
if ($this->env->is_shown_once || (auth()->user()?->isMember() ?? true)) {
$this->value = null;
$this->real_value = null;
}
$this->isValueHidden = auth()->user()?->isMember() ?? false;
$this->isValueHidden = auth()->user()?->isMember() ?? true;
}
public function checkEnvs()
@@ -2,6 +2,7 @@
namespace App\Livewire\Project\Shared\EnvironmentVariable;
use App\Models\EnvironmentVariable;
use Livewire\Component;
class ShowHardcoded extends Component
@@ -20,6 +21,10 @@ class ShowHardcoded extends Component
public bool $isPreview = false;
public ?string $resourceableType = null;
public ?int $resourceableId = null;
public function mount()
{
$this->key = $this->env['key'];
@@ -28,6 +33,20 @@ class ShowHardcoded extends Component
$this->serviceName = $this->env['service_name'] ?? null;
}
public function copyValue(): ?string
{
if (auth()->user()?->isMember() ?? true) {
return null;
}
return EnvironmentVariable::make([
'value' => $this->value,
'is_preview' => $this->isPreview,
'resourceable_type' => $this->resourceableType,
'resourceable_id' => $this->resourceableId,
])->resolveReferencedValue();
}
public function render()
{
return view('livewire.project.shared.environment-variable.show-hardcoded');
@@ -86,6 +86,14 @@ class ResourceOperations extends Component
if (! $server->canHostResources()) {
return $this->addError('destination_id', 'The selected server cannot host resources.');
}
auditLog('ui.resource.clone_started', [
'team_id' => $this->resource->team()?->id,
'resource_uuid' => $this->resource->uuid,
'resource_name' => $this->resource->name,
'resource_type' => class_basename($this->resource),
'destination_uuid' => $new_destination->uuid,
'environment_id' => $new_environment->id,
]);
if ($this->resource->getMorphClass() === Application::class) {
$new_resource = clone_application($this->resource, $new_destination, [
@@ -184,6 +184,13 @@ class Show extends Component
$this->authorize('update', $this->resource);
$this->authorize('update', $this->task);
ScheduledTaskJob::dispatch($this->task);
auditLog('ui.scheduled_task.executed', [
'team_id' => $this->resource->team()?->id,
'resource_uuid' => $this->resource->uuid,
'resource_name' => $this->resource->name,
'scheduled_task_uuid' => $this->task->uuid,
'scheduled_task_name' => $this->task->name,
]);
$this->dispatch('success', 'Scheduled task executed.');
} catch (\Exception $e) {
return handleError($e);
@@ -0,0 +1,290 @@
<?php
namespace App\Livewire\Project\Shared;
use App\Models\IntegrationToken;
use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
/**
* Manages a resource's single secret manager source and lets the user
* browse remote key names, add {{vault.KEY}} reference variables, and import
* all missing keys. Secret values never enter the component state or the DB.
*/
class SecretManagerLinks extends Component
{
use AuthorizesRequests;
public $resource;
public $link;
public $availableTokens;
public string $integration_token_uuid = '';
public array $settings = [];
/** @var list<string> Remote key names only — values are never stored. */
public array $keys = [];
public bool $keysLoaded = false;
public string $search = '';
public function mount(): void
{
$this->loadData();
}
private function loadData(): void
{
$this->link = $this->resource->secretManagerLink()->with('integrationToken')->first();
$this->availableTokens = IntegrationToken::ownedByCurrentTeam()
->whereIn('provider', IntegrationToken::SECRET_MANAGER_PROVIDERS)
->get()
->filter(fn (IntegrationToken $token) => in_array('secrets', $token->capabilities ?? [], true))
->values();
if ($this->link) {
$this->integration_token_uuid = $this->link->integrationToken->uuid;
$this->settings = $this->link->settings ?? [];
}
}
public function getSelectedTokenProperty(): ?IntegrationToken
{
if (blank($this->integration_token_uuid)) {
return null;
}
return $this->availableTokens->firstWhere('uuid', $this->integration_token_uuid);
}
protected function rules(): array
{
$rules = [
'integration_token_uuid' => ['required', 'string'],
];
$rules += match ($this->selectedToken?->provider) {
'doppler' => $this->selectedToken->dopplerTokenType() === 'service_account'
? [
'settings.project' => ['required', 'string'],
'settings.config' => ['required', 'string'],
]
: [],
'infisical' => [
'settings.project_id' => ['required', 'string'],
'settings.environment' => ['required', 'string'],
'settings.secret_path' => ['nullable', 'string'],
],
'vault' => [
'settings.mount' => ['required', 'string'],
'settings.path' => ['required', 'string'],
],
default => [],
};
return $rules;
}
/**
* Auto-save when a token is selected in the dropdown. Existing {{vault.*}}
* references are intentionally NOT re-checked — missing keys surface at
* the next deployment.
*/
public function updatedIntegrationTokenUuid(): void
{
try {
$this->authorize('update', $this->resource);
$token = $this->selectedToken;
if (! $token) {
return;
}
if ($this->link?->integrationToken?->provider !== $token->provider
|| $this->link?->integrationToken?->dopplerTokenType() !== $token->dopplerTokenType()) {
$this->settings = [];
}
$settings = array_filter($this->settings, fn ($value) => filled($value));
$this->resource->secretManagerLink()->updateOrCreate([], [
'integration_token_id' => $token->id,
'settings' => $settings ?: null,
]);
$this->auditSecretManagerAction('source_updated', [
'integration_token_uuid' => $token->uuid,
'provider' => $token->provider,
]);
$this->resetKeys();
$this->loadData();
$this->dispatch('success', 'Secret manager source saved. References resolve at the next deployment.');
} catch (\Throwable $e) {
handleError($e, $this);
}
}
/**
* Auto-save of the provider-specific settings fields (called on blur).
*/
public function saveSettings(): void
{
$this->authorize('update', $this->resource);
if (! $this->link) {
return;
}
$validated = $this->validate();
try {
$settings = array_filter(data_get($validated, 'settings', []), fn ($value) => filled($value));
$this->link->update(['settings' => $settings ?: null]);
$this->auditSecretManagerAction('settings_updated');
$this->resetKeys();
$this->loadData();
$this->dispatch('success', 'Secret manager settings saved.');
} catch (\Throwable $e) {
handleError($e, $this);
}
}
public function removeSource(): void
{
try {
$this->authorize('update', $this->resource);
$token = $this->link?->integrationToken;
$this->resource->secretManagerLink()->delete();
$this->auditSecretManagerAction('source_removed', [
'integration_token_uuid' => $token?->uuid,
'provider' => $token?->provider,
]);
$this->link = null;
$this->integration_token_uuid = '';
$this->settings = [];
$this->resetKeys();
$this->loadData();
$this->dispatch('success', 'Secret manager source removed. Existing {{vault.*}} references will fail the next deployment until they are removed too.');
} catch (\Throwable $e) {
handleError($e, $this);
}
}
public function loadKeys(): void
{
try {
$this->authorize('update', $this->resource);
if (! $this->link) {
return;
}
// Values are fetched into memory, reduced to key names, and discarded.
$keys = array_keys($this->link->fetchSecrets());
sort($keys);
$this->keys = $keys;
$this->keysLoaded = true;
$this->auditSecretManagerAction('keys_viewed', ['key_count' => count($keys)]);
} catch (\Throwable $e) {
$this->dispatch('error', 'Could not fetch keys: '.$e->getMessage());
}
}
public function addReference(string $key): void
{
try {
$this->authorize('update', $this->resource);
if (! in_array($key, $this->keys, true)) {
return;
}
if ($this->resource->environment_variables()->where('key', $key)->exists()) {
$this->dispatch('error', "A variable with the key {$key} already exists.");
return;
}
$this->resource->environment_variables()->create([
'key' => $key,
'value' => '{{vault.'.$key.'}}',
]);
$this->auditSecretManagerAction('reference_created', ['secret_key' => $key]);
$this->dispatch('refreshEnvs');
$this->dispatch('success', "Added {$key} as {{vault.{$key}}}.");
} catch (\Throwable $e) {
handleError($e, $this);
}
}
public function importAll(): void
{
try {
$this->authorize('update', $this->resource);
if (! $this->link) {
return;
}
$imported = $this->link->importMissingReferences();
$this->auditSecretManagerAction('references_imported', [
'key_count' => count($imported),
'secret_keys' => $imported,
]);
$this->dispatch('refreshEnvs');
$this->dispatch('success', $imported === []
? 'All remote keys already exist as variables.'
: 'Imported '.count($imported).' keys as {{vault.KEY}} references.');
} catch (\Throwable $e) {
handleError($e, $this);
}
}
private function resetKeys(): void
{
$this->keys = [];
$this->keysLoaded = false;
$this->search = '';
}
/** @param array<string, mixed> $context */
private function auditSecretManagerAction(string $action, array $context = []): void
{
$resourceType = str(class_basename($this->resource))->snake()->value();
auditLog("ui.{$resourceType}.secret_manager.{$action}", array_merge([
'team_id' => $this->resource->team()?->id,
"{$resourceType}_uuid" => $this->resource->uuid,
"{$resourceType}_name" => $this->resource->name,
], $context));
}
public function getFilteredKeysProperty(): array
{
if (blank($this->search)) {
return $this->keys;
}
return array_values(array_filter(
$this->keys,
fn (string $key) => stripos($key, $this->search) !== false,
));
}
public function render(): View
{
return view('livewire.project.shared.secret-manager-links', [
'selectedToken' => $this->selectedToken,
'filteredKeys' => $this->filteredKeys,
]);
}
}
@@ -108,6 +108,25 @@ class All extends Component
$this->submit($storageId);
}
public function clearHostPath(int $storageId): void
{
$this->authorize('update', $this->resource);
$storage = $this->findStorageOrFail($storageId);
if ($storage->shouldBeReadOnlyInUI()) {
$this->dispatch('error', 'This volume is read-only.');
return;
}
$storage->host_path = null;
$storage->save();
$this->forms[$storageId]['hostPath'] = null;
$this->dispatch('configurationChanged');
$this->dispatch('success', 'Source path removed. Use a directory mount for host directory bindings.');
}
/**
* Livewire listbox onChange cannot pass args; PR suffix fields call this via updatedForms.
*/
@@ -204,6 +204,12 @@ class VolumeBackups extends Component
}
VolumeBackupJob::dispatch($this->backup);
auditLog('ui.volume_backup.started', [
'team_id' => $this->resource->team()?->id,
'resource_uuid' => $this->resource->uuid,
'resource_name' => $this->resource->name,
'backup_uuid' => $this->backup->uuid,
]);
$this->dispatch('success', 'Storage backup queued.');
return redirect()->route($this->routeName('executions'), $this->routeParameters());
+11
View File
@@ -140,6 +140,12 @@ class ApiTokens extends Component
]);
$expiresAt = $this->expiresInDays ? now()->addDays($this->expiresInDays) : null;
$token = auth()->user()->createToken($this->description, array_values($this->permissions), $expiresAt);
auditLog('ui.api_token.created', [
'team_id' => currentTeam()->id,
'api_token_name' => $this->description,
'abilities' => array_values($this->permissions),
'expires_at' => $expiresAt?->toIso8601String(),
]);
$this->getTokens();
// Do NOT strip the numeric prefix (e.g. "69|...") — Sanctum uses it to index and look up tokens.
session()->flash('token', $token->plainTextToken);
@@ -156,7 +162,12 @@ class ApiTokens extends Component
->where('id', $id)
->firstOrFail();
$this->authorize('delete', $token);
$tokenName = $token->name;
$token->delete();
auditLog('ui.api_token.revoked', [
'team_id' => currentTeam()->id,
'api_token_name' => $tokenName,
]);
$this->getTokens();
} catch (\Exception $e) {
return handleError($e, $this);
@@ -0,0 +1,152 @@
<?php
namespace App\Livewire\Security;
use App\Models\IntegrationToken;
use App\Services\IntegrationTokenValidator;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class IntegrationTokenEditor extends Component
{
use AuthorizesRequests;
public IntegrationToken $integrationToken;
public string $name = '';
public string $newToken = '';
public array $capabilities = [];
public array $metadata = [];
public function mount(string $integration_token_uuid): void
{
$this->integrationToken = IntegrationToken::ownedByCurrentTeam()
->whereUuid($integration_token_uuid)
->firstOrFail();
$this->authorize('view', $this->integrationToken);
$this->name = $this->integrationToken->name;
$this->capabilities = $this->integrationToken->capabilities;
$this->metadata = $this->integrationToken->metadata ?? [];
}
protected function rules(): array
{
$allowedCapability = $this->integrationToken->provider === 'cloudflare' ? 'dns' : 'secrets';
$rules = [
'name' => ['required', 'string', 'max:255'],
'newToken' => ['nullable', 'string'],
'capabilities' => ['required', 'array', 'min:1'],
'capabilities.*' => ['required', 'in:'.$allowedCapability],
];
if ($this->integrationToken->provider === 'infisical') {
$rules['metadata.base_url'] = ['required', 'url'];
$rules['metadata.client_id'] = ['required', 'string'];
}
if ($this->integrationToken->provider === 'vault') {
$rules['metadata.base_url'] = ['required', 'url'];
$rules['metadata.namespace'] = ['nullable', 'string'];
}
return $rules;
}
protected function messages(): array
{
return [
'capabilities.required' => 'Select at least one capability.',
'capabilities.min' => 'Select at least one capability.',
];
}
public function save(IntegrationTokenValidator $validator): void
{
$this->authorize('update', $this->integrationToken);
$validated = $this->validate();
$provider = $this->integrationToken->provider;
$token = filled($validated['newToken']) ? $validated['newToken'] : $this->integrationToken->token;
$metadata = array_filter(data_get($validated, 'metadata', []), fn ($value) => filled($value));
$capabilitiesChanged = collect($validated['capabilities'])->sort()->values()->all()
!== collect($this->integrationToken->capabilities)->sort()->values()->all();
$metadataChanged = $metadata != ($this->integrationToken->metadata ?? []);
try {
if ((filled($validated['newToken']) || $capabilitiesChanged || $metadataChanged)
&& ! $validator->validate($provider, $token, $validated['capabilities'], $metadata)) {
$this->dispatch('error', $validator->errorMessage($provider));
return;
}
$updates = [
'name' => $validated['name'],
'capabilities' => $validated['capabilities'],
'metadata' => $metadata ?: null,
];
if (filled($validated['newToken'])) {
$updates['token'] = $validated['newToken'];
}
$this->integrationToken->update($updates);
$this->newToken = '';
auditLog('ui.integration_token.updated', [
'team_id' => currentTeam()->id,
'integration_token_uuid' => $this->integrationToken->uuid,
'integration_token_name' => $this->integrationToken->name,
'provider' => $this->integrationToken->provider,
'rotated' => array_key_exists('token', $updates),
]);
$this->dispatch(
'integration-token-updated',
uuid: $this->integrationToken->uuid,
name: $this->integrationToken->name,
capabilities: $this->integrationToken->capabilities,
);
$this->dispatch('success', 'Integration token updated successfully.');
} catch (\Throwable $e) {
handleError($e, $this);
}
}
public function delete(string $password = ''): void
{
$this->authorize('delete', $this->integrationToken);
if ($this->integrationToken->secretManagerLinks()->exists()) {
$this->dispatch('error', 'This token is used by one or more resources as a secret manager source. Remove those links first.');
return;
}
$uuid = $this->integrationToken->uuid;
$name = $this->integrationToken->name;
$provider = $this->integrationToken->provider;
$this->integrationToken->delete();
auditLog('ui.integration_token.deleted', [
'team_id' => currentTeam()->id,
'integration_token_uuid' => $uuid,
'integration_token_name' => $name,
'provider' => $provider,
]);
$this->dispatch('integration-token-deleted', uuid: $this->integrationToken->uuid);
$this->dispatch('close-modal');
$this->dispatch('success', 'Integration token deleted successfully.');
}
public function render()
{
return view('livewire.security.integration-token-editor');
}
}
@@ -0,0 +1,127 @@
<?php
namespace App\Livewire\Security;
use App\Models\IntegrationToken;
use App\Services\IntegrationTokenValidator;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class IntegrationTokenForm extends Component
{
use AuthorizesRequests;
public bool $modal_mode = false;
public string $provider = 'cloudflare';
public string $name = '';
public string $token = '';
public array $capabilities = ['dns'];
public array $metadata = [];
public function mount(): void
{
$this->authorize('create', IntegrationToken::class);
}
public function updatedProvider(): void
{
if ($this->provider === 'cloudflare') {
$this->capabilities = ['dns'];
$this->metadata = [];
} else {
$this->capabilities = ['secrets'];
$this->metadata = $this->provider === 'infisical'
? ['base_url' => 'https://app.infisical.com']
: [];
}
}
protected function rules(): array
{
$allowedCapability = $this->provider === 'cloudflare' ? 'dns' : 'secrets';
$rules = [
'provider' => ['required', 'in:'.implode(',', array_keys(IntegrationToken::PROVIDER_NAMES))],
'name' => ['required', 'string', 'max:255'],
'token' => ['required', 'string'],
'capabilities' => ['required', 'array', 'min:1'],
'capabilities.*' => ['required', 'in:'.$allowedCapability],
];
if ($this->provider === 'infisical') {
$rules['metadata.base_url'] = ['required', 'url:http,https'];
$rules['metadata.client_id'] = ['required', 'string'];
}
if ($this->provider === 'doppler') {
$rules['token'][] = 'regex:/^dp\.(st|sa)\./';
}
if ($this->provider === 'vault') {
$rules['metadata.base_url'] = ['required', 'url:http,https'];
$rules['metadata.namespace'] = ['nullable', 'string'];
}
return $rules;
}
protected function messages(): array
{
return [
'capabilities.required' => 'Select at least one capability.',
'capabilities.min' => 'Select at least one capability.',
'token.regex' => 'Use a Doppler service token (dp.st.*) or service account token (dp.sa.*).',
];
}
public function addToken(IntegrationTokenValidator $validator): void
{
$validated = $this->validate();
$metadata = array_filter(data_get($validated, 'metadata', []), fn ($value) => filled($value));
try {
if (! $validator->validate($validated['provider'], $validated['token'], $validated['capabilities'], $metadata)) {
$this->dispatch('error', $validator->errorMessage($validated['provider']));
return;
}
$integrationToken = IntegrationToken::query()->create([
'provider' => $validated['provider'],
'name' => $validated['name'],
'token' => $validated['token'],
'capabilities' => $validated['capabilities'],
'metadata' => $metadata ?: null,
'team_id' => currentTeam()->id,
]);
auditLog('ui.integration_token.created', [
'team_id' => currentTeam()->id,
'integration_token_uuid' => $integrationToken->uuid,
'integration_token_name' => $integrationToken->name,
'provider' => $integrationToken->provider,
]);
$this->reset(['name', 'token']);
$this->dispatch('integrationTokenAdded')->to(IntegrationTokens::class);
if ($this->modal_mode) {
$this->dispatch('close-modal');
}
$this->dispatch('success', 'Integration token added successfully.');
} catch (\Throwable $e) {
handleError($e, $this);
}
}
public function render()
{
return view('livewire.security.integration-token-form');
}
}
@@ -0,0 +1,57 @@
<?php
namespace App\Livewire\Security;
use App\Models\IntegrationToken;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\On;
use Livewire\Component;
class IntegrationTokens extends Component
{
use AuthorizesRequests;
public $tokens;
public function mount(): void
{
$this->authorize('viewAny', IntegrationToken::class);
$this->loadTokens();
}
#[On('integrationTokenAdded')]
public function loadTokens(): void
{
$this->tokens = IntegrationToken::ownedByCurrentTeam()->latest()->get();
}
public function deleteToken(int $tokenId, string $password = ''): void
{
$token = IntegrationToken::ownedByCurrentTeam()->findOrFail($tokenId);
$this->authorize('delete', $token);
if ($token->secretManagerLinks()->exists()) {
$this->dispatch('error', 'This token is used by one or more resources as a secret manager source. Remove those links first.');
return;
}
$tokenUuid = $token->uuid;
$tokenName = $token->name;
$provider = $token->provider;
$token->delete();
auditLog('ui.integration_token.deleted', [
'team_id' => currentTeam()->id,
'integration_token_uuid' => $tokenUuid,
'integration_token_name' => $tokenName,
'provider' => $provider,
]);
$this->loadTokens();
$this->dispatch('success', 'Integration token deleted successfully.');
}
public function render()
{
return view('livewire.security.integration-tokens');
}
}
+7
View File
@@ -134,6 +134,13 @@ class DockerCleanup extends Component
try {
$this->authorize('update', $this->server);
DockerCleanupJob::dispatch($this->server, true, $this->deleteUnusedVolumes, $this->deleteUnusedNetworks);
auditLog('ui.server.docker_cleanup_started', [
'team_id' => $this->server->team_id,
'server_uuid' => $this->server->uuid,
'server_name' => $this->server->name,
'delete_unused_volumes' => $this->deleteUnusedVolumes,
'delete_unused_networks' => $this->deleteUnusedNetworks,
]);
$this->dispatch('success', 'Manual cleanup job started. Depending on the amount of data, this might take a while.');
} catch (\Throwable $e) {
return handleError($e, $this);
+72
View File
@@ -177,6 +177,49 @@ class LogDrains extends Component
}
}
public function toggleLogDrain(string $type): void
{
$previousNewRelicEnabled = $this->server->settings->is_logdrain_newrelic_enabled;
$previousAxiomEnabled = $this->server->settings->is_logdrain_axiom_enabled;
$previousCustomEnabled = $this->server->settings->is_logdrain_custom_enabled;
try {
$this->authorize('update', $this->server);
$this->resetErrorBag();
$enabledProperty = $this->enabledProperty($type);
if ($this->{$enabledProperty}) {
$this->{$enabledProperty} = false;
} else {
$this->validateLogDrainSettings($type);
$this->isLogDrainNewRelicEnabled = $type === 'newrelic';
$this->isLogDrainAxiomEnabled = $type === 'axiom';
$this->isLogDrainCustomEnabled = $type === 'custom';
}
$this->syncData(true);
if ($this->server->isLogDrainEnabled()) {
StartLogDrain::run($this->server);
$this->dispatch('success', 'Log drain service started.');
} else {
StopLogDrain::run($this->server);
$this->dispatch('success', 'Log drain service stopped.');
}
} catch (\Throwable $e) {
// Restore the previously persisted enabled flags so the UI/DB never
// claim a runtime state that the Start/StopLogDrain action failed to apply.
$this->server->settings->is_logdrain_newrelic_enabled = $previousNewRelicEnabled;
$this->server->settings->is_logdrain_axiom_enabled = $previousAxiomEnabled;
$this->server->settings->is_logdrain_custom_enabled = $previousCustomEnabled;
$this->server->settings->save();
$this->syncData();
handleError($e, $this);
}
}
public function submit()
{
try {
@@ -192,4 +235,33 @@ class LogDrains extends Component
{
return view('livewire.server.log-drains');
}
private function enabledProperty(string $type): string
{
return match ($type) {
'newrelic' => 'isLogDrainNewRelicEnabled',
'axiom' => 'isLogDrainAxiomEnabled',
'custom' => 'isLogDrainCustomEnabled',
default => throw new \InvalidArgumentException('Unknown log drain type.'),
};
}
private function validateLogDrainSettings(string $type): void
{
match ($type) {
'newrelic' => $this->validate([
'logDrainNewRelicLicenseKey' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'],
'logDrainNewRelicBaseUri' => ['required', 'url'],
]),
'axiom' => $this->validate([
'logDrainAxiomDatasetName' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'],
'logDrainAxiomApiKey' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'],
]),
'custom' => $this->validate([
'logDrainCustomConfig' => ['required'],
'logDrainCustomConfigParser' => ['string', 'nullable'],
]),
default => throw new \InvalidArgumentException('Unknown log drain type.'),
};
}
}
+16
View File
@@ -101,6 +101,11 @@ class Navbar extends Component
// Always use background job for all servers
RestartProxyJob::dispatch($this->server);
auditLog('ui.proxy.restarted', [
'team_id' => $this->server->team_id,
'server_uuid' => $this->server->uuid,
'server_name' => $this->server->name,
]);
} catch (\Throwable $e) {
$this->restartInitiated = false;
@@ -125,6 +130,11 @@ class Navbar extends Component
try {
$this->authorize('manageProxy', $this->server);
$activity = StartProxy::run($this->server, force: true);
auditLog('ui.proxy.started', [
'team_id' => $this->server->team_id,
'server_uuid' => $this->server->uuid,
'server_name' => $this->server->name,
]);
$this->dispatch('activityMonitor', $activity->id);
} catch (\Throwable $e) {
return handleError($e, $this);
@@ -136,6 +146,12 @@ class Navbar extends Component
try {
$this->authorize('manageProxy', $this->server);
StopProxy::dispatch($this->server, $forceStop);
auditLog('ui.proxy.stopped', [
'team_id' => $this->server->team_id,
'server_uuid' => $this->server->uuid,
'server_name' => $this->server->name,
'force' => $forceStop,
]);
} catch (\Throwable $e) {
return handleError($e, $this);
}
+9
View File
@@ -123,6 +123,15 @@ class TransferImport extends Component
$this->lastWarnings = array_values((array) data_get($result, 'warnings', []));
$this->importedServerUuid = $dryRun ? null : data_get($result, 'server_uuid');
if (! $dryRun) {
auditLog('ui.server.imported', [
'team_id' => $teamId,
'server_uuid' => $this->importedServerUuid,
'claimed' => (bool) data_get($result, 'claimed'),
'adopt_mode' => $this->adoptMode,
]);
}
if ($dryRun) {
$this->dispatch('success', 'Dry run completed — nothing was written.');
} elseif (data_get($result, 'claimed')) {
+6
View File
@@ -19,6 +19,9 @@ class Advanced extends Component
#[Validate('boolean')]
public bool $is_registration_enabled;
#[Validate('boolean')]
public bool $disable_registration_when_oauth_enabled;
#[Validate('boolean')]
public bool $do_not_track;
@@ -59,6 +62,7 @@ class Advanced extends Component
{
return [
'is_registration_enabled' => 'boolean',
'disable_registration_when_oauth_enabled' => 'boolean',
'do_not_track' => 'boolean',
'is_dns_validation_enabled' => 'boolean',
'custom_dns_servers' => ['nullable', 'string', new ValidDnsServers],
@@ -84,6 +88,7 @@ class Advanced extends Component
$this->allowed_ips = $this->settings->allowed_ips;
$this->do_not_track = $this->settings->do_not_track;
$this->is_registration_enabled = $this->settings->is_registration_enabled;
$this->disable_registration_when_oauth_enabled = $this->settings->disable_registration_when_oauth_enabled;
$this->is_dns_validation_enabled = $this->settings->is_dns_validation_enabled;
$this->is_api_enabled = $this->settings->is_api_enabled;
$this->disable_two_step_confirmation = $this->settings->disable_two_step_confirmation;
@@ -199,6 +204,7 @@ class Advanced extends Component
try {
$this->authorize('update', $this->settings);
$this->settings->is_registration_enabled = $this->is_registration_enabled;
$this->settings->disable_registration_when_oauth_enabled = $this->disable_registration_when_oauth_enabled;
$this->settings->do_not_track = $this->do_not_track;
$this->settings->is_dns_validation_enabled = $this->is_dns_validation_enabled;
$this->settings->custom_dns_servers = $this->custom_dns_servers;
+93 -31
View File
@@ -160,30 +160,59 @@ class SettingsEmail extends Component
$this->instantSave('Resend');
}
public function toggleSmtp()
{
try {
$this->resetErrorBag();
if ($this->smtpEnabled) {
$this->smtpEnabled = false;
$this->syncData(true);
$this->dispatch('success', 'SMTP settings updated.');
} else {
$this->validateSmtpSettings();
$this->smtpEnabled = true;
$this->resendEnabled = false;
$this->submitSmtp();
}
} catch (\Throwable $e) {
$this->syncData();
return handleError($e, $this);
}
}
public function toggleResend()
{
try {
$this->resetErrorBag();
if ($this->resendEnabled) {
$this->resendEnabled = false;
$this->syncData(true);
$this->dispatch('success', 'Resend settings updated.');
} else {
$this->validateResendSettings();
$this->resendEnabled = true;
$this->smtpEnabled = false;
$this->submitResend();
}
} catch (\Throwable $e) {
$this->syncData();
return handleError($e, $this);
}
}
public function submitSmtp()
{
try {
$this->authorize('update', $this->settings);
$this->validate([
'smtpEnabled' => 'boolean',
'smtpFromAddress' => 'required|email',
'smtpFromName' => 'required|string',
'smtpHost' => 'required|string',
'smtpPort' => 'required|numeric',
'smtpEncryption' => 'required|string|in:starttls,tls,none',
'smtpUsername' => 'nullable|string',
'smtpPassword' => 'nullable|string',
'smtpTimeout' => 'nullable|numeric',
'smtpEhloDomain' => ['nullable', 'string', new ValidHostname],
], [
'smtpFromAddress.required' => 'From Address is required.',
'smtpFromAddress.email' => 'Please enter a valid email address.',
'smtpFromName.required' => 'From Name is required.',
'smtpHost.required' => 'SMTP Host is required.',
'smtpPort.required' => 'SMTP Port is required.',
'smtpPort.numeric' => 'SMTP Port must be a number.',
'smtpEncryption.required' => 'Encryption type is required.',
]);
$this->validateSmtpSettings();
if ($this->smtpEnabled) {
$this->settings->resend_enabled = $this->resendEnabled = false;
}
$this->settings->smtp_enabled = $this->smtpEnabled;
$this->settings->smtp_host = $this->smtpHost;
@@ -210,17 +239,11 @@ class SettingsEmail extends Component
{
try {
$this->authorize('update', $this->settings);
$this->validate([
'resendEnabled' => 'boolean',
'resendApiKey' => $this->resendEnabled ? 'required|string' : 'nullable|string',
'smtpFromAddress' => 'required|email',
'smtpFromName' => 'required|string',
], [
'resendApiKey.required' => 'Resend API Key is required.',
'smtpFromAddress.required' => 'From Address is required.',
'smtpFromAddress.email' => 'Please enter a valid email address.',
'smtpFromName.required' => 'From Name is required.',
]);
$this->validateResendSettings();
if ($this->resendEnabled) {
$this->settings->smtp_enabled = $this->smtpEnabled = false;
}
$this->settings->resend_enabled = $this->resendEnabled;
$this->settings->resend_api_key = $this->resendApiKey;
@@ -237,6 +260,45 @@ class SettingsEmail extends Component
}
}
private function validateSmtpSettings(): void
{
$this->validate([
'smtpEnabled' => 'boolean',
'smtpFromAddress' => 'required|email',
'smtpFromName' => 'required|string',
'smtpHost' => 'required|string',
'smtpPort' => 'required|numeric',
'smtpEncryption' => 'required|string|in:starttls,tls,none',
'smtpUsername' => 'nullable|string',
'smtpPassword' => 'nullable|string',
'smtpTimeout' => 'nullable|numeric',
'smtpEhloDomain' => ['nullable', 'string', new ValidHostname],
], [
'smtpFromAddress.required' => 'From Address is required.',
'smtpFromAddress.email' => 'Please enter a valid email address.',
'smtpFromName.required' => 'From Name is required.',
'smtpHost.required' => 'SMTP Host is required.',
'smtpPort.required' => 'SMTP Port is required.',
'smtpPort.numeric' => 'SMTP Port must be a number.',
'smtpEncryption.required' => 'Encryption type is required.',
]);
}
private function validateResendSettings(): void
{
$this->validate([
'resendEnabled' => 'boolean',
'resendApiKey' => $this->resendEnabled ? 'required|string' : 'nullable|string',
'smtpFromAddress' => 'required|email',
'smtpFromName' => 'required|string',
], [
'resendApiKey.required' => 'Resend API Key is required.',
'smtpFromAddress.required' => 'From Address is required.',
'smtpFromAddress.email' => 'Please enter a valid email address.',
'smtpFromName.required' => 'From Name is required.',
]);
}
public function sendTestEmail()
{
try {
+232 -114
View File
@@ -2,53 +2,89 @@
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;
class SettingsOauth extends Component
{
use AuthorizesRequests;
public InstanceSettings $settings;
public $oauth_settings_map;
protected function rules()
public ?string $selectedProvider = null;
public bool $disable_registration_when_oauth_enabled = false;
protected function rules(): array
{
return OauthSetting::all()->reduce(function ($carry, $setting) {
$carry["oauth_settings_map.$setting->provider.enabled"] = 'required';
$carry["oauth_settings_map.$setting->provider.client_id"] = 'nullable';
$carry["oauth_settings_map.$setting->provider.client_secret"] = 'nullable';
$carry["oauth_settings_map.$setting->provider.redirect_uri"] = 'nullable';
$carry["oauth_settings_map.$setting->provider.tenant"] = 'nullable';
$carry["oauth_settings_map.$setting->provider.base_url"] = 'nullable';
return $this->validationRules();
}
private function validationRules(?string $provider = null): array
{
$rules = OauthSetting::all()->reduce(function ($carry, $setting) use ($provider) {
if ($provider !== null && $setting->provider !== $provider) {
return $carry;
}
$carry["oauth_settings_map.$setting->provider.enabled"] = 'required|boolean';
$carry["oauth_settings_map.$setting->provider.client_id"] = 'nullable|string';
$carry["oauth_settings_map.$setting->provider.client_secret"] = 'nullable|string';
$carry["oauth_settings_map.$setting->provider.redirect_uri"] = 'nullable|string|max:2048|url:http,https';
$carry["oauth_settings_map.$setting->provider.tenant"] = 'nullable|string';
$carry["oauth_settings_map.$setting->provider.base_url"] = 'nullable|string|max:2048|url:http,https';
$carry["oauth_settings_map.$setting->provider.custom_label"] = 'nullable|string|max:255';
$carry["oauth_settings_map.$setting->provider.scopes"] = 'nullable|string|max:1000';
$carry["oauth_settings_map.$setting->provider.allow_registration"] = 'boolean';
$carry["oauth_settings_map.$setting->provider.auto_join_root_team"] = 'boolean';
$carry["oauth_settings_map.$setting->provider.require_email_verified"] = 'boolean';
$carry["oauth_settings_map.$setting->provider.use_pkce"] = 'boolean';
$carry["oauth_settings_map.$setting->provider.clock_skew_seconds"] = 'nullable|integer|min:0|max:600';
return $carry;
}, []);
if ($provider === null) {
$rules['disable_registration_when_oauth_enabled'] = 'boolean';
}
return $rules;
}
public function mount()
public function mount(?string $provider = null): ?RedirectResponse
{
if (! isInstanceAdmin()) {
return redirect()->route('home');
}
$this->oauth_settings_map = OauthSetting::all()->sortBy('provider')->reduce(function ($carry, $setting) {
$carry[$setting->provider] = [
'id' => $setting->id,
'provider' => $setting->provider,
'enabled' => $setting->enabled,
'client_id' => $setting->client_id,
'client_secret' => $setting->client_secret,
'redirect_uri' => $setting->redirect_uri,
'tenant' => $setting->tenant,
'base_url' => $setting->base_url,
];
return $carry;
}, []);
$this->settings = instanceSettings();
$this->selectedProvider = $provider;
$this->disable_registration_when_oauth_enabled = (bool) $this->settings->disable_registration_when_oauth_enabled;
$this->oauth_settings_map = OauthSetting::all()
->sortBy(fn (OauthSetting $setting): string => $setting->isOidc() ? '' : $setting->provider)
->reduce(function ($carry, $setting) {
$carry[$setting->provider] = $this->oauthSettingToArray($setting);
return $carry;
}, []);
if ($this->selectedProvider !== null && ! array_key_exists($this->selectedProvider, $this->oauth_settings_map)) {
abort(404);
}
return null;
}
private function updateOauthSettings(?string $provider = null)
private function updateOauthSettings(?string $provider = null): void
{
$this->validate($this->validationRules($provider));
if ($provider) {
$oauthData = $this->oauth_settings_map[$provider];
$oauth = OauthSetting::find($oauthData['id']);
@@ -57,78 +93,128 @@ class SettingsOauth extends Component
throw new \Exception('OAuth setting for '.$provider.' not found. It may have been deleted.');
}
$oauth->fill([
'enabled' => $oauthData['enabled'],
'client_id' => $oauthData['client_id'],
'client_secret' => $oauthData['client_secret'],
'redirect_uri' => $oauthData['redirect_uri'],
'tenant' => $oauthData['tenant'],
'base_url' => $oauthData['base_url'],
]);
if ($oauthData['enabled'] && ! $oauth->couldBeEnabled()) {
$oauth->update(['enabled' => false]);
throw new \Exception('OAuth settings are not complete for '.$oauth->provider.'.<br/>Please fill in all required fields.');
}
$this->fillOauthSetting($oauth, $oauthData);
$this->ensureProviderCanBeEnabled($oauth);
$oauth->save();
// Update the array with fresh data
$this->oauth_settings_map[$provider] = [
'id' => $oauth->id,
'provider' => $oauth->provider,
'enabled' => $oauth->enabled,
'client_id' => $oauth->client_id,
'client_secret' => $oauth->client_secret,
'redirect_uri' => $oauth->redirect_uri,
'tenant' => $oauth->tenant,
'base_url' => $oauth->base_url,
];
$this->oauth_settings_map[$provider] = $this->oauthSettingToArray($oauth);
$this->dispatch('success', 'OAuth settings for '.$oauth->provider.' updated successfully!');
} else {
$errors = [];
foreach (array_values($this->oauth_settings_map) as $settingData) {
$oauth = OauthSetting::find($settingData['id']);
if (! $oauth) {
$errors[] = "OAuth setting for provider '{$settingData['provider']}' not found. It may have been deleted.";
continue;
}
$oauth->fill([
'enabled' => $settingData['enabled'],
'client_id' => $settingData['client_id'],
'client_secret' => $settingData['client_secret'],
'redirect_uri' => $settingData['redirect_uri'],
'tenant' => $settingData['tenant'],
'base_url' => $settingData['base_url'],
]);
if ($settingData['enabled'] && ! $oauth->couldBeEnabled()) {
$oauth->enabled = false;
$errors[] = "OAuth settings are incomplete for '{$oauth->provider}'. Required fields are missing. The provider has been disabled.";
}
$oauth->save();
// Update the array with fresh data
$this->oauth_settings_map[$oauth->provider] = [
'id' => $oauth->id,
'provider' => $oauth->provider,
'enabled' => $oauth->enabled,
'client_id' => $oauth->client_id,
'client_secret' => $oauth->client_secret,
'redirect_uri' => $oauth->redirect_uri,
'tenant' => $oauth->tenant,
'base_url' => $oauth->base_url,
];
}
if (! empty($errors)) {
$this->dispatch('error', implode('<br/>', $errors));
}
return;
}
$errors = [];
foreach (array_values($this->oauth_settings_map) as $settingData) {
$oauth = OauthSetting::find($settingData['id']);
if (! $oauth) {
$errors[] = "OAuth setting for provider '{$settingData['provider']}' not found. It may have been deleted.";
continue;
}
$this->fillOauthSetting($oauth, $settingData);
if ($oauth->enabled && ! $oauth->couldBeEnabled()) {
$oauth->enabled = false;
$errors[] = "OAuth settings are incomplete for '{$oauth->provider}'. Required fields are missing. The provider has been disabled.";
}
if ($oauth->enabled && $oauth->isOidc() && ! in_array('openid', $oauth->scopeList(), true)) {
$oauth->enabled = false;
$errors[] = "OIDC scopes must include 'openid'. The provider has been disabled.";
}
$oauth->save();
$this->oauth_settings_map[$oauth->provider] = $this->oauthSettingToArray($oauth);
}
instanceSettings()->update([
'disable_registration_when_oauth_enabled' => $this->disable_registration_when_oauth_enabled,
]);
if (! empty($errors)) {
$this->dispatch('error', implode('<br/>', $errors));
}
}
private function fillOauthSetting(OauthSetting $oauth, array $data): void
{
$oauth->fill([
'enabled' => (bool) ($data['enabled'] ?? false),
'client_id' => $data['client_id'] ?? null,
'client_secret' => $data['client_secret'] ?? null,
'redirect_uri' => $this->nullableString($data['redirect_uri'] ?? null),
'tenant' => $data['tenant'] ?? null,
'base_url' => $this->nullableString($data['base_url'] ?? null),
'custom_label' => $data['custom_label'] ?? null,
'scopes' => $data['scopes'] ?? null,
'allow_registration' => (bool) ($data['allow_registration'] ?? false),
'auto_join_root_team' => (bool) ($data['auto_join_root_team'] ?? false),
'require_email_verified' => (bool) ($data['require_email_verified'] ?? true),
'use_pkce' => (bool) ($data['use_pkce'] ?? true),
'clock_skew_seconds' => (int) ($data['clock_skew_seconds'] ?? 60),
]);
}
private function nullableString(mixed $value): ?string
{
if ($value === null) {
return null;
}
$value = trim((string) $value);
return $value === '' ? null : $value;
}
private function ensureProviderCanBeEnabled(OauthSetting $oauth): void
{
if (! $oauth->enabled) {
return;
}
if (! $oauth->couldBeEnabled()) {
$oauth->update(['enabled' => false]);
throw new \Exception('OAuth settings are not complete for '.$oauth->provider.'.<br/>Please fill in all required fields.');
}
if ($oauth->isOidc() && ! in_array('openid', $oauth->scopeList(), true)) {
$oauth->update(['enabled' => false]);
throw new \Exception("OIDC scopes must include 'openid'.");
}
}
private function oauthSettingToArray(OauthSetting $setting): array
{
return [
'id' => $setting->id,
'provider' => $setting->provider,
'enabled' => $setting->enabled,
'client_id' => $setting->client_id,
'client_secret' => $setting->client_secret,
'redirect_uri' => $setting->redirect_uri,
'tenant' => $setting->tenant,
'base_url' => $setting->base_url,
'custom_label' => $setting->custom_label,
'scopes' => $setting->scopes ?: 'openid email profile',
'allow_registration' => $setting->allow_registration,
'auto_join_root_team' => $setting->auto_join_root_team,
'require_email_verified' => $setting->require_email_verified ?? true,
'use_pkce' => $setting->use_pkce ?? true,
'clock_skew_seconds' => $setting->clock_skew_seconds ?? 60,
'label' => $this->providerLabel($setting->provider),
];
}
public function providerLabel(string $provider): string
{
return match ($provider) {
'oidc' => 'OpenID Connect',
'gitlab' => 'GitLab',
default => str($provider)->headline()->toString(),
};
}
public function instantSave(string $provider)
@@ -141,56 +227,88 @@ class SettingsOauth extends Component
}
}
public function toggleProvider(string $provider): mixed
public function toggleProvider(string $provider)
{
try {
$this->authorize('update', instanceSettings());
if (! array_key_exists($provider, $this->oauth_settings_map)) {
throw new \Exception('OAuth provider not found.');
abort(404);
}
$enabling = ! $this->oauth_settings_map[$provider]['enabled'];
if ($enabling) {
$this->validate($this->providerRules($provider));
if (! (bool) $this->oauth_settings_map[$provider]['enabled']) {
$this->validateProviderCanBeEnabled($provider);
}
$this->oauth_settings_map[$provider]['enabled'] = $enabling;
$this->oauth_settings_map[$provider]['enabled'] = ! (bool) $this->oauth_settings_map[$provider]['enabled'];
$this->updateOauthSettings($provider);
} catch (\Throwable $e) {
} catch (\Exception $e) {
$oauth = OauthSetting::where('provider', $provider)->first();
if ($oauth) {
$this->oauth_settings_map[$provider] = $this->oauthSettingToArray($oauth);
}
return handleError($e, $this);
}
return null;
}
private function providerRules(string $provider): array
private function validateProviderCanBeEnabled(string $provider): void
{
$prefix = "oauth_settings_map.$provider";
$rules = [
"$prefix.client_id" => 'required',
"$prefix.client_secret" => 'required',
];
$this->validate($this->validationRules($provider));
if ($provider === 'azure') {
$rules["$prefix.tenant"] = 'required';
$oauth = OauthSetting::find($this->oauth_settings_map[$provider]['id']);
if (! $oauth) {
throw new \Exception('OAuth setting for '.$provider.' not found. It may have been deleted.');
}
if (in_array($provider, ['authentik', 'clerk'], true)) {
$rules["$prefix.base_url"] = 'required';
$this->fillOauthSetting($oauth, [
...$this->oauth_settings_map[$provider],
'enabled' => true,
]);
if (! $oauth->couldBeEnabled()) {
throw new \Exception('OAuth settings are not complete for '.$oauth->provider.'.<br/>Please fill in all required fields.');
}
return $rules;
if ($oauth->isOidc() && ! in_array('openid', $oauth->scopeList(), true)) {
throw new \Exception("OIDC scopes must include 'openid'.");
}
}
public function submit()
public function saveRegistrationPolicy(): void
{
$this->authorize('update', instanceSettings());
$this->validate([
'disable_registration_when_oauth_enabled' => 'boolean',
]);
instanceSettings()->update([
'disable_registration_when_oauth_enabled' => $this->disable_registration_when_oauth_enabled,
]);
$this->dispatch('success', 'Authentication settings updated successfully!');
}
public function submit(): void
{
try {
$this->authorize('update', instanceSettings());
$this->updateOauthSettings();
$this->dispatch('success', 'Instance settings updated successfully!');
} catch (\Throwable $e) {
return handleError($e, $this);
$this->updateOauthSettings($this->selectedProvider);
if ($this->selectedProvider === null) {
$this->dispatch('success', 'Instance settings updated successfully!');
}
} catch (ValidationException $e) {
throw $e;
} catch (\Exception $e) {
if ($this->selectedProvider !== null) {
$oauth = OauthSetting::where('provider', $this->selectedProvider)->first();
if ($oauth) {
$this->oauth_settings_map[$this->selectedProvider] = $this->oauthSettingToArray($oauth);
}
}
handleError($e, $this);
}
}
}
+73
View File
@@ -0,0 +1,73 @@
<?php
namespace App\Livewire\Team;
use App\Models\AuditEvent;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Str;
use Livewire\Component;
use Livewire\WithPagination;
class AuditLog extends Component
{
use WithPagination;
public string $search = '';
public string $action = 'all';
public string $source = 'all';
public int $perPage = 25;
public function boot(): void
{
abort_unless(auth()->user()->isAdminOfTeam(currentTeam()->id), 403);
}
public function updatedSearch(): void
{
$this->resetPage();
}
public function updatedAction(): void
{
$this->resetPage();
}
public function updatedSource(): void
{
$this->resetPage();
}
public function updatedPerPage(): void
{
$this->perPage = max(10, min(100, $this->perPage));
$this->resetPage();
}
public function render(): View
{
$search = trim($this->search);
$teamId = currentTeam()->id;
$canViewInstanceEvents = $teamId === 0 && isInstanceAdmin();
$visibleEvents = AuditEvent::query()->visibleToTeam($teamId, $canViewInstanceEvents);
$actionOptions = [
['value' => 'all', 'label' => 'All actions'],
...$visibleEvents->clone()
->select('action')
->distinct()
->orderBy('action')
->pluck('action')
->map(fn (string $action): array => ['value' => $action, 'label' => Str::headline($action)])
->all(),
];
$events = AuditEvent::query()
->visibleToTeam($teamId, $canViewInstanceEvents)
->filtered($search, $this->action, $this->source)
->latestFirst()
->paginate($this->perPage);
return view('livewire.team.audit-log', ['actionOptions' => $actionOptions, 'events' => $events]);
}
}
+7
View File
@@ -22,6 +22,8 @@ class Invitations extends Component
$this->authorize('manageInvitations', currentTeam());
$invitation = TeamInvitation::ownedByCurrentTeam()->findOrFail($invitation_id);
$invitationEmail = $invitation->email;
$invitationUuid = $invitation->uuid;
DB::transaction(function () use ($invitation): void {
$user = User::whereEmail($invitation->email)->first();
if (filled($user)) {
@@ -30,6 +32,11 @@ class Invitations extends Component
$invitation->delete();
});
auditLog('ui.team_invitation.revoked', [
'team_id' => currentTeam()->id,
'invitation_uuid' => $invitationUuid,
'invitation_email' => $invitationEmail,
]);
$this->refreshInvitations();
$this->dispatch('success', 'Invitation revoked.');
} catch (\Exception) {
+7
View File
@@ -103,6 +103,13 @@ class InviteLink extends Component
'link' => $link,
'via' => $sendEmail ? 'email' : 'link',
]);
auditLog('ui.team_invitation.created', [
'team_id' => currentTeam()->id,
'invitation_uuid' => $invitation->uuid,
'invitation_email' => $invitation->email,
'role' => $invitation->role,
'via' => $invitation->via,
]);
if ($sendEmail) {
$mail = new MailMessage;
$mail->view('emails.invitation-link', [
+20
View File
@@ -30,6 +30,7 @@ class Member extends Component
$this->member->teams()->updateExistingPivot($teamId, ['role' => Role::ADMIN->value]);
RevokeUserTeamTokens::forUserTeam($this->member, $teamId);
});
$this->auditRoleUpdate($teamId, Role::ADMIN);
$this->dispatch('reloadWindow');
} catch (\Exception $e) {
$this->dispatch('error', $e->getMessage());
@@ -50,6 +51,7 @@ class Member extends Component
$this->member->teams()->updateExistingPivot($teamId, ['role' => Role::OWNER->value]);
RevokeUserTeamTokens::forUserTeam($this->member, $teamId);
});
$this->auditRoleUpdate($teamId, Role::OWNER);
$this->dispatch('reloadWindow');
} catch (\Exception $e) {
$this->dispatch('error', $e->getMessage());
@@ -70,6 +72,7 @@ class Member extends Component
$this->member->teams()->updateExistingPivot($teamId, ['role' => Role::MEMBER->value]);
RevokeUserTeamTokens::forUserTeam($this->member, $teamId);
});
$this->auditRoleUpdate($teamId, Role::MEMBER);
$this->dispatch('reloadWindow');
} catch (\Exception $e) {
$this->dispatch('error', $e->getMessage());
@@ -90,6 +93,12 @@ class Member extends Component
$this->member->teams()->detach($teamId);
RevokeUserTeamTokens::forUserTeam($this->member, $teamId);
});
auditLog('ui.team_member.removed', [
'team_id' => $teamId,
'member_id' => $this->member->id,
'member_name' => $this->member->name,
'member_email' => $this->member->email,
]);
// Clear cache for the removed user - both old and new key formats
Cache::forget("team:{$this->member->id}");
Cache::forget("user:{$this->member->id}:team:{$teamId}");
@@ -103,4 +112,15 @@ class Member extends Component
{
return $this->member->teams()->where('teams.id', currentTeam()->id)->first()?->pivot?->role;
}
private function auditRoleUpdate(int $teamId, Role $role): void
{
auditLog('ui.team_member.role_updated', [
'team_id' => $teamId,
'member_id' => $this->member->id,
'member_name' => $this->member->name,
'member_email' => $this->member->email,
'role' => $role->value,
]);
}
}
+5 -3
View File
@@ -9,11 +9,14 @@ use App\Services\DeploymentConfiguration\ConfigurationDiff;
use App\Services\DeploymentConfiguration\ConfigurationDiffer;
use App\Support\DomainPortOverrides;
use App\Support\DomainUrlParts;
use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasConfiguration;
use App\Traits\HasMetrics;
use App\Traits\HasNoindexDomains;
use App\Traits\HasSafeStringAttribute;
use App\Traits\HasSecretManager;
use Database\Factories\ApplicationFactory;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -123,10 +126,8 @@ use Symfony\Component\Yaml\Yaml;
class Application extends BaseModel
{
use ClearsGlobalSearchCache, HasConfiguration, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, SoftDeletes;
/** @use HasFactory<ApplicationFactory> */
use HasFactory;
use Auditable, ClearsGlobalSearchCache, HasConfiguration, HasFactory, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
public const MAX_DOCKER_COMPOSE_SIZE_BYTES = 5 * 1024 * 1024;
@@ -394,6 +395,7 @@ class Application extends BaseModel
$application->persistentStorages()->delete();
$application->environment_variables()->delete();
$application->environment_variables_preview()->delete();
$application->secretManagerLink()->delete();
foreach ($application->scheduled_tasks as $task) {
$task->delete();
}
+39
View File
@@ -3,6 +3,7 @@
namespace App\Models;
use App\Casts\EncryptedArrayCast;
use App\Enums\ApplicationDeploymentStatus;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
@@ -44,6 +45,44 @@ use OpenApi\Attributes as OA;
)]
class ApplicationDeploymentQueue extends Model
{
protected static function booted(): void
{
static::created(function (ApplicationDeploymentQueue $deployment): void {
if (! auth()->check() || ! $deployment->rollback) {
return;
}
$application = $deployment->application;
$source = $deployment->is_api ? 'api' : 'ui';
auditLog("{$source}.application.rollback", [
'team_id' => $application?->team()?->id,
'application_uuid' => $application?->uuid,
'application_name' => $application?->name,
'deployment_uuid' => $deployment->deployment_uuid,
'commit' => $deployment->commit,
]);
});
static::updated(function (ApplicationDeploymentQueue $deployment): void {
if (! auth()->check()
|| ! $deployment->wasChanged('status')
|| $deployment->status !== ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
return;
}
$application = $deployment->application;
$source = $deployment->is_api ? 'api' : 'ui';
auditLog("{$source}.deployment.cancelled", [
'team_id' => $application?->team()?->id,
'application_uuid' => $application?->uuid,
'application_name' => $application?->name,
'deployment_uuid' => $deployment->deployment_uuid,
]);
});
}
protected $fillable = [
'application_id',
'deployment_uuid',
+211
View File
@@ -0,0 +1,211 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Throwable;
class AuditEvent extends Model
{
use HasFactory;
public const UPDATED_AT = null;
protected $fillable = [
'team_id',
'event',
'source',
'action',
'actor_type',
'actor_id',
'actor_name',
'actor_email',
'actor_token_id',
'actor_token_name',
'resource_type',
'resource_uuid',
'resource_name',
'description',
'metadata',
'ip_address',
'user_agent',
'created_at',
];
protected function casts(): array
{
return [
'metadata' => 'array',
'created_at' => 'datetime',
];
}
public function scopeVisibleToTeam(Builder $query, int $teamId, bool $includeInstanceEvents = false): Builder
{
return $query->where(function (Builder $query) use ($includeInstanceEvents, $teamId): void {
$query->where('team_id', $teamId)
->when($includeInstanceEvents, fn (Builder $query) => $query->orWhereNull('team_id'));
});
}
public function scopeFiltered(
Builder $query,
string $search = '',
string $action = 'all',
string $source = 'all',
bool $searchSensitiveFields = true,
): Builder {
return $query
->when($action !== 'all', fn (Builder $query) => $query->where('action', $action))
->when($source !== 'all', fn (Builder $query) => $query->where('source', $source))
->when($search !== '', function (Builder $query) use ($search, $searchSensitiveFields): void {
$query->where(function (Builder $query) use ($search, $searchSensitiveFields): void {
$query->where('event', 'like', "%{$search}%")
->orWhere('description', 'like', "%{$search}%")
->orWhere('resource_name', 'like', "%{$search}%")
->orWhere('actor_name', 'like', "%{$search}%")
->when($searchSensitiveFields, fn (Builder $query) => $query->orWhere('actor_email', 'like', "%{$search}%"));
});
});
}
public function scopeLatestFirst(Builder $query): Builder
{
return $query->latest('created_at')->latest('id');
}
/**
* @param array<string, mixed> $context
*/
public static function record(string $event, array $context = []): void
{
try {
$attributes = self::attributesFor($event, $context);
DB::afterCommit(function () use ($attributes): void {
defer(function () use ($attributes): void {
try {
self::query()->create($attributes);
} catch (Throwable $exception) {
Log::warning('Audit event persistence failed', [
'event' => $attributes['event'],
'exception' => $exception::class,
]);
}
})->always();
});
} catch (Throwable $exception) {
Log::warning('Audit event preparation failed', [
'event' => $event,
'exception' => $exception::class,
]);
}
}
/**
* @param array<string, mixed> $context
* @return array<string, mixed>
*/
private static function attributesFor(string $event, array $context): array
{
$teamId = data_get(auth()->user()?->currentAccessToken(), 'team_id')
?? data_get($context, 'team_id')
?? currentTeam()?->id
?? self::teamIdFromContext($context);
$parts = explode('.', $event);
$source = $parts[0] ?? 'system';
$resourceType = data_get($context, 'resource') ?? ($parts[1] ?? null);
$action = data_get($context, 'action') ?? (end($parts) ?: 'event');
$resourceUuid = self::firstContextValue($context, $resourceType ? "{$resourceType}_uuid" : null, '_uuid');
$resourceName = self::firstContextValue($context, $resourceType ? "{$resourceType}_name" : null, '_name');
$user = auth()->user();
$token = $user?->currentAccessToken();
$actorType = match (true) {
in_array($source, ['mcp', 'webhook', 'system', 'scheduler'], true) => $source,
$token !== null => 'api_token',
$user !== null => 'user',
default => 'system',
};
return [
'team_id' => $teamId,
'event' => $event,
'source' => $source,
'action' => $action,
'actor_type' => $actorType,
'actor_id' => $user?->id,
'actor_name' => $user?->name,
'actor_email' => $user?->email,
'actor_token_id' => $token?->id,
'actor_token_name' => $token?->name,
'resource_type' => $resourceType,
'resource_uuid' => $resourceUuid,
'resource_name' => $resourceName,
'description' => data_get($context, 'audit_description')
?? trim(($resourceName ?? Str::headline((string) $resourceType)).' '.Str::headline($action)),
'metadata' => self::redact($context),
'ip_address' => app()->bound('request') ? request()->ip() : null,
'user_agent' => app()->bound('request') ? Str::limit((string) request()->userAgent(), 200, '') : null,
];
}
/**
* @param array<string, mixed> $context
*/
private static function teamIdFromContext(array $context): ?int
{
$applicationUuid = data_get($context, 'application_uuid');
if (! is_string($applicationUuid) || $applicationUuid === '') {
return null;
}
return Application::query()
->where('uuid', $applicationUuid)
->first()?->team()?->id;
}
public static function pruneExpired(): int
{
return self::query()
->where('created_at', '<', now()->subDays(90))
->delete();
}
/**
* @param array<string, mixed> $context
*/
private static function firstContextValue(array $context, ?string $preferredKey, string $suffix): mixed
{
if ($preferredKey !== null && filled(data_get($context, $preferredKey))) {
return data_get($context, $preferredKey);
}
$key = Arr::first(array_keys($context), fn (string $key): bool => str_ends_with($key, $suffix));
return $key ? data_get($context, $key) : null;
}
private static function redact(mixed $value, ?string $key = null): mixed
{
if ($key !== null && preg_match('/password|secret|token|private_key|signature|credential|invitation_email|api_key|access_key|authorization|cookie/i', $key)) {
return '[REDACTED]';
}
if (! is_array($value)) {
return $value;
}
return collect($value)
->mapWithKeys(fn (mixed $item, string|int $itemKey): array => [
$itemKey => self::redact($item, (string) $itemKey),
])
->all();
}
}
+2 -1
View File
@@ -2,6 +2,7 @@
namespace App\Models;
use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -21,8 +22,8 @@ use OpenApi\Attributes as OA;
)]
class Environment extends BaseModel
{
use Auditable, HasFactory;
use ClearsGlobalSearchCache;
use HasFactory;
use HasSafeStringAttribute;
protected $fillable = [
+33 -11
View File
@@ -4,6 +4,7 @@ namespace App\Models;
use App\Models\EnvironmentVariable as ModelsEnvironmentVariable;
use App\Support\ValidationPatterns;
use App\Traits\Auditable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use OpenApi\Attributes as OA;
@@ -34,6 +35,8 @@ use OpenApi\Attributes as OA;
)]
class EnvironmentVariable extends BaseModel
{
use Auditable;
public const BUILDPACK_CONTROL_VARIABLE_PREFIXES = ['NIXPACKS_', 'RAILPACK_'];
protected $attributes = [
@@ -249,17 +252,21 @@ class EnvironmentVariable extends BaseModel
protected function isShared(): Attribute
{
return Attribute::make(
get: function () {
$type = str($this->value)->after('{{')->before('.')->value;
if (str($this->value)->startsWith('{{'.$type) && str($this->value)->endsWith('}}')) {
return true;
}
return false;
}
get: fn () => $this->isSharedReference(),
);
}
private function isSharedReference(): bool
{
if (blank($this->value)) {
return false;
}
$types = implode('|', SHARED_VARIABLE_TYPES);
return preg_match('/^{{\s*(?:'.$types.')\..*}}$/s', trim($this->value)) === 1;
}
public function get_real_environment_variables_with_server(?string $environment_variable = null, $resource = null, $server = null)
{
return $this->get_real_environment_variables_internal($environment_variable, $resource, $server);
@@ -302,6 +309,23 @@ class EnvironmentVariable extends BaseModel
return $real_value;
}
public function resolveReferencedValue(): ?string
{
$value = $this->value;
if ($this->is_literal || blank($value) || ! str($value)->startsWith('$')) {
return $value;
}
$referencedKey = str($value)->after('$')->trim('{}')->value();
return static::where('resourceable_type', $this->resourceable_type)
->where('resourceable_id', $this->resourceable_id)
->where('is_preview', (bool) $this->is_preview)
->where('key', $referencedKey)
->first()?->value ?? $value;
}
private function get_real_environment_variables(?string $environment_variable = null, $resource = null)
{
return $this->get_real_environment_variables_internal($environment_variable, $resource);
@@ -389,8 +413,6 @@ class EnvironmentVariable extends BaseModel
protected function updateIsShared(): void
{
$type = str($this->value)->after('{{')->before('.')->value;
$isShared = str($this->value)->startsWith('{{'.$type) && str($this->value)->endsWith('}}');
$this->is_shared = $isShared;
$this->is_shared = $this->isSharedReference();
}
}
+3
View File
@@ -2,11 +2,14 @@
namespace App\Models;
use App\Traits\Auditable;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Support\Facades\DB;
class GithubApp extends BaseModel
{
use Auditable;
public function delete(): ?bool
{
return DB::transaction(fn () => parent::delete());
+3
View File
@@ -2,12 +2,15 @@
namespace App\Models;
use App\Traits\Auditable;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Support\Facades\Crypt;
class GitlabApp extends BaseModel
{
use Auditable;
protected $fillable = [
'name',
'organization',
+16
View File
@@ -22,6 +22,7 @@ class InstanceSettings extends Model
'do_not_track',
'is_auto_update_enabled',
'is_registration_enabled',
'disable_registration_when_oauth_enabled',
'next_channel',
'smtp_enabled',
'smtp_from_address',
@@ -88,6 +89,8 @@ class InstanceSettings extends Model
'allowed_ip_ranges' => 'array',
'is_auto_update_enabled' => 'boolean',
'is_registration_enabled' => 'boolean',
'disable_registration_when_oauth_enabled' => 'boolean',
'auto_update_frequency' => 'string',
'update_check_frequency' => 'string',
'sentinel_token' => 'encrypted',
@@ -115,6 +118,19 @@ class InstanceSettings extends Model
});
}
public function isPasswordRegistrationAllowed(): bool
{
if (! $this->is_registration_enabled) {
return false;
}
if (! $this->disable_registration_when_oauth_enabled) {
return true;
}
return ! OauthSetting::where('enabled', true)->exists();
}
public function fqdn(): Attribute
{
return Attribute::make(
+78
View File
@@ -0,0 +1,78 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class IntegrationToken extends BaseModel
{
public const SECRET_MANAGER_PROVIDERS = ['doppler', 'infisical', 'vault'];
public const PROVIDER_NAMES = [
'cloudflare' => 'Cloudflare',
'doppler' => 'Doppler',
'infisical' => 'Infisical',
'vault' => 'HashiCorp Vault',
];
protected $fillable = [
'team_id',
'provider',
'name',
'token',
'capabilities',
'metadata',
];
protected $hidden = [
'token',
];
protected function casts(): array
{
return [
'token' => 'encrypted',
'capabilities' => 'array',
'metadata' => 'array',
];
}
public function team(): BelongsTo
{
return $this->belongsTo(Team::class);
}
public function secretManagerLinks(): HasMany
{
return $this->hasMany(SecretManagerLink::class);
}
public function isSecretManager(): bool
{
return in_array($this->provider, self::SECRET_MANAGER_PROVIDERS, true);
}
public function providerName(): string
{
return self::PROVIDER_NAMES[$this->provider] ?? ucfirst($this->provider);
}
public function dopplerTokenType(): ?string
{
if ($this->provider !== 'doppler') {
return null;
}
return match (true) {
str_starts_with($this->token, 'dp.st.') => 'service',
str_starts_with($this->token, 'dp.sa.') => 'service_account',
default => null,
};
}
public static function ownedByCurrentTeam()
{
return self::query()->where('team_id', currentTeam()->id);
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class OauthIdentity extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'provider',
'issuer',
'provider_user_id',
'email',
'raw_claims',
'last_login_at',
];
protected function casts(): array
{
return [
'raw_claims' => 'array',
'last_login_at' => 'datetime',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
+50 -1
View File
@@ -11,7 +11,19 @@ class OauthSetting extends Model
{
use HasFactory;
protected $fillable = ['provider', 'client_id', 'client_secret', 'redirect_uri', 'tenant', 'base_url', 'enabled'];
protected $fillable = ['provider', 'client_id', 'client_secret', 'redirect_uri', 'tenant', 'base_url', 'enabled', 'custom_label', 'scopes', 'allow_registration', 'auto_join_root_team', 'require_email_verified', 'use_pkce', 'clock_skew_seconds'];
protected function casts(): array
{
return [
'enabled' => 'boolean',
'allow_registration' => 'boolean',
'auto_join_root_team' => 'boolean',
'require_email_verified' => 'boolean',
'use_pkce' => 'boolean',
'clock_skew_seconds' => 'integer',
];
}
protected $hidden = [
'client_secret',
@@ -32,9 +44,46 @@ class OauthSetting extends Model
return filled($this->client_id) && filled($this->client_secret) && filled($this->tenant);
case 'authentik':
case 'clerk':
case 'oidc':
return filled($this->client_id) && filled($this->client_secret) && filled($this->base_url);
default:
return filled($this->client_id) && filled($this->client_secret);
}
}
/**
* @return array<int, string>
*/
public function scopeList(): array
{
$scopes = str($this->scopes ?: 'openid email profile')
->replace(',', ' ')
->explode(' ')
->map(fn (string $scope) => trim($scope))
->filter()
->unique()
->values()
->all();
return $scopes === [] ? ['openid', 'email', 'profile'] : $scopes;
}
public function loginLabel(): string
{
if (filled($this->custom_label)) {
return $this->custom_label;
}
$envLabel = config("services.{$this->provider}.custom_label");
if (filled($envLabel)) {
return $envLabel;
}
return __("auth.login.{$this->provider}");
}
public function isOidc(): bool
{
return $this->provider === 'oidc';
}
}
+2 -1
View File
@@ -2,6 +2,7 @@
namespace App\Models;
use App\Traits\Auditable;
use App\Traits\HasSafeStringAttribute;
use DanHarrin\LivewireRateLimiting\WithRateLimiting;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -31,7 +32,7 @@ use phpseclib3\Crypt\PublicKeyLoader;
)]
class PrivateKey extends BaseModel
{
use HasFactory, HasSafeStringAttribute, WithRateLimiting;
use Auditable, HasFactory, HasSafeStringAttribute, WithRateLimiting;
protected $fillable = [
'name',
+5 -2
View File
@@ -2,6 +2,7 @@
namespace App\Models;
use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -20,8 +21,8 @@ use OpenApi\Attributes as OA;
)]
class Project extends BaseModel
{
use Auditable, HasFactory;
use ClearsGlobalSearchCache;
use HasFactory;
use HasSafeStringAttribute;
protected $fillable = [
@@ -63,7 +64,9 @@ class Project extends BaseModel
]);
});
static::deleting(function ($project) {
$project->environments()->delete();
foreach ($project->environments()->get() as $environment) {
$environment->delete();
}
$project->settings()->delete();
$shared_variables = $project->environment_variables();
foreach ($shared_variables as $shared_variable) {
+2 -1
View File
@@ -4,6 +4,7 @@ namespace App\Models;
use App\Rules\SafeWebhookUrl;
use App\Rules\ValidS3BucketName;
use App\Traits\Auditable;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -14,7 +15,7 @@ use Illuminate\Support\Facades\Validator;
class S3Storage extends BaseModel
{
use HasFactory, HasSafeStringAttribute;
use Auditable, HasFactory, HasSafeStringAttribute;
private const CONNECTION_TIMEOUT_SECONDS = 15;
+122
View File
@@ -0,0 +1,122 @@
<?php
namespace App\Models;
use App\Services\DopplerService;
use App\Services\InfisicalService;
use App\Services\VaultService;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
/**
* Links a resource (currently Application) to a remote secret manager source.
* Holds only the source coordinates — secret values are never persisted.
*/
class SecretManagerLink extends BaseModel
{
protected $fillable = [
'resourceable_type',
'resourceable_id',
'integration_token_id',
'settings',
];
protected function casts(): array
{
return [
'settings' => 'array',
];
}
public function resourceable(): MorphTo
{
return $this->morphTo();
}
public function integrationToken(): BelongsTo
{
return $this->belongsTo(IntegrationToken::class);
}
/**
* Fetch the secrets from the remote manager. Values live only in memory.
*
* @return array<string, string>
*/
public function fetchSecrets(): array
{
$token = $this->integrationToken;
$settings = $this->settings ?? [];
$metadata = $token->metadata ?? [];
return match ($token->provider) {
'doppler' => (new DopplerService($token->token))->fetchSecrets(
data_get($settings, 'project'),
data_get($settings, 'config'),
),
'infisical' => (new InfisicalService(
data_get($metadata, 'base_url', 'https://app.infisical.com'),
(string) data_get($metadata, 'client_id'),
$token->token,
))->fetchSecrets(
(string) data_get($settings, 'project_id'),
(string) data_get($settings, 'environment'),
(string) data_get($settings, 'secret_path', '/'),
),
'vault' => (new VaultService(
(string) data_get($metadata, 'base_url'),
$token->token,
data_get($metadata, 'namespace'),
))->fetchSecrets(
(string) data_get($settings, 'mount', 'secret'),
(string) data_get($settings, 'path'),
),
default => throw new \RuntimeException("Unsupported secret manager provider [{$token->provider}]."),
};
}
/**
* Create one {{vault.KEY}} reference variable per remote key that has no
* variable with that key yet. Only key names touch the database.
*
* @return list<string> The keys that were imported
*/
public function importMissingReferences(): array
{
$keys = array_keys($this->fetchSecrets());
sort($keys);
$existing = $this->resourceable->environment_variables()->pluck('key')->flip();
$imported = [];
foreach ($keys as $key) {
if (isset($existing[$key])) {
continue;
}
$this->resourceable->environment_variables()->create([
'key' => $key,
'value' => '{{vault.'.$key.'}}',
]);
$imported[] = $key;
}
return $imported;
}
/** Short human-readable description of the remote source for the UI. */
public function sourceSummary(): string
{
$settings = $this->settings ?? [];
return match ($this->integrationToken->provider) {
'doppler' => trim(implode('/', array_filter([
data_get($settings, 'project'),
data_get($settings, 'config'),
])), '/') ?: 'token scope',
'infisical' => data_get($settings, 'project_id').'/'.data_get($settings, 'environment').data_get($settings, 'secret_path', '/'),
'vault' => data_get($settings, 'mount', 'secret').'/'.data_get($settings, 'path'),
default => '',
};
}
}
+2 -1
View File
@@ -21,6 +21,7 @@ use App\Services\DigitalOceanService;
use App\Services\HetznerService;
use App\Services\VultrService;
use App\Support\ValidationPatterns;
use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
@@ -111,7 +112,7 @@ use Symfony\Component\Yaml\Yaml;
class Server extends BaseModel
{
use ClearsGlobalSearchCache, HasFactory, HasMetrics, SchemalessAttributesTrait, SoftDeletes;
use Auditable, ClearsGlobalSearchCache, HasFactory, HasMetrics, SchemalessAttributesTrait, SoftDeletes;
/**
* Sentinel IP for servers that do not have a real address yet
+5 -2
View File
@@ -5,8 +5,11 @@ namespace App\Models;
use App\Enums\ProcessStatus;
use App\Services\ContainerStatusAggregator;
use App\Support\DomainPortOverrides;
use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute;
use App\Traits\HasSecretManager;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -44,7 +47,7 @@ use Symfony\Component\Yaml\Yaml;
)]
class Service extends BaseModel
{
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
use Auditable, ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
private static $parserVersion = '5';
@@ -1639,7 +1642,7 @@ class Service extends BaseModel
return 3;
});
foreach ($sorted as $env) {
$envs->push("{$env->key}={$env->real_value}");
$envs->push("{$env->key}={$this->resolveSecretManagerEnvironmentVariable($env)}");
}
if ($envs->count() === 0) {
$commands[] = 'touch .env';
+3
View File
@@ -3,11 +3,14 @@
namespace App\Models;
use App\Support\ValidationPatterns;
use App\Traits\Auditable;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
class SharedEnvironmentVariable extends Model
{
use Auditable;
protected $fillable = [
// Core identification
'key',
+7 -1
View File
@@ -2,18 +2,24 @@
namespace App\Models;
use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics;
use App\Traits\HasRestartLimit;
use App\Traits\HasSafeStringAttribute;
use App\Traits\HasSecretManager;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneClickhouse extends BaseModel
{
use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasRestartLimit, HasSafeStringAttribute, SoftDeletes;
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasRestartLimit, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
protected array $auditExclude = ['last_online_at'];
protected $fillable = [
'uuid',
+5 -1
View File
@@ -2,18 +2,22 @@
namespace App\Models;
use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics;
use App\Traits\HasRestartLimit;
use App\Traits\HasSafeStringAttribute;
use App\Traits\HasSecretManager;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneDragonfly extends BaseModel
{
use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasRestartLimit, HasSafeStringAttribute, SoftDeletes;
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasRestartLimit, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
protected $fillable = [
'uuid',
+5 -1
View File
@@ -2,18 +2,22 @@
namespace App\Models;
use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics;
use App\Traits\HasRestartLimit;
use App\Traits\HasSafeStringAttribute;
use App\Traits\HasSecretManager;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneKeydb extends BaseModel
{
use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasRestartLimit, HasSafeStringAttribute, SoftDeletes;
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasRestartLimit, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
protected $fillable = [
'uuid',

Some files were not shown because too many files have changed in this diff Show More