diff --git a/.ai/lessons.md b/.ai/lessons.md deleted file mode 100644 index 0c08f5d495..0000000000 --- a/.ai/lessons.md +++ /dev/null @@ -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. diff --git a/.env.testing b/.env.testing index 1a73117986..d445b5afed 100644 --- a/.env.testing +++ b/.env.testing @@ -1,6 +1,7 @@ APP_ENV=testing APP_KEY=base64:8VEfVNVkXQ9mH2L33WBWNMF4eQ0BWD5CTzB8mIxcl+k= APP_DEBUG=true +APP_MAINTENANCE_DRIVER=file DB_CONNECTION=testing diff --git a/app/Actions/Database/StartClickhouse.php b/app/Actions/Database/StartClickhouse.php index b256eb2255..f9e92e08f1 100644 --- a/app/Actions/Database/StartClickhouse.php +++ b/app/Actions/Database/StartClickhouse.php @@ -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()) { diff --git a/app/Actions/Database/StartDatabase.php b/app/Actions/Database/StartDatabase.php index cd7e083286..cb1c517539 100644 --- a/app/Actions/Database/StartDatabase.php +++ b/app/Actions/Database/StartDatabase.php @@ -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,7 +26,7 @@ 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()) { @@ -33,32 +37,32 @@ class StartDatabase 'last_restart_at' => null, 'last_restart_type' => null, ]); - 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); } diff --git a/app/Actions/Database/StartDragonfly.php b/app/Actions/Database/StartDragonfly.php index ddd930f278..078d557f57 100644 --- a/app/Actions/Database/StartDragonfly.php +++ b/app/Actions/Database/StartDragonfly.php @@ -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()) { diff --git a/app/Actions/Database/StartKeydb.php b/app/Actions/Database/StartKeydb.php index cc017e3514..3b9cba28f4 100644 --- a/app/Actions/Database/StartKeydb.php +++ b/app/Actions/Database/StartKeydb.php @@ -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) { diff --git a/app/Actions/Database/StartMariadb.php b/app/Actions/Database/StartMariadb.php index 2f030ae299..a05da25efd 100644 --- a/app/Actions/Database/StartMariadb.php +++ b/app/Actions/Database/StartMariadb.php @@ -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()) { diff --git a/app/Actions/Database/StartMongodb.php b/app/Actions/Database/StartMongodb.php index 097e19f7b2..ff338aa99f 100644 --- a/app/Actions/Database/StartMongodb.php +++ b/app/Actions/Database/StartMongodb.php @@ -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"; diff --git a/app/Actions/Database/StartMysql.php b/app/Actions/Database/StartMysql.php index d21ee02fb1..cff8d0b363 100644 --- a/app/Actions/Database/StartMysql.php +++ b/app/Actions/Database/StartMysql.php @@ -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()) { diff --git a/app/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php index f70e8f3cfd..f9dd7a3c4f 100644 --- a/app/Actions/Database/StartPostgresql.php +++ b/app/Actions/Database/StartPostgresql.php @@ -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()) { diff --git a/app/Actions/Database/StartRedis.php b/app/Actions/Database/StartRedis.php index 8d65453f70..41ece532b1 100644 --- a/app/Actions/Database/StartRedis.php +++ b/app/Actions/Database/StartRedis.php @@ -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) { diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index 44602f0619..c69863c572 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -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); } diff --git a/app/Actions/Server/CheckUpdates.php b/app/Actions/Server/CheckUpdates.php index f90e007089..5cf5658f8f 100644 --- a/app/Actions/Server/CheckUpdates.php +++ b/app/Actions/Server/CheckUpdates.php @@ -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, + ]; + } } diff --git a/app/Actions/Server/InstallDocker.php b/app/Actions/Server/InstallDocker.php index 2e08ec6ad9..552445d728 100644 --- a/app/Actions/Server/InstallDocker.php +++ b/app/Actions/Server/InstallDocker.php @@ -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'; diff --git a/app/Actions/Server/InstallPrerequisites.php b/app/Actions/Server/InstallPrerequisites.php index 84be7f2068..57fd4f1d7c 100644 --- a/app/Actions/Server/InstallPrerequisites.php +++ b/app/Actions/Server/InstallPrerequisites.php @@ -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', + ]; + } } diff --git a/app/Actions/Server/UpdatePackage.php b/app/Actions/Server/UpdatePackage.php index ab0ca94943..2b06e06011 100644 --- a/app/Actions/Server/UpdatePackage.php +++ b/app/Actions/Server/UpdatePackage.php @@ -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', diff --git a/app/Auth/Oidc/Exceptions/OidcDiscoveryException.php b/app/Auth/Oidc/Exceptions/OidcDiscoveryException.php new file mode 100644 index 0000000000..e4a2ba0dfe --- /dev/null +++ b/app/Auth/Oidc/Exceptions/OidcDiscoveryException.php @@ -0,0 +1,5 @@ + $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, + ); + } +} diff --git a/app/Auth/Oidc/OidcDiscoveryDocument.php b/app/Auth/Oidc/OidcDiscoveryDocument.php new file mode 100644 index 0000000000..d17061c51d --- /dev/null +++ b/app/Auth/Oidc/OidcDiscoveryDocument.php @@ -0,0 +1,61 @@ + $supportedScopes + * @param array $supportedClaims + * @param array $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 $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 + */ + private static function stringList(mixed $value): array + { + if (! is_array($value)) { + return []; + } + + return array_values(array_map('strval', $value)); + } +} diff --git a/app/Auth/Oidc/OidcDiscoveryService.php b/app/Auth/Oidc/OidcDiscoveryService.php new file mode 100644 index 0000000000..0847afc9a7 --- /dev/null +++ b/app/Auth/Oidc/OidcDiscoveryService.php @@ -0,0 +1,97 @@ +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 + */ + 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; + } + } +} diff --git a/app/Auth/Oidc/OidcTokenValidator.php b/app/Auth/Oidc/OidcTokenValidator.php new file mode 100644 index 0000000000..a8563611dd --- /dev/null +++ b/app/Auth/Oidc/OidcTokenValidator.php @@ -0,0 +1,199 @@ + $jwks + * @return array + */ + 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 $jwks + * @return array + */ + 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 $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 $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 $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 $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 $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.'); + } + } +} diff --git a/app/Auth/Oidc/OidcUser.php b/app/Auth/Oidc/OidcUser.php new file mode 100644 index 0000000000..645130e019 --- /dev/null +++ b/app/Auth/Oidc/OidcUser.php @@ -0,0 +1,32 @@ + + */ + public array $idTokenClaims = []; + + /** + * @param array $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; + } +} diff --git a/app/Auth/Oidc/Socialite/OidcProvider.php b/app/Auth/Oidc/Socialite/OidcProvider.php new file mode 100644 index 0000000000..383b0cc910 --- /dev/null +++ b/app/Auth/Oidc/Socialite/OidcProvider.php @@ -0,0 +1,299 @@ + + */ + 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 + */ + 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 $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 + */ + 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 + */ + 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 $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}"; + } +} diff --git a/app/Console/Commands/CleanupDatabase.php b/app/Console/Commands/CleanupDatabase.php index 347ea94193..65f686ba61 100644 --- a/app/Console/Commands/CleanupDatabase.php +++ b/app/Console/Commands/CleanupDatabase.php @@ -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(); diff --git a/app/Helpers/SshMultiplexingHelper.php b/app/Helpers/SshMultiplexingHelper.php index cbb18945e2..e7d6d071b4 100644 --- a/app/Helpers/SshMultiplexingHelper.php +++ b/app/Helpers/SshMultiplexingHelper.php @@ -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'); diff --git a/app/Http/Controllers/Api/ApplicationSecretManagerController.php b/app/Http/Controllers/Api/ApplicationSecretManagerController.php new file mode 100644 index 0000000000..c8c311766d --- /dev/null +++ b/app/Http/Controllers/Api/ApplicationSecretManagerController.php @@ -0,0 +1,123 @@ + []]], + 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, + ]); + } +} diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index dbe0f86336..246214986c 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -3393,7 +3393,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, @@ -5901,14 +5901,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, diff --git a/app/Http/Controllers/Api/AuditEventsController.php b/app/Http/Controllers/Api/AuditEventsController.php new file mode 100644 index 0000000000..da452bb303 --- /dev/null +++ b/app/Http/Controllers/Api/AuditEventsController.php @@ -0,0 +1,80 @@ +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)); + } +} diff --git a/app/Http/Controllers/Api/IntegrationTokensController.php b/app/Http/Controllers/Api/IntegrationTokensController.php new file mode 100644 index 0000000000..13a225a107 --- /dev/null +++ b/app/Http/Controllers/Api/IntegrationTokensController.php @@ -0,0 +1,108 @@ + []]], + 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); + } +} diff --git a/app/Http/Controllers/Api/ProjectController.php b/app/Http/Controllers/Api/ProjectController.php index eb137c5349..16eff1ba18 100644 --- a/app/Http/Controllers/Api/ProjectController.php +++ b/app/Http/Controllers/Api/ProjectController.php @@ -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.']); } diff --git a/app/Http/Controllers/OauthController.php b/app/Http/Controllers/OauthController.php index 4038fe63e2..93d27615a7 100644 --- a/app/Http/Controllers/OauthController.php +++ b/app/Http/Controllers/OauthController.php @@ -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; + } } diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index aca4293919..b1cb8d853d 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -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, ]; diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 01a365a0c5..19c0b750ec 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -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|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 + */ + 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; @@ -2762,6 +2895,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; @@ -3305,7 +3444,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); } @@ -3321,7 +3462,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); } @@ -4415,7 +4558,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('|'); @@ -4481,7 +4624,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 @@ -4503,7 +4646,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 @@ -4517,6 +4660,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] ========================================'); @@ -4538,11 +4689,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")); @@ -4551,11 +4697,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, ]); } diff --git a/app/Jobs/DatabaseStartJob.php b/app/Jobs/DatabaseStartJob.php new file mode 100644 index 0000000000..e21ee38c61 --- /dev/null +++ b/app/Jobs/DatabaseStartJob.php @@ -0,0 +1,88 @@ +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)); + } + } +} diff --git a/app/Livewire/Notifications/Discord.php b/app/Livewire/Notifications/Discord.php index 8ea4bbc958..cb31e6c111 100644 --- a/app/Livewire/Notifications/Discord.php +++ b/app/Livewire/Notifications/Discord.php @@ -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 { diff --git a/app/Livewire/Notifications/Email.php b/app/Livewire/Notifications/Email.php index 5bd55137b5..ea626ed57a 100644 --- a/app/Livewire/Notifications/Email.php +++ b/app/Livewire/Notifications/Email.php @@ -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 { diff --git a/app/Livewire/Notifications/Pushover.php b/app/Livewire/Notifications/Pushover.php index 7caacdb916..cae1c3d689 100644 --- a/app/Livewire/Notifications/Pushover.php +++ b/app/Livewire/Notifications/Pushover.php @@ -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 { diff --git a/app/Livewire/Notifications/Slack.php b/app/Livewire/Notifications/Slack.php index fe84710bdf..644252c1a3 100644 --- a/app/Livewire/Notifications/Slack.php +++ b/app/Livewire/Notifications/Slack.php @@ -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 { diff --git a/app/Livewire/Notifications/Telegram.php b/app/Livewire/Notifications/Telegram.php index 51239b8621..f999294477 100644 --- a/app/Livewire/Notifications/Telegram.php +++ b/app/Livewire/Notifications/Telegram.php @@ -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); diff --git a/app/Livewire/Notifications/Webhook.php b/app/Livewire/Notifications/Webhook.php index a3480ada69..fb537fc7d9 100644 --- a/app/Livewire/Notifications/Webhook.php +++ b/app/Livewire/Notifications/Webhook.php @@ -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 { diff --git a/app/Livewire/Profile/Index.php b/app/Livewire/Profile/Index.php index 04f58dadd2..69f27b0e55 100644 --- a/app/Livewire/Profile/Index.php +++ b/app/Livewire/Profile/Index.php @@ -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'); diff --git a/app/Livewire/Project/Application/DeploymentNavbar.php b/app/Livewire/Project/Application/DeploymentNavbar.php index b60f543ba5..3abc2da73c 100644 --- a/app/Livewire/Project/Application/DeploymentNavbar.php +++ b/app/Livewire/Project/Application/DeploymentNavbar.php @@ -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); diff --git a/app/Livewire/Project/Application/Heading.php b/app/Livewire/Project/Application/Heading.php index 6c75cd7a61..830a4eace8 100644 --- a/app/Livewire/Project/Application/Heading.php +++ b/app/Livewire/Project/Application/Heading.php @@ -156,6 +156,11 @@ class Heading extends Component $this->dispatch('info', 'Gracefully stopping application.
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); } diff --git a/app/Livewire/Project/Application/Previews.php b/app/Livewire/Project/Application/Previews.php index 832a0d55cf..46681d5d57 100644 --- a/app/Livewire/Project/Application/Previews.php +++ b/app/Livewire/Project/Application/Previews.php @@ -306,6 +306,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); diff --git a/app/Livewire/Project/CloneMe.php b/app/Livewire/Project/CloneMe.php index fff2b7fbf5..ad032779b3 100644 --- a/app/Livewire/Project/CloneMe.php +++ b/app/Livewire/Project/CloneMe.php @@ -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) { diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index 4a709d2b96..d938ebde8d 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -212,10 +212,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'], @@ -251,9 +259,14 @@ class BackupEdit extends Component } 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', [ diff --git a/app/Livewire/Project/Database/BackupNow.php b/app/Livewire/Project/Database/BackupNow.php index 8c83e33556..39a1960119 100644 --- a/app/Livewire/Project/Database/BackupNow.php +++ b/app/Livewire/Project/Database/BackupNow.php @@ -25,6 +25,13 @@ class BackupNow extends Component } 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); diff --git a/app/Livewire/Project/Database/Heading.php b/app/Livewire/Project/Database/Heading.php index f2f8fa387d..993200b578 100644 --- a/app/Livewire/Project/Database/Heading.php +++ b/app/Livewire/Project/Database/Heading.php @@ -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, + ]); + } } diff --git a/app/Livewire/Project/Database/ImportForm.php b/app/Livewire/Project/Database/ImportForm.php index 5b8d90e2bb..87c9328eb3 100644 --- a/app/Livewire/Project/Database/ImportForm.php +++ b/app/Livewire/Project/Database/ImportForm.php @@ -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; diff --git a/app/Livewire/Project/Service/Heading.php b/app/Livewire/Project/Service/Heading.php index 0e7fed960f..d692a71546 100644 --- a/app/Livewire/Project/Service/Heading.php +++ b/app/Livewire/Project/Service/Heading.php @@ -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', [ diff --git a/app/Livewire/Project/Service/Storage.php b/app/Livewire/Project/Service/Storage.php index adb19a3135..10079276f2 100644 --- a/app/Livewire/Project/Service/Storage.php +++ b/app/Livewire/Project/Service/Storage.php @@ -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(); diff --git a/app/Livewire/Project/Shared/Destination.php b/app/Livewire/Project/Shared/Destination.php index 94fb4b4eb3..9262b9847e 100644 --- a/app/Livewire/Project/Shared/Destination.php +++ b/app/Livewire/Project/Shared/Destination.php @@ -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); diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php index 1dcb7c7810..15b4410a5f 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php @@ -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; diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php index 8350cffa4c..fea181395c 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php @@ -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() diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php b/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php index da55dee197..c2f0059399 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php @@ -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'); diff --git a/app/Livewire/Project/Shared/ResourceOperations.php b/app/Livewire/Project/Shared/ResourceOperations.php index dd00be25cc..61b4b2d2ed 100644 --- a/app/Livewire/Project/Shared/ResourceOperations.php +++ b/app/Livewire/Project/Shared/ResourceOperations.php @@ -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, [ diff --git a/app/Livewire/Project/Shared/ScheduledTask/Show.php b/app/Livewire/Project/Shared/ScheduledTask/Show.php index 30d1024621..c121f1b93b 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Show.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Show.php @@ -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); diff --git a/app/Livewire/Project/Shared/SecretManagerLinks.php b/app/Livewire/Project/Shared/SecretManagerLinks.php new file mode 100644 index 0000000000..c0641b56c5 --- /dev/null +++ b/app/Livewire/Project/Shared/SecretManagerLinks.php @@ -0,0 +1,290 @@ + 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 $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, + ]); + } +} diff --git a/app/Livewire/Project/Shared/Storages/All.php b/app/Livewire/Project/Shared/Storages/All.php index fcd7752a0c..3dadfb46f4 100644 --- a/app/Livewire/Project/Shared/Storages/All.php +++ b/app/Livewire/Project/Shared/Storages/All.php @@ -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. */ diff --git a/app/Livewire/Project/Shared/Storages/Show.php b/app/Livewire/Project/Shared/Storages/Show.php deleted file mode 100644 index c70ebc57fd..0000000000 --- a/app/Livewire/Project/Shared/Storages/Show.php +++ /dev/null @@ -1,201 +0,0 @@ - 'name', - 'mountPath' => 'mount', - 'hostPath' => 'host', - ]; - - protected function rules(): array - { - return [ - 'name' => ValidationPatterns::volumeNameRules(), - 'mountPath' => ['required', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], - 'hostPath' => ['nullable', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], - 'isPreviewSuffixEnabled' => 'required|boolean', - ]; - } - - protected function messages(): array - { - return array_merge( - ValidationPatterns::volumeNameMessages(), - [ - 'mountPath.regex' => 'Mount path must start with / and only contain safe path characters.', - 'hostPath.regex' => 'Host path must start with / and only contain safe path characters.', - ] - ); - } - - /** - * Sync data between component properties and model - * - * @param bool $toModel If true, sync FROM properties TO model. If false, sync FROM model TO properties. - */ - private function syncData(bool $toModel = false): void - { - if ($toModel) { - // Sync TO model (before save) - $this->storage->name = $this->name; - $this->storage->mount_path = $this->mountPath; - $this->storage->host_path = $this->hostPath; - $this->storage->is_preview_suffix_enabled = $this->isPreviewSuffixEnabled; - } else { - // Sync FROM model (on load/refresh) - $this->name = $this->storage->name; - $this->mountPath = $this->storage->mount_path; - $this->hostPath = $this->storage->host_path; - $this->isPreviewSuffixEnabled = $this->storage->is_preview_suffix_enabled ?? true; - } - } - - public function mount(): void - { - $this->syncData(false); - $this->isReadOnly = $this->storage->shouldBeReadOnlyInUI(); - // PR deployment volume suffixes only apply to git-based applications. - $this->supportsPreviewSuffix = $this->resource instanceof Application - && $this->resource->git_based() - && filled($this->resource->git_repository) - && ! $this->isService; - // Parent All batches badge/url; isolated embeds still hydrate themselves. - if (! $this->backupMetaHydrated) { - $this->refreshBackupStatus(); - } - } - - #[On('refreshVolumeBackups')] - public function refreshBackupStatus(): void - { - $backup = $this->storage->scheduledBackups()->first(); - - $this->hasEnabledBackup = $backup?->enabled ?? false; - $this->backupUrl = null; - - if (! $this->hasEnabledBackup || ! $this->resource instanceof Application) { - return; - } - - $this->resource->loadMissing('environment.project'); - - $parameters = [ - 'project_uuid' => $this->resource->project()->uuid, - 'environment_uuid' => $this->resource->environment->uuid, - 'application_uuid' => $this->resource->uuid, - ]; - $hasOtherBackups = ScheduledVolumeBackup::query() - ->forApplication($this->resource) - ->where('id', '!=', $backup->id) - ->exists(); - - $this->backupUrl = $hasOtherBackups - ? route('project.application.backup.index', [...$parameters, 'search' => $this->storage->name]) - : route('project.application.backup.show', [...$parameters, 'backup_uuid' => $backup->uuid]); - } - - public function openBackupModal(): void - { - $this->authorize('update', $this->resource); - $this->showBackupModal = true; - } - - #[On('modalClosed')] - public function onModalClosed(): void - { - // Drop the nested Create component from the DOM after close to free snapshot weight. - if ($this->showBackupModal) { - $this->showBackupModal = false; - } - } - - public function instantSave(): void - { - $this->authorize('update', $this->resource); - $this->validate(); - - $this->syncData(true); - $this->storage->save(); - $this->dispatch('success', 'Storage updated successfully'); - } - - public function submit() - { - $this->authorize('update', $this->resource); - - $this->validate(); - $this->syncData(true); - $this->storage->save(); - $this->dispatch('success', 'Storage updated successfully'); - } - - public function delete($password, $selectedActions = []) - { - $this->authorize('update', $this->resource); - - if (! verifyPasswordConfirmation($password, $this)) { - return 'The provided password is incorrect.'; - } - - if ($this->storage->scheduledBackups()->exists()) { - $this->dispatch('error', 'Delete this volume backup schedule and its archives before deleting the volume.'); - - return false; - } - - $this->storage->delete(); - $this->dispatch('storageCountsChanged')->to(StorageComponent::class); - $this->dispatch('configurationChanged'); - - return true; - } -} diff --git a/app/Livewire/Project/Shared/Storages/VolumeBackups.php b/app/Livewire/Project/Shared/Storages/VolumeBackups.php index a8e2d72df1..86023628e2 100644 --- a/app/Livewire/Project/Shared/Storages/VolumeBackups.php +++ b/app/Livewire/Project/Shared/Storages/VolumeBackups.php @@ -208,6 +208,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()); diff --git a/app/Livewire/Security/ApiTokens.php b/app/Livewire/Security/ApiTokens.php index 5a978ac84f..a1cc4db19f 100644 --- a/app/Livewire/Security/ApiTokens.php +++ b/app/Livewire/Security/ApiTokens.php @@ -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); diff --git a/app/Livewire/Security/IntegrationTokenEditor.php b/app/Livewire/Security/IntegrationTokenEditor.php new file mode 100644 index 0000000000..2a33591822 --- /dev/null +++ b/app/Livewire/Security/IntegrationTokenEditor.php @@ -0,0 +1,152 @@ +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'); + } +} diff --git a/app/Livewire/Security/IntegrationTokenForm.php b/app/Livewire/Security/IntegrationTokenForm.php new file mode 100644 index 0000000000..26ecce1e14 --- /dev/null +++ b/app/Livewire/Security/IntegrationTokenForm.php @@ -0,0 +1,127 @@ +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'); + } +} diff --git a/app/Livewire/Security/IntegrationTokens.php b/app/Livewire/Security/IntegrationTokens.php new file mode 100644 index 0000000000..71805fb841 --- /dev/null +++ b/app/Livewire/Security/IntegrationTokens.php @@ -0,0 +1,57 @@ +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'); + } +} diff --git a/app/Livewire/Server/DockerCleanup.php b/app/Livewire/Server/DockerCleanup.php index 40dd92d87e..d0a8d8ca9d 100644 --- a/app/Livewire/Server/DockerCleanup.php +++ b/app/Livewire/Server/DockerCleanup.php @@ -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); diff --git a/app/Livewire/Server/LogDrains.php b/app/Livewire/Server/LogDrains.php index 5ce657f001..9319c856c0 100644 --- a/app/Livewire/Server/LogDrains.php +++ b/app/Livewire/Server/LogDrains.php @@ -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.'), + }; + } } diff --git a/app/Livewire/Server/Navbar.php b/app/Livewire/Server/Navbar.php index d9f70ea253..242b0971ec 100644 --- a/app/Livewire/Server/Navbar.php +++ b/app/Livewire/Server/Navbar.php @@ -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); } diff --git a/app/Livewire/Server/TransferImport.php b/app/Livewire/Server/TransferImport.php index db8999c268..9fe37c10ca 100644 --- a/app/Livewire/Server/TransferImport.php +++ b/app/Livewire/Server/TransferImport.php @@ -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')) { diff --git a/app/Livewire/Settings/Advanced.php b/app/Livewire/Settings/Advanced.php index 45aff3f3c9..4bb89c9f08 100644 --- a/app/Livewire/Settings/Advanced.php +++ b/app/Livewire/Settings/Advanced.php @@ -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; @@ -61,6 +64,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], @@ -87,6 +91,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; @@ -203,6 +208,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; diff --git a/app/Livewire/SettingsEmail.php b/app/Livewire/SettingsEmail.php index 4b5857db50..975ce9a241 100644 --- a/app/Livewire/SettingsEmail.php +++ b/app/Livewire/SettingsEmail.php @@ -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 { diff --git a/app/Livewire/SettingsOauth.php b/app/Livewire/SettingsOauth.php index 4082718191..3b24d0cd2e 100644 --- a/app/Livewire/SettingsOauth.php +++ b/app/Livewire/SettingsOauth.php @@ -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.'.
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('
', $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('
', $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.'.
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.'.
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); } } } diff --git a/app/Livewire/Team/AuditLog.php b/app/Livewire/Team/AuditLog.php new file mode 100644 index 0000000000..53cb203eff --- /dev/null +++ b/app/Livewire/Team/AuditLog.php @@ -0,0 +1,73 @@ +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]); + } +} diff --git a/app/Livewire/Team/Invitations.php b/app/Livewire/Team/Invitations.php index 8ecafc417c..b66c49ac9e 100644 --- a/app/Livewire/Team/Invitations.php +++ b/app/Livewire/Team/Invitations.php @@ -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) { diff --git a/app/Livewire/Team/InviteLink.php b/app/Livewire/Team/InviteLink.php index a93bf8dd92..d6ea836075 100644 --- a/app/Livewire/Team/InviteLink.php +++ b/app/Livewire/Team/InviteLink.php @@ -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', [ diff --git a/app/Livewire/Team/Member.php b/app/Livewire/Team/Member.php index 38c932c39d..d99fd2eb1b 100644 --- a/app/Livewire/Team/Member.php +++ b/app/Livewire/Team/Member.php @@ -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, + ]); + } } diff --git a/app/Models/Application.php b/app/Models/Application.php index cec8d501ab..6737e57986 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -10,11 +10,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; @@ -124,10 +127,8 @@ use Symfony\Component\Yaml\Yaml; class Application extends BaseModel { - use ClearsGlobalSearchCache, HasConfiguration, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, SoftDeletes; - /** @use HasFactory */ - use HasFactory; + use Auditable, ClearsGlobalSearchCache, HasConfiguration, HasFactory, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, HasSecretManager, SoftDeletes; public const MAX_DOCKER_COMPOSE_SIZE_BYTES = 5 * 1024 * 1024; @@ -397,6 +398,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(); } diff --git a/app/Models/ApplicationDeploymentQueue.php b/app/Models/ApplicationDeploymentQueue.php index ee190532c4..f16f7f8f96 100644 --- a/app/Models/ApplicationDeploymentQueue.php +++ b/app/Models/ApplicationDeploymentQueue.php @@ -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', diff --git a/app/Models/AuditEvent.php b/app/Models/AuditEvent.php new file mode 100644 index 0000000000..2383dee267 --- /dev/null +++ b/app/Models/AuditEvent.php @@ -0,0 +1,211 @@ + '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 $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 $context + * @return array + */ + 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 $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 $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(); + } +} diff --git a/app/Models/Environment.php b/app/Models/Environment.php index 1364d874a1..e98f13d21f 100644 --- a/app/Models/Environment.php +++ b/app/Models/Environment.php @@ -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 = [ diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index 89188b31b1..e7dd8564bc 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -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(); } } diff --git a/app/Models/GithubApp.php b/app/Models/GithubApp.php index 564fbcf6a4..96c7a2d39d 100644 --- a/app/Models/GithubApp.php +++ b/app/Models/GithubApp.php @@ -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()); diff --git a/app/Models/GitlabApp.php b/app/Models/GitlabApp.php index c6c2b84095..727ec77cd1 100644 --- a/app/Models/GitlabApp.php +++ b/app/Models/GitlabApp.php @@ -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', diff --git a/app/Models/InstanceSettings.php b/app/Models/InstanceSettings.php index 26aceec354..1e7d8282a5 100644 --- a/app/Models/InstanceSettings.php +++ b/app/Models/InstanceSettings.php @@ -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', @@ -89,6 +90,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', @@ -116,6 +119,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( diff --git a/app/Models/IntegrationToken.php b/app/Models/IntegrationToken.php new file mode 100644 index 0000000000..53b4dd6f4a --- /dev/null +++ b/app/Models/IntegrationToken.php @@ -0,0 +1,78 @@ + '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); + } +} diff --git a/app/Models/OauthIdentity.php b/app/Models/OauthIdentity.php new file mode 100644 index 0000000000..1edf71ad2f --- /dev/null +++ b/app/Models/OauthIdentity.php @@ -0,0 +1,35 @@ + 'array', + 'last_login_at' => 'datetime', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/OauthSetting.php b/app/Models/OauthSetting.php index e7999134a6..7765e41160 100644 --- a/app/Models/OauthSetting.php +++ b/app/Models/OauthSetting.php @@ -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 + */ + 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'; + } } diff --git a/app/Models/PrivateKey.php b/app/Models/PrivateKey.php index 3f72642a57..43aa310cbc 100644 --- a/app/Models/PrivateKey.php +++ b/app/Models/PrivateKey.php @@ -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', diff --git a/app/Models/Project.php b/app/Models/Project.php index 57dbf823ce..65c21c1e78 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -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) { diff --git a/app/Models/S3Storage.php b/app/Models/S3Storage.php index e4b1e2fd68..3c0d9e7e95 100644 --- a/app/Models/S3Storage.php +++ b/app/Models/S3Storage.php @@ -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; diff --git a/app/Models/SecretManagerLink.php b/app/Models/SecretManagerLink.php new file mode 100644 index 0000000000..34e4e90d12 --- /dev/null +++ b/app/Models/SecretManagerLink.php @@ -0,0 +1,122 @@ + '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 + */ + 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 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 => '', + }; + } +} diff --git a/app/Models/Server.php b/app/Models/Server.php index dccbed15ed..9cc91d40d4 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -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 diff --git a/app/Models/Service.php b/app/Models/Service.php index 3b80a19412..2f1a0264c2 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -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; @@ -43,7 +46,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'; @@ -1615,7 +1618,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'; diff --git a/app/Models/SharedEnvironmentVariable.php b/app/Models/SharedEnvironmentVariable.php index c70bf9f08a..086cc33e50 100644 --- a/app/Models/SharedEnvironmentVariable.php +++ b/app/Models/SharedEnvironmentVariable.php @@ -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', diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php index 7ca45cc3b7..6265345ee9 100644 --- a/app/Models/StandaloneClickhouse.php +++ b/app/Models/StandaloneClickhouse.php @@ -2,17 +2,21 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; 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, HasSafeStringAttribute, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; + + protected array $auditExclude = ['last_online_at']; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneDragonfly.php b/app/Models/StandaloneDragonfly.php index 769d9f00c4..da4804dd2d 100644 --- a/app/Models/StandaloneDragonfly.php +++ b/app/Models/StandaloneDragonfly.php @@ -2,17 +2,19 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; 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, HasSafeStringAttribute, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneKeydb.php b/app/Models/StandaloneKeydb.php index 15a1fe2f82..f4dbaec210 100644 --- a/app/Models/StandaloneKeydb.php +++ b/app/Models/StandaloneKeydb.php @@ -2,17 +2,19 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; 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, HasSafeStringAttribute, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneMariadb.php b/app/Models/StandaloneMariadb.php index 378d36395d..c923b489bd 100644 --- a/app/Models/StandaloneMariadb.php +++ b/app/Models/StandaloneMariadb.php @@ -2,10 +2,12 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; 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\MorphTo; @@ -13,7 +15,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMariadb extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneMongodb.php b/app/Models/StandaloneMongodb.php index 1010ca5f37..70b108087a 100644 --- a/app/Models/StandaloneMongodb.php +++ b/app/Models/StandaloneMongodb.php @@ -2,17 +2,19 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; 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 StandaloneMongodb extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneMysql.php b/app/Models/StandaloneMysql.php index 90828bf012..6a08a4dc45 100644 --- a/app/Models/StandaloneMysql.php +++ b/app/Models/StandaloneMysql.php @@ -2,17 +2,19 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; 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 StandaloneMysql extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandalonePostgresql.php b/app/Models/StandalonePostgresql.php index e7db812858..f8dc5c0caa 100644 --- a/app/Models/StandalonePostgresql.php +++ b/app/Models/StandalonePostgresql.php @@ -2,17 +2,19 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; 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 StandalonePostgresql extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneRedis.php b/app/Models/StandaloneRedis.php index 3262611903..3bfcc5434e 100644 --- a/app/Models/StandaloneRedis.php +++ b/app/Models/StandaloneRedis.php @@ -2,17 +2,21 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; 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 StandaloneRedis extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; + + protected array $auditExclude = ['last_online_at']; protected $fillable = [ 'uuid', diff --git a/app/Models/Tag.php b/app/Models/Tag.php index d5cccabd8f..30844b2bb6 100644 --- a/app/Models/Tag.php +++ b/app/Models/Tag.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\HasSafeStringAttribute; use Illuminate\Support\Facades\DB; use OpenApi\Attributes as OA; @@ -18,7 +19,7 @@ use OpenApi\Attributes as OA; )] class Tag extends BaseModel { - use HasSafeStringAttribute; + use Auditable, HasSafeStringAttribute; protected $fillable = [ 'name', diff --git a/app/Models/Team.php b/app/Models/Team.php index 4cf6391231..6ec79f2046 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -8,6 +8,7 @@ use App\Notifications\Channels\SendsDiscord; use App\Notifications\Channels\SendsEmail; use App\Notifications\Channels\SendsPushover; use App\Notifications\Channels\SendsSlack; +use App\Traits\Auditable; use App\Traits\HasNotificationSettings; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -39,7 +40,7 @@ use OpenApi\Attributes as OA; class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, SendsSlack { - use HasFactory, HasNotificationSettings, HasSafeStringAttribute, Notifiable; + use Auditable, HasFactory, HasNotificationSettings, HasSafeStringAttribute, Notifiable; protected $fillable = [ 'name', @@ -86,8 +87,11 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen } // Transfer instance-wide sources to root team so they remain available - GithubApp::where('team_id', $team->id)->where('is_system_wide', true)->update(['team_id' => 0]); - GitlabApp::where('team_id', $team->id)->where('is_system_wide', true)->update(['team_id' => 0]); + $systemWideSources = GithubApp::where('team_id', $team->id)->where('is_system_wide', true)->get() + ->concat(GitlabApp::where('team_id', $team->id)->where('is_system_wide', true)->get()); + foreach ($systemWideSources as $source) { + $source->update(['team_id' => 0]); + } // Delete non-instance-wide sources owned by this team $teamSources = GithubApp::where('team_id', $team->id)->get() @@ -313,6 +317,11 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen return $this->hasMany(CloudProviderToken::class); } + public function integrationTokens() + { + return $this->hasMany(IntegrationToken::class); + } + public function sources() { $sources = collect([]); diff --git a/app/Models/User.php b/app/Models/User.php index 5b38473962..10303422bd 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -11,6 +11,7 @@ use App\Services\ChangelogService; use App\Traits\DeletesUserSessions; use DateTimeInterface; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Notifiable; @@ -507,12 +508,26 @@ class User extends Authenticatable implements SendsEmail && Carbon::now()->lessThan($this->email_change_code_expires_at); } + public function oauthIdentities(): HasMany + { + return $this->hasMany(OauthIdentity::class); + } + + public function hasSsoIdentity(): bool + { + return $this->oauthIdentities()->exists(); + } + /** * Check if the user has a password set. - * OAuth users are created without passwords. */ public function hasPassword(): bool { return ! empty($this->password); } + + public function requiresPasswordConfirmation(): bool + { + return $this->hasPassword() && ! $this->hasSsoIdentity(); + } } diff --git a/app/Policies/IntegrationTokenPolicy.php b/app/Policies/IntegrationTokenPolicy.php new file mode 100644 index 0000000000..309c8167f2 --- /dev/null +++ b/app/Policies/IntegrationTokenPolicy.php @@ -0,0 +1,34 @@ +isAdmin(); + } + + public function create(User $user): bool + { + return $user->isAdmin(); + } + + public function view(User $user, IntegrationToken $integrationToken): bool + { + return $user->isAdmin() && $integrationToken->team_id === currentTeam()->id; + } + + public function update(User $user, IntegrationToken $integrationToken): bool + { + return $user->isAdmin() && $integrationToken->team_id === currentTeam()->id; + } + + public function delete(User $user, IntegrationToken $integrationToken): bool + { + return $user->isAdmin() && $integrationToken->team_id === currentTeam()->id; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 5856791662..e4d2b0a851 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,9 @@ namespace App\Providers; +use App\Auth\Oidc\OidcDiscoveryService; +use App\Auth\Oidc\OidcTokenValidator; +use App\Auth\Oidc\Socialite\OidcProvider; use App\Models\PersonalAccessToken; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\App; @@ -10,6 +13,7 @@ use Illuminate\Support\Facades\Http; use Illuminate\Support\ServiceProvider; use Illuminate\Validation\Rules\Password; use Laravel\Sanctum\Sanctum; +use Laravel\Socialite\Contracts\Factory as SocialiteFactory; use Stripe\StripeClient; class AppServiceProvider extends ServiceProvider @@ -22,12 +26,11 @@ class AppServiceProvider extends ServiceProvider public function boot(): void { $this->configureCommands(); - $this->configureModels(); $this->configurePasswords(); $this->configureSanctumModel(); $this->configureGitHubHttp(); - + $this->configureOidcSocialite(); } private function configureCommands(): void @@ -62,6 +65,24 @@ class AppServiceProvider extends ServiceProvider Sanctum::usePersonalAccessTokenModel(PersonalAccessToken::class); } + private function configureOidcSocialite(): void + { + if (! $this->app->bound(SocialiteFactory::class)) { + return; + } + + $this->app->make(SocialiteFactory::class)->extend('oidc', function ($app) { + return new OidcProvider( + $app['request'], + $app->make(OidcDiscoveryService::class), + $app->make(OidcTokenValidator::class), + '', + '', + '', + ); + }); + } + private function configureGitHubHttp(): void { Http::macro('GitHub', function (string $api_url, ?string $github_access_token = null) { @@ -77,16 +98,5 @@ class AppServiceProvider extends ServiceProvider ])->baseUrl($api_url); } }); - - Http::macro('GitLab', function (string $api_url, ?string $access_token = null) { - $client = Http::withHeaders([ - 'Accept' => 'application/json', - ])->baseUrl($api_url); - if ($access_token) { - $client = $client->withToken($access_token); - } - - return $client; - }); } } diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index 09b2a3e089..e8e6fb42c6 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -15,6 +15,7 @@ use App\Models\EnvironmentVariable; use App\Models\GithubApp; use App\Models\GitlabApp; use App\Models\InstanceSettings; +use App\Models\IntegrationToken; use App\Models\PrivateKey; use App\Models\Project; use App\Models\PushoverNotificationSettings; @@ -52,6 +53,7 @@ use App\Policies\EnvironmentVariablePolicy; use App\Policies\GithubAppPolicy; use App\Policies\GitlabAppPolicy; use App\Policies\InstanceSettingsPolicy; +use App\Policies\IntegrationTokenPolicy; use App\Policies\NotificationPolicy; use App\Policies\PrivateKeyPolicy; use App\Policies\ProjectPolicy; @@ -132,6 +134,7 @@ class AuthServiceProvider extends ServiceProvider // Cloud provider policies CloudProviderToken::class => CloudProviderTokenPolicy::class, + IntegrationToken::class => IntegrationTokenPolicy::class, CloudInitScript::class => CloudInitScriptPolicy::class, Tag::class => TagPolicy::class, diff --git a/app/Providers/DuskServiceProvider.php b/app/Providers/DuskServiceProvider.php deleted file mode 100644 index 07e0e8709f..0000000000 --- a/app/Providers/DuskServiceProvider.php +++ /dev/null @@ -1,21 +0,0 @@ -visit('/login') - ->type('email', 'test@example.com') - ->type('password', 'password') - ->press('Login'); - }); - } -} diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index ce16e617d7..b5ca1922eb 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -46,7 +46,7 @@ class FortifyServiceProvider extends ServiceProvider $isFirstUser = User::count() === 0; $settings = instanceSettings(); - if (! $settings->is_registration_enabled) { + if (! $settings->isPasswordRegistrationAllowed()) { return redirect()->route('login'); } @@ -59,13 +59,13 @@ class FortifyServiceProvider extends ServiceProvider $settings = instanceSettings(); $enabled_oauth_providers = OauthSetting::where('enabled', true)->get(); $users = User::count(); - if ($users == 0) { - // If there are no users, redirect to registration + if ($users == 0 && $settings->isPasswordRegistrationAllowed()) { + // If there are no users and password registration is allowed, redirect to registration. return redirect()->route('register'); } return view('auth.login', [ - 'is_registration_enabled' => $settings->is_registration_enabled, + 'is_registration_enabled' => $settings->isPasswordRegistrationAllowed(), 'enabled_oauth_providers' => $enabled_oauth_providers, ]); }); diff --git a/app/Services/Auth/OauthLoginService.php b/app/Services/Auth/OauthLoginService.php new file mode 100644 index 0000000000..2ec8f88e3e --- /dev/null +++ b/app/Services/Auth/OauthLoginService.php @@ -0,0 +1,228 @@ +email)); + if ($email === '' || ! filter_var($email, FILTER_VALIDATE_EMAIL)) { + throw new HttpException(403, 'OAuth provider did not return a valid email address'); + } + + $user = $provider === 'oidc' + ? $this->resolveOidcUser($oauthUser, $oauthSetting, $email) + : $this->resolveOauthUser($oauthUser, $oauthSetting, $email); + + Auth::login($user); + $team = $user->currentTeam() ?? $user->teams()->first() ?? $user->recreate_personal_team(); + session(['currentTeam' => $user->currentTeam = $team]); + + return $user; + } + + private function resolveOauthUser(object $oauthUser, OauthSetting $oauthSetting, string $email): User + { + $provider = $oauthSetting->provider; + $providerUserId = $oauthUser->id ?? null; + if ( + (! is_string($providerUserId) && ! is_int($providerUserId)) + || (is_string($providerUserId) && trim($providerUserId) === '') + ) { + throw new HttpException(403, 'OAuth provider did not return a valid user ID'); + } + $providerUserId = (string) $providerUserId; + $rawClaims = is_array($oauthUser->user ?? null) ? $oauthUser->user : []; + + $identityKey = [ + 'provider' => $provider, + 'issuer' => $provider, + 'provider_user_id' => $providerUserId, + ]; + + try { + return DB::transaction(function () use ($oauthUser, $oauthSetting, $email, $provider, $providerUserId, $rawClaims, $identityKey): User { + $identity = OauthIdentity::where($identityKey)->first(); + + if ($identity) { + $identity->update([ + 'email' => $email, + 'raw_claims' => $rawClaims, + 'last_login_at' => now(), + ]); + + return $identity->user; + } + + $user = User::whereEmail($email)->first(); + if (! $user) { + if (! $this->canCreateUser($oauthSetting)) { + throw new HttpException(403, 'Registration is disabled'); + } + + $user = $this->createUser($oauthUser->name ?: $email, $email, $oauthSetting); + } + + OauthIdentity::create([ + 'user_id' => $user->id, + 'provider' => $provider, + 'issuer' => $provider, + 'provider_user_id' => $providerUserId, + 'email' => $email, + 'raw_claims' => $rawClaims, + 'last_login_at' => now(), + ]); + + return $user; + }); + } catch (UniqueConstraintViolationException $exception) { + return OauthIdentity::where($identityKey)->first()?->user ?? throw $exception; + } + } + + private function resolveOidcUser(object $oauthUser, OauthSetting $oauthSetting, string $email): User + { + $issuer = $oauthUser instanceof OidcUser && filled($oauthUser->issuer) + ? $oauthUser->issuer + : data_get($oauthUser->user, 'iss'); + $subject = $oauthUser instanceof OidcUser && filled($oauthUser->subject) + ? $oauthUser->subject + : data_get($oauthUser->user, 'sub', $oauthUser->id); + $emailVerified = ($oauthUser instanceof OidcUser && $oauthUser->emailVerified) + || data_get($oauthUser->user, 'email_verified') === true; + + if (! is_string($issuer) || $issuer === '' || ! is_string($subject) || $subject === '') { + throw new HttpException(403, 'OIDC provider did not return issuer and subject claims'); + } + + if ($oauthSetting->require_email_verified && ! $emailVerified) { + throw new HttpException(403, 'OIDC provider did not verify the email address'); + } + + $rawClaims = is_array($oauthUser->user ?? null) ? $oauthUser->user : []; + + $identityKey = [ + 'provider' => 'oidc', + 'issuer' => $issuer, + 'provider_user_id' => $subject, + ]; + + try { + return DB::transaction(function () use ($oauthUser, $oauthSetting, $email, $issuer, $subject, $emailVerified, $rawClaims, $identityKey): User { + $identity = OauthIdentity::where($identityKey)->first(); + + if ($identity) { + $identity->update([ + 'email' => $email, + 'raw_claims' => $rawClaims, + 'last_login_at' => now(), + ]); + + return $identity->user; + } + + $user = User::whereEmail($email)->first(); + + // Linking a new OIDC identity to an existing local account by email + // is account takeover unless the provider attests the email. This + // guard is independent of the require_email_verified toggle, which + // only governs the broader login flow. + if ($user && ! $emailVerified) { + throw new HttpException(403, 'OIDC provider must verify the email address before linking to an existing account'); + } + + if (! $user) { + if (! $this->canCreateUser($oauthSetting)) { + throw new HttpException(403, 'Registration is disabled'); + } + + $user = $this->createUser($oauthUser->name ?: $email, $email, $oauthSetting); + } + + OauthIdentity::create([ + 'user_id' => $user->id, + 'provider' => 'oidc', + 'issuer' => $issuer, + 'provider_user_id' => $subject, + 'email' => $email, + 'raw_claims' => $rawClaims, + 'last_login_at' => now(), + ]); + + return $user; + }); + } catch (UniqueConstraintViolationException $exception) { + return OauthIdentity::where($identityKey)->first()?->user ?? throw $exception; + } + } + + private function canCreateUser(OauthSetting $oauthSetting): bool + { + return instanceSettings()->is_registration_enabled || $oauthSetting->allow_registration; + } + + private function createUser(string $name, string $email, OauthSetting $oauthSetting): User + { + if (User::count() === 0) { + $user = (new User)->forceFill([ + 'id' => 0, + 'name' => $name, + 'email' => $email, + 'password' => Hash::make(Str::random(64)), + ]); + $user->save(); + + $team = $user->teams()->first() ?? Team::find(0); + if ($team !== null && ! $user->teams()->where('team_id', $team->id)->exists()) { + $user->teams()->attach($team, ['role' => 'owner']); + } + + instanceSettings()->update(['is_registration_enabled' => false]); + + return $user; + } + + if ($oauthSetting->auto_join_root_team) { + return $this->createRootTeamOnlyUser($name, $email); + } + + return User::create([ + 'name' => $name, + 'email' => $email, + 'password' => Hash::make(Str::random(64)), + ]); + } + + private function createRootTeamOnlyUser(string $name, string $email): User + { + return DB::transaction(function () use ($name, $email) { + $rootTeam = Team::find(0); + if ($rootTeam === null) { + throw new HttpException(403, 'Root team is not available for OAuth user provisioning'); + } + + $user = User::withoutEvents(fn () => User::create([ + 'name' => $name, + 'email' => $email, + 'password' => Hash::make(Str::random(64)), + ])); + + $user->teams()->attach($rootTeam, ['role' => 'member']); + + return $user; + }); + } +} diff --git a/app/Services/CloudflareTokenValidator.php b/app/Services/CloudflareTokenValidator.php new file mode 100644 index 0000000000..2a4a761027 --- /dev/null +++ b/app/Services/CloudflareTokenValidator.php @@ -0,0 +1,42 @@ +client($token); + $verification = $client->get('https://api.cloudflare.com/client/v4/user/tokens/verify'); + + if (! $verification->successful() || $verification->json('result.status') !== 'active') { + return false; + } + + if (in_array('dns', $capabilities, true)) { + $zones = $client->get('https://api.cloudflare.com/client/v4/zones', ['per_page' => 1]); + $zoneId = $zones->json('result.0.id'); + + if (! $zones->successful() || ! is_string($zoneId)) { + return false; + } + + return $client->get("https://api.cloudflare.com/client/v4/zones/{$zoneId}/dns_records", [ + 'per_page' => 1, + ])->successful(); + } + + return true; + } + + private function client(string $token): PendingRequest + { + return Http::withToken($token) + ->acceptJson() + ->connectTimeout(5) + ->timeout(10); + } +} diff --git a/app/Services/DatabaseStartCommandExecutor.php b/app/Services/DatabaseStartCommandExecutor.php new file mode 100644 index 0000000000..dab3599101 --- /dev/null +++ b/app/Services/DatabaseStartCommandExecutor.php @@ -0,0 +1,77 @@ +destination->server; + if ($server->isNonRoot()) { + $commands = parseCommandsByLineForSudo(collect($commands), $server)->all(); + } + + $secrets = method_exists($database, 'resolvedSecretManagerValuesForRedaction') + ? $database->resolvedSecretManagerValuesForRedaction() + : []; + $remoteCommand = SshMultiplexingHelper::generateSshCommand($server, implode("\n", $commands)); + + $activity->properties = $activity->properties->merge(['status' => ProcessStatus::IN_PROGRESS->value]); + $activity->save(); + + $process = Process::timeout(config('constants.ssh.command_timeout')) + ->idleTimeout(3600) + ->start($remoteCommand, function (string $type, string $output) use ($activity, $secrets): void { + $this->appendOutput($activity, $type, $this->redact($output, $secrets)); + }); + + $result = $process->wait(); + $status = $result->successful() ? ProcessStatus::FINISHED : ProcessStatus::ERROR; + $activity->properties = $activity->properties->merge([ + 'status' => $status->value, + 'exitCode' => $result->exitCode(), + ]); + $activity->save(); + + if (! $result->successful()) { + throw new \RuntimeException($this->redact($result->errorOutput(), $secrets), $result->exitCode()); + } + + return $activity; + } + + private function redact(string $value, array $secrets): string + { + foreach ($secrets as $secret) { + if (is_string($secret) && $secret !== '') { + $value = str_replace($secret, REDACTED, $value); + } + } + + return sanitize_utf8_text(remove_iip($value)); + } + + private function appendOutput(Activity $activity, string $type, string $output): void + { + if ($output === '') { + return; + } + + $entries = json_decode($activity->description ?: '[]', true, flags: JSON_THROW_ON_ERROR); + $entries[] = [ + 'type' => $type, + 'output' => $output, + 'timestamp' => hrtime(true), + 'batch' => 1, + 'order' => count($entries) + 1, + ]; + $activity->description = json_encode($entries, flags: JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); + $activity->save(); + } +} diff --git a/app/Services/DopplerService.php b/app/Services/DopplerService.php new file mode 100644 index 0000000000..2513a4f7d8 --- /dev/null +++ b/app/Services/DopplerService.php @@ -0,0 +1,57 @@ +client()->get($this->baseUrl.'/v3/me')->successful(); + } catch (\Throwable) { + return false; + } + } + + /** + * Download all secrets for a config. Project and config are not needed for + * service tokens (the token itself is pinned to one config). + * + * @return array + */ + public function fetchSecrets(?string $project = null, ?string $config = null): array + { + $query = ['format' => 'json']; + if (filled($project)) { + $query['project'] = $project; + } + if (filled($config)) { + $query['config'] = $config; + } + + $response = $this->client()->get($this->baseUrl.'/v3/configs/config/secrets/download', $query); + + if (! $response->successful()) { + throw new \RuntimeException('Doppler API error: '.($response->json('messages.0') ?? 'HTTP '.$response->status())); + } + + return collect($response->json()) + ->map(fn ($value) => is_string($value) ? $value : json_encode($value)) + ->all(); + } + + private function client(): PendingRequest + { + return Http::withToken($this->token) + ->acceptJson() + ->connectTimeout(5) + ->timeout(10); + } +} diff --git a/app/Services/InfisicalService.php b/app/Services/InfisicalService.php new file mode 100644 index 0000000000..06f1e5d49f --- /dev/null +++ b/app/Services/InfisicalService.php @@ -0,0 +1,89 @@ + */ + private array $httpClientOptions; + + public function __construct(string $baseUrl, private string $clientId, private string $clientSecret) + { + $this->baseUrl = rtrim($baseUrl, '/'); + Validator::make(['base_url' => $this->baseUrl], ['base_url' => new SafeExternalUrl])->validate(); + $this->httpClientOptions = SafeExternalUrl::httpClientOptions($this->baseUrl); + } + + public function validate(): bool + { + try { + $this->login(); + + return true; + } catch (\Throwable) { + return false; + } + } + + /** + * @return array + */ + public function fetchSecrets(string $projectId, string $environment, string $secretPath = '/'): array + { + $client = $this->client()->withToken($this->login()); + $secretPath = $secretPath ?: '/'; + + $response = $client->get($this->baseUrl.'/api/v4/secrets', [ + 'projectId' => $projectId, + 'environment' => $environment, + 'secretPath' => $secretPath, + ]); + + // Older self-hosted instances only expose the v3 endpoint. + if ($response->status() === 404) { + $response = $client->get($this->baseUrl.'/api/v3/secrets/raw', [ + 'workspaceId' => $projectId, + 'environment' => $environment, + 'secretPath' => $secretPath, + ]); + } + + if (! $response->successful()) { + throw new \RuntimeException('Infisical API error: '.($response->json('message') ?? 'HTTP '.$response->status())); + } + + return collect($response->json('secrets', [])) + ->mapWithKeys(fn ($secret) => [(string) data_get($secret, 'secretKey') => (string) data_get($secret, 'secretValue', '')]) + ->all(); + } + + private function login(): string + { + $response = $this->client()->post($this->baseUrl.'/api/v1/auth/universal-auth/login', [ + 'clientId' => $this->clientId, + 'clientSecret' => $this->clientSecret, + ]); + + $accessToken = $response->json('accessToken'); + if (! $response->successful() || blank($accessToken)) { + throw new \RuntimeException('Infisical login failed: '.($response->json('message') ?? 'HTTP '.$response->status())); + } + + return $accessToken; + } + + private function client(): PendingRequest + { + return Http::acceptJson() + ->withOptions($this->httpClientOptions) + ->connectTimeout(5) + ->timeout(10); + } +} diff --git a/app/Services/IntegrationTokenValidator.php b/app/Services/IntegrationTokenValidator.php new file mode 100644 index 0000000000..6033ce98f7 --- /dev/null +++ b/app/Services/IntegrationTokenValidator.php @@ -0,0 +1,39 @@ + app(CloudflareTokenValidator::class)->validate($token, $capabilities), + 'doppler' => (new DopplerService($token))->validate(), + 'infisical' => (new InfisicalService( + (string) data_get($metadata, 'base_url', 'https://app.infisical.com'), + (string) data_get($metadata, 'client_id'), + $token, + ))->validate(), + 'vault' => (new VaultService( + (string) data_get($metadata, 'base_url'), + $token, + data_get($metadata, 'namespace'), + ))->validate(), + default => false, + }; + } + + public function errorMessage(string $provider): string + { + return match ($provider) { + 'cloudflare' => 'The token could not access the selected Cloudflare capabilities. Check its permissions and zone resources.', + 'doppler' => 'The Doppler token could not be verified. Check the token and its access.', + 'infisical' => 'Infisical login failed. Check the base URL, the client ID, and the client secret.', + 'vault' => 'The Vault token could not be verified. Check the base URL, the namespace, and the token.', + default => 'The token could not be verified.', + }; + } +} diff --git a/app/Services/VaultService.php b/app/Services/VaultService.php new file mode 100644 index 0000000000..e41652cd54 --- /dev/null +++ b/app/Services/VaultService.php @@ -0,0 +1,68 @@ + */ + private array $httpClientOptions; + + public function __construct(string $baseUrl, private string $token, private ?string $namespace = null) + { + $this->baseUrl = rtrim($baseUrl, '/'); + Validator::make(['base_url' => $this->baseUrl], ['base_url' => new SafeExternalUrl])->validate(); + $this->httpClientOptions = SafeExternalUrl::httpClientOptions($this->baseUrl); + } + + public function validate(): bool + { + try { + return $this->client()->get($this->baseUrl.'/v1/auth/token/lookup-self')->successful(); + } catch (\Throwable) { + return false; + } + } + + /** + * Read a KV v2 secret. Non-string values are stored as JSON strings. + * + * @return array + */ + public function fetchSecrets(string $mount, string $path): array + { + $mount = trim($mount, '/'); + $path = trim($path, '/'); + + $response = $this->client()->get($this->baseUrl."/v1/{$mount}/data/{$path}"); + + if (! $response->successful()) { + throw new \RuntimeException('Vault API error: '.($response->json('errors.0') ?? 'HTTP '.$response->status())); + } + + return collect($response->json('data.data', [])) + ->map(fn ($value) => is_string($value) ? $value : json_encode($value)) + ->all(); + } + + private function client(): PendingRequest + { + $client = Http::withHeaders(['X-Vault-Token' => $this->token]) + ->acceptJson() + ->withOptions($this->httpClientOptions) + ->connectTimeout(5) + ->timeout(10); + + if (filled($this->namespace)) { + $client = $client->withHeaders(['X-Vault-Namespace' => $this->namespace]); + } + + return $client; + } +} diff --git a/app/Support/RemoteSecretReferences.php b/app/Support/RemoteSecretReferences.php new file mode 100644 index 0000000000..530c29a28c --- /dev/null +++ b/app/Support/RemoteSecretReferences.php @@ -0,0 +1,64 @@ + Referenced secret key names (unique, in order of appearance) + */ + public static function referencedKeys(?string $value): array + { + if (blank($value)) { + return []; + } + + preg_match_all(self::PATTERN, $value, $matches); + + return array_values(array_unique($matches[1])); + } + + /** + * Replace every reference with its value from the secrets map. + * Keys missing from the map are left as-is — collect them first with + * missingKeys() and fail before calling substitute(). + * + * @param array $secrets + */ + public static function substitute(string $value, array $secrets): string + { + return preg_replace_callback( + self::PATTERN, + fn (array $matches) => array_key_exists($matches[1], $secrets) ? $secrets[$matches[1]] : $matches[0], + $value, + ); + } + + /** + * @param array $secrets + * @return list + */ + public static function missingKeys(?string $value, array $secrets): array + { + return array_values(array_filter( + self::referencedKeys($value), + fn (string $key) => ! array_key_exists($key, $secrets), + )); + } +} diff --git a/app/Traits/Auditable.php b/app/Traits/Auditable.php new file mode 100644 index 0000000000..878d46c1e4 --- /dev/null +++ b/app/Traits/Auditable.php @@ -0,0 +1,102 @@ + $model->recordAuditMutation('created')); + static::updated(fn (Model $model) => $model->recordAuditMutation('updated')); + static::deleted(fn (Model $model) => $model->recordAuditMutation('deleted')); + } + + private function recordAuditMutation(string $action): void + { + if (! $this->auditLoggingEnabled || ! auth()->check()) { + return; + } + + $teamId = $this->auditTeamId(); + if ($teamId === null) { + return; + } + + $changedFields = $action === 'updated' + ? collect(array_keys($this->getChanges())) + ->reject(fn (string $field): bool => in_array($field, [ + 'updated_at', + 'order', + 'status', + ...($this->auditExclude ?? []), + ], true)) + ->values() + ->all() + : []; + + if ($action === 'updated' && $changedFields === []) { + return; + } + + $resourceType = Str::snake(class_basename($this)); + $source = auth()->user()?->currentAccessToken() instanceof PersonalAccessToken ? 'api' : 'ui'; + + auditLog("{$source}.{$resourceType}.{$action}", [ + 'team_id' => $teamId, + "{$resourceType}_uuid" => $this->getAttribute('uuid'), + "{$resourceType}_name" => $this->getAttribute('name') ?? $this->getAttribute('key'), + 'changed_fields' => $changedFields, + ]); + } + + public function withoutAuditLogging(Closure $callback): mixed + { + $wasAuditLoggingEnabled = $this->auditLoggingEnabled; + $this->auditLoggingEnabled = false; + + try { + return $callback(); + } finally { + $this->auditLoggingEnabled = $wasAuditLoggingEnabled; + } + } + + private function auditTeamId(): ?int + { + if ($this instanceof Team) { + return (int) $this->getKey(); + } + + if ($this->getAttribute('team_id') !== null) { + return (int) $this->getAttribute('team_id'); + } + + if ($this->getAttribute('project_id') !== null) { + return $this->project?->team_id; + } + + if ($this->getAttribute('environment_id') !== null) { + return $this->environment?->project?->team_id; + } + + if ($this->getAttribute('server_id') !== null) { + return $this->server?->team_id; + } + + if ($this->getAttribute('resourceable_id') !== null) { + return $this->resourceable?->team()?->id + ?? $this->resourceable?->team_id + ?? $this->resourceable?->environment?->project?->team_id; + } + + return null; + } +} diff --git a/app/Traits/ExecuteRemoteCommand.php b/app/Traits/ExecuteRemoteCommand.php index a2c3d06da9..b8ff5df14b 100644 --- a/app/Traits/ExecuteRemoteCommand.php +++ b/app/Traits/ExecuteRemoteCommand.php @@ -46,6 +46,13 @@ trait ExecuteRemoteCommand ); } + if (isset($this->remote_secrets_cache)) { + $lockedVars = $lockedVars->merge(array_values(array_filter( + $this->remote_secrets_cache, + static fn (mixed $value): bool => is_string($value) && $value !== '' + ))); + } + foreach ($lockedVars as $key => $value) { $escapedValue = preg_quote($value, '/'); $text = preg_replace( diff --git a/app/Traits/ExecutesDatabaseStartCommands.php b/app/Traits/ExecutesDatabaseStartCommands.php new file mode 100644 index 0000000000..d267a8b1b2 --- /dev/null +++ b/app/Traits/ExecutesDatabaseStartCommands.php @@ -0,0 +1,19 @@ +execute($commands, $database, $activity); + } + + return remote_process($commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged'); + } +} diff --git a/app/Traits/HasSecretManager.php b/app/Traits/HasSecretManager.php new file mode 100644 index 0000000000..8b3e50b7bd --- /dev/null +++ b/app/Traits/HasSecretManager.php @@ -0,0 +1,107 @@ +|null */ + private ?array $resolvedSecretManagerValues = null; + + public static function bootHasSecretManager(): void + { + static::deleting(fn ($resource) => $resource->secretManagerLink()->delete()); + } + + public function secretManagerLink(): MorphOne + { + return $this->morphOne(SecretManagerLink::class, 'resourceable'); + } + + public function resolveSecretManagerEnvironmentVariable(EnvironmentVariable $environmentVariable): ?string + { + $value = $this->resolveSecretManagerEnvironmentVariableValue($environmentVariable); + + return $this->formatEnvironmentVariableValue($environmentVariable, $value); + } + + public function formatEnvironmentVariableValue(EnvironmentVariable $environmentVariable, ?string $value): ?string + { + if ($value === null) { + return null; + } + + if (json_validate($value) && (str_starts_with($value, '{') || str_starts_with($value, '['))) { + return $value; + } + + return $environmentVariable->is_literal || $environmentVariable->is_multiline + ? "'{$value}'" + : escapeEnvVariables($value); + } + + public function resolveSecretManagerEnvironmentVariableValue(EnvironmentVariable $environmentVariable): ?string + { + $value = $this->resolvedEnvironmentVariableValue($environmentVariable); + + if ($value === null) { + return null; + } + + if (RemoteSecretReferences::containsReference($value)) { + $secrets = $this->secretManagerValues(); + $missing = RemoteSecretReferences::missingKeys($value, $secrets); + + if ($missing !== []) { + throw new RuntimeException('Missing secret keys: '.implode(', ', $missing)." (referenced by {$environmentVariable->key})."); + } + + $value = RemoteSecretReferences::substitute($value, $secrets); + } + + return $value; + } + + public function environmentVariableUsesSecretManager(EnvironmentVariable $environmentVariable): bool + { + return RemoteSecretReferences::containsReference( + $this->resolvedEnvironmentVariableValue($environmentVariable), + ); + } + + private function resolvedEnvironmentVariableValue(EnvironmentVariable $environmentVariable): ?string + { + return $environmentVariable->get_real_environment_variables_with_server( + $environmentVariable->value, + $this, + data_get($this, 'server'), + ); + } + + /** @return array */ + private function secretManagerValues(): array + { + if ($this->resolvedSecretManagerValues !== null) { + return $this->resolvedSecretManagerValues; + } + + $link = $this->secretManagerLink()->with('integrationToken')->first(); + + if (! $link) { + throw new RuntimeException('Environment variables reference remote secrets, but no secret manager source is configured.'); + } + + return $this->resolvedSecretManagerValues = $link->fetchSecrets(); + } + + /** @return array */ + public function resolvedSecretManagerValuesForRedaction(): array + { + return $this->resolvedSecretManagerValues ?? []; + } +} diff --git a/app/Traits/HasSecretManagerAutocomplete.php b/app/Traits/HasSecretManagerAutocomplete.php new file mode 100644 index 0000000000..1b46ca2dd5 --- /dev/null +++ b/app/Traits/HasSecretManagerAutocomplete.php @@ -0,0 +1,58 @@ +secretManagerLinkForAutocomplete() !== null; + } + + /** + * @return list + */ + public function fetchSecretManagerKeys(): array + { + $this->skipRender(); + + $link = $this->secretManagerLinkForAutocomplete(); + + if (! $link) { + return []; + } + + try { + $this->authorize('view', $link->resourceable); + $keys = array_keys($link->fetchSecrets()); + sort($keys); + + return $keys; + } catch (\Throwable) { + throw new \RuntimeException('Unable to fetch secret manager keys.'); + } + } + + private function secretManagerLinkForAutocomplete(): ?SecretManagerLink + { + $resource = $this->secretManagerResource(); + + if (! $resource || ! method_exists($resource, 'secretManagerLink')) { + return null; + } + + if (! $resource->relationLoaded('secretManagerLink')) { + $resource->load('secretManagerLink.integrationToken'); + } + + return $resource->secretManagerLink; + } +} diff --git a/app/View/Components/Forms/EnvVarInput.php b/app/View/Components/Forms/EnvVarInput.php index a3e6646fec..9ff5d72dc5 100644 --- a/app/View/Components/Forms/EnvVarInput.php +++ b/app/View/Components/Forms/EnvVarInput.php @@ -35,6 +35,7 @@ class EnvVarInput extends Component public mixed $canResource = null, public bool $autoDisable = true, public array $availableVars = [], + public bool $hasVaultSource = false, public ?string $projectUuid = null, public ?string $environmentUuid = null, public ?string $serverUuid = null, diff --git a/bootstrap/helpers/applications.php b/bootstrap/helpers/applications.php index 339a0bcf7b..2fb0bb3f53 100644 --- a/bootstrap/helpers/applications.php +++ b/bootstrap/helpers/applications.php @@ -84,6 +84,15 @@ function queue_application_deployment(Application $application, string $deployme 'only_this_server' => $only_this_server, ]); + if (auth()->check() && ! $is_webhook && ! $is_api && ! $rollback) { + auditLog($restart_only ? 'ui.application.restarted' : 'ui.application.deployed', [ + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'deployment_uuid' => $deployment_uuid, + 'force_rebuild' => $force_rebuild, + ]); + } + if ($no_questions_asked) { $deployment->update([ 'status' => ApplicationDeploymentStatus::IN_PROGRESS->value, diff --git a/bootstrap/helpers/audit.php b/bootstrap/helpers/audit.php index 8477450c4b..1a1ad0a994 100644 --- a/bootstrap/helpers/audit.php +++ b/bootstrap/helpers/audit.php @@ -1,13 +1,10 @@ $context Identifiers + outcome details. @@ -16,39 +13,15 @@ if (! function_exists('auditLog')) { function auditLog(string $event, array $context = [], string $level = 'info'): void { try { - $request = app()->bound('request') ? request() : null; - $user = auth()->check() ? auth()->user() : null; - $token = $user?->currentAccessToken(); - - $base = [ - 'event' => $event, - 'ip' => $request?->ip(), - 'ua' => substr((string) $request?->userAgent(), 0, 200), - 'user_id' => $user?->id, - 'user_email' => $user?->email, - 'team_id' => $token ? data_get($token, 'team_id') : null, - 'token_id' => $token?->id ?? null, - 'token_name' => $token?->name ?? null, - 'method' => $request?->method(), - 'path' => $request?->path(), - ]; - - $payload = array_merge($base, $context); - - Log::channel('audit')->{$level}($event, $payload); - } catch (Throwable $e) { - // Audit logging must never break the request path. - try { - Log::warning('auditLog failed: '.$e->getMessage(), ['event' => $event]); - } catch (Throwable) { - } + AuditEvent::record($event, $context); + } catch (Throwable) { } } } if (! function_exists('auditLogWebhookFailure')) { /** - * Record a webhook signature/auth verification failure to the `audit` channel. + * Record a webhook signature/auth verification failure. */ function auditLogWebhookFailure(string $provider, string $reason, array $context = []): void { @@ -58,10 +31,7 @@ if (! function_exists('auditLogWebhookFailure')) { $event = "webhook.{$provider}.signature_failed"; $base = [ - 'event' => $event, 'reason' => $reason, - 'ip' => $request?->ip(), - 'ua' => substr((string) $request?->userAgent(), 0, 200), 'method' => $request?->method(), 'path' => $request?->path(), 'event_header' => $request?->header('X-GitHub-Event') @@ -70,12 +40,8 @@ if (! function_exists('auditLogWebhookFailure')) { ?? $request?->header('X-Event-Key'), ]; - Log::channel('audit')->warning($event, array_merge($base, $context)); - } catch (Throwable $e) { - try { - Log::warning('auditLogWebhookFailure failed: '.$e->getMessage(), ['provider' => $provider]); - } catch (Throwable) { - } + auditLog($event, array_merge($base, $context), 'warning'); + } catch (Throwable) { } } } diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index fbfbc9b566..44ceb603b9 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -4619,7 +4619,7 @@ function formatContainerStatus(string $status): string * Check if password confirmation should be skipped. * Returns true if: * - Two-step confirmation is globally disabled - * - User has no password (OAuth users) + * - User has no usable local password confirmation (including SSO users) * * Used by modal-confirmation.blade.php to determine if password step should be shown. * @@ -4632,8 +4632,9 @@ function shouldSkipPasswordConfirmation(): bool return true; } - // Skip if user has no password (OAuth users) - if (! Auth::user()?->hasPassword()) { + // OAuth users may have an unusable generated password, so the linked + // identity is the source of truth for whether confirmation is possible. + if (! Auth::user()?->requiresPasswordConfirmation()) { return true; } @@ -4644,7 +4645,7 @@ function shouldSkipPasswordConfirmation(): bool * Verify password for two-step confirmation. * Skips verification if: * - Two-step confirmation is globally disabled - * - User has no password (OAuth users) + * - User has no usable local password confirmation (including SSO users) * * @param mixed $password The password to verify (may be array if skipped by frontend) * @param Component|null $component Optional Livewire component to add errors to diff --git a/bootstrap/helpers/socialite.php b/bootstrap/helpers/socialite.php index fd3fbe74ba..f177e6c16f 100644 --- a/bootstrap/helpers/socialite.php +++ b/bootstrap/helpers/socialite.php @@ -1,7 +1,13 @@ client_id, $oauth_setting->client_secret, $oauth_setting->redirect_uri, @@ -23,7 +29,7 @@ function get_socialite_provider(string $provider) } if ($provider == 'authentik' || $provider == 'clerk') { - $authentik_clerk_config = new \SocialiteProviders\Manager\Config( + $authentik_clerk_config = new Config( $oauth_setting->client_id, $oauth_setting->client_secret, $oauth_setting->redirect_uri, @@ -34,7 +40,7 @@ function get_socialite_provider(string $provider) } if ($provider == 'zitadel') { - $zitadel_config = new \SocialiteProviders\Manager\Config( + $zitadel_config = new Config( $oauth_setting->client_id, $oauth_setting->client_secret, $oauth_setting->redirect_uri, @@ -44,8 +50,12 @@ function get_socialite_provider(string $provider) return Socialite::driver('zitadel')->setConfig($zitadel_config); } + if ($provider === 'oidc') { + return Socialite::driver('oidc')->setConfig(OidcConfig::fromOauthSetting($oauth_setting)); + } + if ($provider == 'google') { - $google_config = new \SocialiteProviders\Manager\Config( + $google_config = new Config( $oauth_setting->client_id, $oauth_setting->client_secret, $oauth_setting->redirect_uri @@ -63,11 +73,11 @@ function get_socialite_provider(string $provider) ]; $provider_class_map = [ - 'bitbucket' => \Laravel\Socialite\Two\BitbucketProvider::class, - 'discord' => \SocialiteProviders\Discord\Provider::class, - 'github' => \Laravel\Socialite\Two\GithubProvider::class, - 'gitlab' => \Laravel\Socialite\Two\GitlabProvider::class, - 'infomaniak' => \SocialiteProviders\Infomaniak\Provider::class, + 'bitbucket' => BitbucketProvider::class, + 'discord' => Provider::class, + 'github' => GithubProvider::class, + 'gitlab' => GitlabProvider::class, + 'infomaniak' => SocialiteProviders\Infomaniak\Provider::class, ]; $socialite = Socialite::buildProvider( diff --git a/composer.json b/composer.json index 416b5e8f65..4778cc91dd 100644 --- a/composer.json +++ b/composer.json @@ -14,6 +14,7 @@ "php": "^8.4", "danharrin/livewire-rate-limiting": "^2.2.1", "doctrine/dbal": "^4.4.4", + "firebase/php-jwt": "7.1.0", "guzzlehttp/guzzle": "^7.15.3", "laravel/fortify": "^1.37.3", "laravel/framework": "^12.65.0", @@ -63,7 +64,6 @@ "driftingly/rector-laravel": "^2.5.0", "fakerphp/faker": "^1.24.1", "laravel/boost": "^2.4.8", - "laravel/dusk": "^8.6.0", "laravel/pint": "^1.30.4", "mockery/mockery": "^1.6.12", "nunomaduro/collision": "^8.9.5", diff --git a/composer.lock b/composer.lock index e5718b31b0..55be2166b6 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "971daeb1b3078a36428c0fb56bb895b7", + "content-hash": "13e5d201c34a64cdf53e80a21304c9d5", "packages": [ { "name": "aws/aws-crt-php", @@ -13698,80 +13698,6 @@ }, "time": "2026-05-19T20:09:50+00:00" }, - { - "name": "laravel/dusk", - "version": "v8.6.0", - "source": { - "type": "git", - "url": "https://github.com/laravel/dusk.git", - "reference": "e7fd48762c6a82ad2cd311db07587aa2a97ce143" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/laravel/dusk/zipball/e7fd48762c6a82ad2cd311db07587aa2a97ce143", - "reference": "e7fd48762c6a82ad2cd311db07587aa2a97ce143", - "shasum": "" - }, - "require": { - "ext-json": "*", - "ext-zip": "*", - "guzzlehttp/guzzle": "^7.5", - "illuminate/console": "^10.0|^11.0|^12.0|^13.0", - "illuminate/support": "^10.0|^11.0|^12.0|^13.0", - "php": "^8.1", - "php-webdriver/webdriver": "^1.15.2", - "symfony/console": "^6.2|^7.0|^8.0", - "symfony/finder": "^6.2|^7.0|^8.0", - "symfony/process": "^6.2|^7.0|^8.0", - "vlucas/phpdotenv": "^5.2" - }, - "require-dev": { - "laravel/framework": "^10.0|^11.0|^12.0|^13.0", - "mockery/mockery": "^1.6", - "orchestra/testbench-core": "^8.19|^9.17|^10.8|^11.0", - "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^10.1|^11.0|^12.0.1", - "psy/psysh": "^0.11.12|^0.12", - "symfony/yaml": "^6.2|^7.0|^8.0" - }, - "suggest": { - "ext-pcntl": "Used to gracefully terminate Dusk when tests are running." - }, - "type": "library", - "extra": { - "laravel": { - "providers": [ - "Laravel\\Dusk\\DuskServiceProvider" - ] - } - }, - "autoload": { - "psr-4": { - "Laravel\\Dusk\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Taylor Otwell", - "email": "taylor@laravel.com" - } - ], - "description": "Laravel Dusk provides simple end-to-end testing and browser automation.", - "keywords": [ - "laravel", - "testing", - "webdriver" - ], - "support": { - "issues": "https://github.com/laravel/dusk/issues", - "source": "https://github.com/laravel/dusk/tree/v8.6.0" - }, - "time": "2026-04-15T14:50:40+00:00" - }, { "name": "laravel/pint", "version": "v1.30.4", @@ -14817,72 +14743,6 @@ }, "time": "2022-02-21T01:04:05+00:00" }, - { - "name": "php-webdriver/webdriver", - "version": "1.16.0", - "source": { - "type": "git", - "url": "https://github.com/php-webdriver/php-webdriver.git", - "reference": "ac0662863aa120b4f645869f584013e4c4dba46a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-webdriver/php-webdriver/zipball/ac0662863aa120b4f645869f584013e4c4dba46a", - "reference": "ac0662863aa120b4f645869f584013e4c4dba46a", - "shasum": "" - }, - "require": { - "ext-curl": "*", - "ext-json": "*", - "ext-zip": "*", - "php": "^7.3 || ^8.0", - "symfony/polyfill-mbstring": "^1.12", - "symfony/process": "^5.0 || ^6.0 || ^7.0 || ^8.0" - }, - "replace": { - "facebook/webdriver": "*" - }, - "require-dev": { - "ergebnis/composer-normalize": "^2.20.0", - "ondram/ci-detector": "^4.0", - "php-coveralls/php-coveralls": "^2.4", - "php-mock/php-mock-phpunit": "^2.0", - "php-parallel-lint/php-parallel-lint": "^1.2", - "phpunit/phpunit": "^9.3", - "squizlabs/php_codesniffer": "^3.5", - "symfony/var-dumper": "^5.0 || ^6.0 || ^7.0 || ^8.0" - }, - "suggest": { - "ext-simplexml": "For Firefox profile creation" - }, - "type": "library", - "autoload": { - "files": [ - "lib/Exception/TimeoutException.php" - ], - "psr-4": { - "Facebook\\WebDriver\\": "lib/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "A PHP client for Selenium WebDriver. Previously facebook/webdriver.", - "homepage": "https://github.com/php-webdriver/php-webdriver", - "keywords": [ - "Chromedriver", - "geckodriver", - "php", - "selenium", - "webdriver" - ], - "support": { - "issues": "https://github.com/php-webdriver/php-webdriver/issues", - "source": "https://github.com/php-webdriver/php-webdriver/tree/1.16.0" - }, - "time": "2025-12-28T23:57:40+00:00" - }, { "name": "phpstan/phpstan", "version": "2.2.8", diff --git a/config/app.php b/config/app.php index 13a5b7d4b8..59aa6f4c28 100644 --- a/config/app.php +++ b/config/app.php @@ -193,8 +193,8 @@ return [ */ 'maintenance' => [ - 'driver' => 'cache', - 'store' => 'redis', + 'driver' => env('APP_MAINTENANCE_DRIVER', 'cache'), + 'store' => env('APP_MAINTENANCE_STORE', 'redis'), ], /* diff --git a/config/logging.php b/config/logging.php index 05cf8e13d3..89c9d38dde 100644 --- a/config/logging.php +++ b/config/logging.php @@ -133,13 +133,6 @@ return [ 'days' => 14, ], - 'audit' => [ - 'driver' => 'daily', - 'path' => storage_path('logs/audit.log'), - 'level' => env('LOG_AUDIT_LEVEL', 'info'), - 'days' => env('LOG_AUDIT_DAYS', 90), - 'replace_placeholders' => true, - ], ], ]; diff --git a/config/services.php b/config/services.php index c5956cf6c9..3a2a0631ef 100644 --- a/config/services.php +++ b/config/services.php @@ -60,6 +60,14 @@ return [ 'tenant' => env('GOOGLE_TENANT'), ], + 'oidc' => [ + 'client_id' => env('OIDC_CLIENT_ID'), + 'client_secret' => env('OIDC_CLIENT_SECRET'), + 'redirect' => env('OIDC_REDIRECT_URI'), + 'base_url' => env('OIDC_BASE_URL'), + 'custom_label' => env('OIDC_LOGIN_LABEL'), + ], + 'zitadel' => [ 'client_id' => env('ZITADEL_CLIENT_ID'), 'client_secret' => env('ZITADEL_CLIENT_SECRET'), diff --git a/database/factories/AuditEventFactory.php b/database/factories/AuditEventFactory.php new file mode 100644 index 0000000000..01ddebbd2b --- /dev/null +++ b/database/factories/AuditEventFactory.php @@ -0,0 +1,29 @@ + + */ +class AuditEventFactory extends Factory +{ + protected $model = AuditEvent::class; + + public function definition(): array + { + return [ + 'team_id' => Team::factory(), + 'event' => 'ui.application.updated', + 'source' => 'ui', + 'action' => 'updated', + 'actor_type' => 'user', + 'description' => 'Application updated', + 'metadata' => [], + 'created_at' => now(), + ]; + } +} diff --git a/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php b/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php index 19c4445b26..13fe6b6784 100644 --- a/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php +++ b/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php @@ -8,6 +8,12 @@ return new class extends Migration /** * The configuration snapshot/diff now store an encrypted blob (not valid * JSON), so the columns must hold arbitrary text instead of json. + * + * Coolify's own backend runs exclusively on PostgreSQL in production and + * SQLite in testing (see config/database.php — the only configured + * connections are `pgsql` and `testing`). MySQL/MariaDB are user-managed + * resources, never Coolify's application database, so no driver path is + * needed for them here. */ public function up(): void { diff --git a/database/migrations/2026_06_04_091631_add_oidc_fields_to_oauth_settings_table.php b/database/migrations/2026_06_04_091631_add_oidc_fields_to_oauth_settings_table.php new file mode 100644 index 0000000000..3160ef9ddb --- /dev/null +++ b/database/migrations/2026_06_04_091631_add_oidc_fields_to_oauth_settings_table.php @@ -0,0 +1,40 @@ +string('custom_label')->nullable(); + $table->string('scopes')->nullable(); + $table->boolean('allow_registration')->default(true); + $table->boolean('require_email_verified')->default(true); + $table->boolean('use_pkce')->default(true); + $table->unsignedSmallInteger('clock_skew_seconds')->default(60); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('oauth_settings', function (Blueprint $table) { + $table->dropColumn([ + 'custom_label', + 'scopes', + 'allow_registration', + 'require_email_verified', + 'use_pkce', + 'clock_skew_seconds', + ]); + }); + } +}; diff --git a/database/migrations/2026_06_04_091631_create_oauth_identities_table.php b/database/migrations/2026_06_04_091631_create_oauth_identities_table.php new file mode 100644 index 0000000000..9f838e5779 --- /dev/null +++ b/database/migrations/2026_06_04_091631_create_oauth_identities_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('provider'); + $table->string('issuer'); + $table->string('provider_user_id'); + $table->string('email')->nullable()->index(); + $table->json('raw_claims')->nullable(); + $table->timestamp('last_login_at')->nullable(); + $table->timestamps(); + + $table->unique(['provider', 'issuer', 'provider_user_id'], 'oauth_identity_provider_issuer_user_unique'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_identities'); + } +}; diff --git a/database/migrations/2026_06_04_091632_add_oauth_registration_policy_to_instance_settings_table.php b/database/migrations/2026_06_04_091632_add_oauth_registration_policy_to_instance_settings_table.php new file mode 100644 index 0000000000..06c0f1dd52 --- /dev/null +++ b/database/migrations/2026_06_04_091632_add_oauth_registration_policy_to_instance_settings_table.php @@ -0,0 +1,28 @@ +boolean('disable_registration_when_oauth_enabled')->default(false); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('instance_settings', function (Blueprint $table) { + $table->dropColumn('disable_registration_when_oauth_enabled'); + }); + } +}; diff --git a/database/migrations/2026_06_23_151229_add_auto_join_root_team_to_oauth_settings_table.php b/database/migrations/2026_06_23_151229_add_auto_join_root_team_to_oauth_settings_table.php new file mode 100644 index 0000000000..b0f5aad18a --- /dev/null +++ b/database/migrations/2026_06_23_151229_add_auto_join_root_team_to_oauth_settings_table.php @@ -0,0 +1,28 @@ +boolean('auto_join_root_team')->default(false); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('oauth_settings', function (Blueprint $table) { + $table->dropColumn('auto_join_root_team'); + }); + } +}; diff --git a/database/migrations/2026_08_15_000000_create_integration_tokens_table.php b/database/migrations/2026_08_15_000000_create_integration_tokens_table.php new file mode 100644 index 0000000000..a17d3972d5 --- /dev/null +++ b/database/migrations/2026_08_15_000000_create_integration_tokens_table.php @@ -0,0 +1,29 @@ +id(); + $table->string('uuid')->unique(); + $table->foreignId('team_id')->constrained()->cascadeOnDelete(); + $table->string('provider'); + $table->string('name'); + $table->text('token'); + $table->json('capabilities'); + $table->timestamps(); + + $table->index(['team_id', 'provider']); + }); + } + + public function down(): void + { + Schema::dropIfExists('integration_tokens'); + } +}; diff --git a/database/migrations/2026_08_20_000000_create_audit_events_table.php b/database/migrations/2026_08_20_000000_create_audit_events_table.php new file mode 100644 index 0000000000..0ace21f229 --- /dev/null +++ b/database/migrations/2026_08_20_000000_create_audit_events_table.php @@ -0,0 +1,45 @@ +id(); + $table->unsignedBigInteger('team_id')->nullable(); + $table->string('event'); + $table->string('source', 32); + $table->string('action', 64); + $table->string('actor_type', 32); + $table->unsignedBigInteger('actor_id')->nullable(); + $table->string('actor_name')->nullable(); + $table->string('actor_email')->nullable(); + $table->unsignedBigInteger('actor_token_id')->nullable(); + $table->string('actor_token_name')->nullable(); + $table->string('resource_type')->nullable(); + $table->string('resource_uuid')->nullable(); + $table->string('resource_name')->nullable(); + $table->text('description'); + $table->json('metadata')->nullable(); + $table->string('ip_address', 45)->nullable(); + $table->string('user_agent', 200)->nullable(); + $table->timestamp('created_at')->useCurrent(); + + $table->index('created_at'); + $table->index(['team_id', 'created_at', 'id']); + $table->index(['team_id', 'action', 'created_at', 'id']); + $table->index(['team_id', 'source', 'created_at', 'id']); + $table->index(['team_id', 'resource_type', 'resource_uuid', 'created_at']); + $table->index(['team_id', 'actor_id', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('audit_events'); + } +}; diff --git a/database/migrations/2026_08_23_000000_add_secret_manager_integrations.php b/database/migrations/2026_08_23_000000_add_secret_manager_integrations.php new file mode 100644 index 0000000000..744697628f --- /dev/null +++ b/database/migrations/2026_08_23_000000_add_secret_manager_integrations.php @@ -0,0 +1,36 @@ +json('metadata')->nullable()->after('capabilities'); + }); + + Schema::create('secret_manager_links', function (Blueprint $table) { + $table->id(); + $table->string('uuid')->unique(); + $table->string('resourceable_type'); + $table->unsignedBigInteger('resourceable_id'); + $table->foreignId('integration_token_id')->constrained()->cascadeOnDelete(); + $table->json('settings')->nullable(); + $table->timestamps(); + + $table->unique(['resourceable_type', 'resourceable_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('secret_manager_links'); + + Schema::table('integration_tokens', function (Blueprint $table) { + $table->dropColumn('metadata'); + }); + } +}; diff --git a/database/seeders/OauthSettingSeeder.php b/database/seeders/OauthSettingSeeder.php index 2e3e63defd..f916c4a9cd 100644 --- a/database/seeders/OauthSettingSeeder.php +++ b/database/seeders/OauthSettingSeeder.php @@ -23,6 +23,7 @@ class OauthSettingSeeder extends Seeder 'github', 'gitlab', 'google', + 'oidc', 'authentik', 'infomaniak', 'zitadel', diff --git a/docker/coolify-realtime/terminal-utils.js b/docker/coolify-realtime/terminal-utils.js index 0d13dc18f1..c2762f1d85 100644 --- a/docker/coolify-realtime/terminal-utils.js +++ b/docker/coolify-realtime/terminal-utils.js @@ -28,7 +28,7 @@ function normalizeShellArgument(argument) { } export function extractSshArgs(commandString) { - const sshCommandMatch = commandString.match(/ssh (.+?) 'bash -se'/); + const sshCommandMatch = commandString.match(/ssh (.+?) '[^']+' << /); if (!sshCommandMatch) return []; const argsString = sshCommandMatch[1]; diff --git a/docker/coolify-realtime/terminal-utils.test.js b/docker/coolify-realtime/terminal-utils.test.js index 21625eece4..e9acda3270 100644 --- a/docker/coolify-realtime/terminal-utils.test.js +++ b/docker/coolify-realtime/terminal-utils.test.js @@ -63,6 +63,14 @@ test('extractSshArgs preserves proxy command as a single normalized ssh option v assert.equal(sshArgs[4], 'root@example.com'); }); +test('extractSshArgs supports the generated bash or sh fallback command', () => { + const sshArgs = extractSshArgs( + "timeout 3600 ssh -o StrictHostKeyChecking=no 'root'@'10.0.0.5' 'if command -v bash >/dev/null 2>&1; then exec bash -se; else exec sh -se; fi' << \\\\$abc\necho hi\nabc" + ); + + assert.equal(extractTargetHost(sshArgs), '10.0.0.5'); +}); + test('isAuthorizedTargetHost matches normalized hosts against plain allowlist values', () => { assert.equal(isAuthorizedTargetHost("'10.0.0.5'", ['10.0.0.5']), true); assert.equal(isAuthorizedTargetHost('"host.docker.internal"', ['host.docker.internal']), true); diff --git a/lang/de.json b/lang/de.json index 7c43300e67..cbc2237a75 100644 --- a/lang/de.json +++ b/lang/de.json @@ -7,6 +7,7 @@ "auth.login.github": "Mit GitHub anmelden", "auth.login.gitlab": "Mit GitLab anmelden", "auth.login.google": "Mit Google anmelden", + "auth.login.oidc": "Mit SSO anmelden", "auth.login.infomaniak": "Mit Infomaniak anmelden", "auth.login.zitadel": "Mit Zitadel anmelden", "auth.already_registered": "Bereits registriert?", diff --git a/lang/en.json b/lang/en.json index 12c21b6665..b97a10d629 100644 --- a/lang/en.json +++ b/lang/en.json @@ -8,6 +8,7 @@ "auth.login.github": "Login with GitHub", "auth.login.gitlab": "Login with Gitlab", "auth.login.google": "Login with Google", + "auth.login.oidc": "Login with SSO", "auth.login.infomaniak": "Login with Infomaniak", "auth.login.zitadel": "Login with Zitadel", "auth.already_registered": "Already registered?", diff --git a/lang/pl.json b/lang/pl.json index bcd8e23937..b05437ac4e 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -8,6 +8,7 @@ "auth.login.github": "Zaloguj się przez GitHub", "auth.login.gitlab": "Zaloguj się przez Gitlab", "auth.login.google": "Zaloguj się przez Google", + "auth.login.oidc": "Zaloguj się przez SSO", "auth.login.infomaniak": "Zaloguj się przez Infomaniak", "auth.login.zitadel": "Zaloguj się przez Zitadel", "auth.already_registered": "Już zarejestrowany?", diff --git a/public/svgs/oidc.svg b/public/svgs/oidc.svg new file mode 100644 index 0000000000..9c542584ef --- /dev/null +++ b/public/svgs/oidc.svg @@ -0,0 +1,5 @@ + + OpenID Connect + + + diff --git a/resources/css/app.css b/resources/css/app.css index 199f904d08..b5219badf2 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1870,6 +1870,61 @@ html[data-theme="custom"] textarea:disabled { flex-shrink: 0; } +.split-action { + display: inline-flex; +} + +.split-action-main, +.split-action-caret { + @apply button-highlighted; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.375rem; + height: 2rem; + font-size: 13px; + font-weight: 500; + white-space: nowrap; + cursor: pointer; +} + +.split-action-main { + flex: 1 1 auto; + min-width: 0; + padding: 0 0.625rem; + border-radius: 6px 0 0 6px; +} + +.split-action-caret { + flex-shrink: 0; + width: 1.75rem; + border-left: 1px solid rgba(255, 255, 255, 0.25); + border-radius: 0 6px 6px 0; +} + +.split-action > .split-action-main:only-of-type { + border-radius: 6px; +} + +.split-action-main:disabled, +.split-action-caret:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.split-action-main:focus-visible, +.split-action-caret:focus-visible { + outline: none; + position: relative; + z-index: 1; + box-shadow: 0 0 0 1px var(--color-accent); +} + +.application-heading-actions .split-action-main, +.application-heading-actions .split-action-caret { + height: 1.75rem; +} + /* Custom listbox (replaces native - - - diff --git a/resources/views/components/forms/copy-input.blade.php b/resources/views/components/forms/copy-input.blade.php new file mode 100644 index 0000000000..d31fac0bca --- /dev/null +++ b/resources/views/components/forms/copy-input.blade.php @@ -0,0 +1,15 @@ +@props(['text', 'label' => null]) + +
+ @if ($label) + + @endif +
+ + +
+
diff --git a/resources/views/components/forms/env-var-input.blade.php b/resources/views/components/forms/env-var-input.blade.php index 378a3947e3..41a29fbbdb 100644 --- a/resources/views/components/forms/env-var-input.blade.php +++ b/resources/views/components/forms/env-var-input.blade.php @@ -20,13 +20,32 @@ cursorPosition: 0, currentScope: null, availableVars: @js($availableVars), + hasVaultSource: @js($hasVaultSource), + vaultKeysLoading: false, get availableScopes() { // Only include scopes that have at least one variable const allScopes = ['team', 'project', 'environment', 'server']; - return allScopes.filter(scope => { + const scopes = allScopes.filter(scope => { const vars = this.availableVars[scope]; return vars && vars.length > 0; }); + // The vault scope is offered whenever a secret manager source is + // configured; its keys are fetched lazily on first use. + if (this.hasVaultSource) { + scopes.push('vault'); + } + return scopes; + }, + loadVaultKeys() { + if (this.vaultKeysLoading) return; + this.vaultKeysLoading = true; + this.$wire.fetchSecretManagerKeys().then(keys => { + this.availableVars['vault'] = keys || []; + this.vaultKeysLoading = false; + this.handleInput(); + }).catch(() => { + this.vaultKeysLoading = false; + }); }, scopeUrls: @js($scopeUrls), @@ -84,6 +103,15 @@ } this.currentScope = scope; + + // Vault keys are fetched from the secret manager on first use. + if (scope === 'vault' && this.availableVars['vault'] === undefined) { + this.loadVaultKeys(); + this.suggestions = []; + this.showDropdown = true; + return; + } + const scopeVars = this.availableVars[scope] || []; const filtered = scopeVars.filter(v => v.toLowerCase().includes((partial || '').toLowerCase()) @@ -214,6 +242,7 @@ wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]" @endif wire:loading.attr="disabled" + wire:target.except="fetchSecretManagerKeys" @disabled($disabled) @if ($type !== 'password') type="{{ $type }}" @@ -236,7 +265,14 @@
-