diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 6a587c6574..c800c8d94f 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -152,7 +152,12 @@ class ApplicationsController extends Controller $application->settings?->makeHidden(['id', 'application_id', 'created_at', 'updated_at']); } - return serializeApiResponse($application); + $payload = serializeApiResponse($application); + if ($application instanceof Application && filled(data_get($payload, 'docker_compose'))) { + $payload['docker_compose'] = redactLockedValuesInGeneratedCompose($payload['docker_compose'], $application); + } + + return $payload; } /** diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 5a0c4c526c..29f6526a03 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -237,7 +237,19 @@ class DatabasesController extends Controller $this->hideNestedServerSecrets($database, $loadNestedServerSecrets); } - return serializeApiResponse($database); + $payload = serializeApiResponse($database); + + // A locked password variable is never shown again, also not inside the connection URLs. + if (request()->attributes->get('can_read_sensitive', false) === true + && method_exists($database, 'isCredentialLocked') && $database->isCredentialLocked()) { + foreach (['internal_db_url', 'external_db_url'] as $key) { + if ($payload->has($key)) { + $payload->put($key, null); + } + } + } + + return $payload; } private function hideNestedServerSecrets(Model $model, bool $loadRelations = false): void diff --git a/app/Http/Controllers/Api/ServerTransferController.php b/app/Http/Controllers/Api/ServerTransferController.php index a818ae26cc..efd8f428b7 100644 --- a/app/Http/Controllers/Api/ServerTransferController.php +++ b/app/Http/Controllers/Api/ServerTransferController.php @@ -30,7 +30,7 @@ class ServerTransferController extends Controller #[OA\Post( summary: 'Migrate server to another Coolify instance', - description: 'Start a transfer in the background: export this server, import and claim it on the target instance (with the given token), then disable automations here. Follow the progress with GET /servers/{uuid}/transfer. The target URL must use https. Requires read:sensitive and write.', + description: 'Start a transfer in the background: export this server, import and claim it on the target instance (with the given token), then disable automations here. All secrets of the server, also locked environment variables, are sent to the target. Follow the progress with GET /servers/{uuid}/transfer. The target URL must use https. Requires read:sensitive and write, and the owner role of the server team.', path: '/servers/{uuid}/migrate', operationId: 'migrate-server-between-instances', security: [['bearerAuth' => []]], @@ -50,7 +50,7 @@ class ServerTransferController extends Controller ), responses: [ new OA\Response(response: 202, description: 'Transfer started'), - new OA\Response(response: 403, description: 'Missing sensitive permission'), + new OA\Response(response: 403, description: 'Missing sensitive permission or not an owner of the server team'), new OA\Response(response: 404, ref: '#/components/responses/404'), new OA\Response(response: 422, description: 'Validation or remote import failed'), ] @@ -64,7 +64,7 @@ class ServerTransferController extends Controller if (! $this->canReadSensitive($request)) { return response()->json([ - 'message' => 'Migrating a server requires a token with read:sensitive (or root) ability and an admin/owner team role.', + 'message' => 'Migrating a server requires a token with read:sensitive (or root) ability and the owner team role.', ], 403); } @@ -73,7 +73,7 @@ class ServerTransferController extends Controller return response()->json(['message' => 'Server not found.'], 404); } - $this->authorize('update', $server); + $this->authorize('transfer', $server); $return = validateIncomingRequest($request); if ($return instanceof JsonResponse) { diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 367bf28457..aa4ea63110 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -76,7 +76,12 @@ class ServicesController extends Controller $service->makeHidden(['value', 'real_value']); } - return serializeApiResponse($service); + $payload = serializeApiResponse($service); + if ($service instanceof Service && filled(data_get($payload, 'docker_compose'))) { + $payload['docker_compose'] = redactLockedValuesInGeneratedCompose($payload['docker_compose'], $service); + } + + return $payload; } /** diff --git a/app/Livewire/ActivityMonitor.php b/app/Livewire/ActivityMonitor.php index 25935d88e2..12095942c0 100644 --- a/app/Livewire/ActivityMonitor.php +++ b/app/Livewire/ActivityMonitor.php @@ -3,7 +3,9 @@ namespace App\Livewire; use App\Models\Server; +use App\Models\Service; use App\Models\User; +use Illuminate\Database\Eloquent\Model; use Livewire\Attributes\Locked; use Livewire\Component; use Spatie\Activitylog\Models\Activity; @@ -79,41 +81,37 @@ class ActivityMonitor extends Component return; } - $currentTeamId = currentTeam()?->id; + $this->activity = $this->canReadActivity($activity) ? $activity : null; + } - // Check team_id stored directly in activity properties - $activityTeamId = data_get($activity, 'properties.team_id'); - if ($activityTeamId !== null) { - if ((int) $activityTeamId !== (int) $currentTeamId) { - $this->activity = null; + /** + * Activity output can contain secrets, so only users who can edit the resource that the + * activity ran for (or its server) may read it. The policy uses the team of that resource, + * not the session team. + */ + private function canReadActivity(Activity $activity): bool + { + $user = auth()->user(); + if (! $user) { + return false; + } - return; - } + $subject = $activity->subject; + if ($subject instanceof Model) { + return $user->can('update', $subject); + } - $this->activity = $activity; - - return; + // Service starts store the service uuid instead of a subject. + $typeUuid = data_get($activity, 'properties.type_uuid'); + if (filled($typeUuid) && ($service = Service::where('uuid', $typeUuid)->first())) { + return $user->can('update', $service); } - // Fallback: verify ownership via the server that ran the command $serverUuid = data_get($activity, 'properties.server_uuid'); - if ($serverUuid) { - $server = Server::where('uuid', $serverUuid)->first(); - if ($server && (int) $server->team_id !== (int) $currentTeamId) { - $this->activity = null; + $server = filled($serverUuid) ? Server::where('uuid', $serverUuid)->first() : null; - return; - } - - if ($server) { - $this->activity = $activity; - - return; - } - } - - // Fail closed: no team_id and no server_uuid means we cannot verify ownership - $this->activity = null; + // Fail closed: an activity without a resource or server cannot be authorized. + return $server !== null && $user->can('update', $server); } public function polling() diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index cfec74df5c..296076eac3 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -322,6 +322,8 @@ class General extends Component $this->parsedServices = collect([ 'services' => collect(data_get($this->parsedServices, 'services', []))->map(fn () => []), ]); + } elseif (isset($this->parsedServices)) { + $this->parsedServices = redactLockedValuesInGeneratedCompose($this->parsedServices, $this->application); } if ($this->application->build_pack === 'dockercompose') { // Only update if user has permission @@ -411,7 +413,7 @@ class General extends Component $this->application->docker_registry_image_name = $this->dockerRegistryImageName; $this->application->docker_registry_image_tag = $this->dockerRegistryImageTag; $this->application->docker_compose_location = $this->dockerComposeLocation; - $this->application->docker_compose = $this->dockerCompose; + // docker_compose is generated by the parser; the property holds a redacted display copy. $this->application->docker_compose_raw = $this->dockerComposeRaw; $this->application->docker_compose_custom_start_command = $this->dockerComposeCustomStartCommand; $this->application->docker_compose_custom_build_command = $this->dockerComposeCustomBuildCommand; @@ -468,7 +470,7 @@ class General extends Component $this->dockerRegistryImageTag = $this->application->docker_registry_image_tag; $this->dockerComposeLocation = $this->application->docker_compose_location; $canViewCompose = auth()->user()?->can('update', $this->application) ?? false; - $this->dockerCompose = $canViewCompose ? $this->application->docker_compose : null; + $this->dockerCompose = $canViewCompose ? redactLockedValuesInGeneratedCompose($this->application->docker_compose, $this->application) : null; $this->dockerComposeRaw = $canViewCompose ? $this->application->docker_compose_raw : null; $this->dockerComposeCustomStartCommand = $this->application->docker_compose_custom_start_command; $this->dockerComposeCustomBuildCommand = $this->application->docker_compose_custom_build_command; @@ -573,6 +575,7 @@ class General extends Component return; } + $this->parsedServices = redactLockedValuesInGeneratedCompose($this->parsedServices, $this->application); // Refresh parsedServiceDomains to reflect any changes in docker_compose_domains $this->application->refresh(); diff --git a/app/Livewire/Project/Database/Keydb/General.php b/app/Livewire/Project/Database/Keydb/General.php index b677e6ec59..1e159d2f9b 100644 --- a/app/Livewire/Project/Database/Keydb/General.php +++ b/app/Livewire/Project/Database/Keydb/General.php @@ -129,8 +129,8 @@ class General extends Component } else { $this->name = $this->database->name; $this->description = $this->database->description; - $this->keydbConf = $this->database->keydb_conf; $canSeeCredentials = auth()->user()?->can('update', $this->database) ?? false; + $this->keydbConf = $canSeeCredentials ? $this->database->keydb_conf : null; $this->keydbPassword = $canSeeCredentials ? $this->database->keydb_password : ''; $this->image = $this->database->image; $this->portsMappings = $this->database->ports_mappings; diff --git a/app/Livewire/Project/Database/Mariadb/General.php b/app/Livewire/Project/Database/Mariadb/General.php index d12a1f74b3..d65136e9f0 100644 --- a/app/Livewire/Project/Database/Mariadb/General.php +++ b/app/Livewire/Project/Database/Mariadb/General.php @@ -159,7 +159,7 @@ class General extends Component $this->mariadbUser = $this->database->mariadb_user; $this->mariadbPassword = $canSeeCredentials ? $this->database->mariadb_password : ''; $this->mariadbDatabase = $this->database->mariadb_database; - $this->mariadbConf = $this->database->mariadb_conf; + $this->mariadbConf = $canSeeCredentials ? $this->database->mariadb_conf : null; $this->image = $this->database->image; $this->portsMappings = $this->database->ports_mappings; $this->isPublic = $this->database->is_public; diff --git a/app/Livewire/Project/Database/Mongodb/General.php b/app/Livewire/Project/Database/Mongodb/General.php index e1d029dfd6..02357a5581 100644 --- a/app/Livewire/Project/Database/Mongodb/General.php +++ b/app/Livewire/Project/Database/Mongodb/General.php @@ -146,9 +146,9 @@ class General extends Component } else { $this->name = $this->database->name; $this->description = $this->database->description; - $this->mongoConf = $this->database->mongo_conf; - $this->mongoInitdbRootUsername = $this->database->mongo_initdb_root_username; $canSeeCredentials = auth()->user()?->can('update', $this->database) ?? false; + $this->mongoConf = $canSeeCredentials ? $this->database->mongo_conf : null; + $this->mongoInitdbRootUsername = $this->database->mongo_initdb_root_username; $this->mongoInitdbRootPassword = $canSeeCredentials ? $this->database->mongo_initdb_root_password : ''; $this->mongoInitdbDatabase = $this->database->mongo_initdb_database; $this->image = $this->database->image; diff --git a/app/Livewire/Project/Database/Mysql/General.php b/app/Livewire/Project/Database/Mysql/General.php index 65c3a71e5f..fd3abfc218 100644 --- a/app/Livewire/Project/Database/Mysql/General.php +++ b/app/Livewire/Project/Database/Mysql/General.php @@ -159,7 +159,7 @@ class General extends Component $this->mysqlUser = $this->database->mysql_user; $this->mysqlPassword = $canSeeCredentials ? $this->database->mysql_password : ''; $this->mysqlDatabase = $this->database->mysql_database; - $this->mysqlConf = $this->database->mysql_conf; + $this->mysqlConf = $canSeeCredentials ? $this->database->mysql_conf : null; $this->image = $this->database->image; $this->portsMappings = $this->database->ports_mappings; $this->isPublic = $this->database->is_public; diff --git a/app/Livewire/Project/Database/Postgresql/General.php b/app/Livewire/Project/Database/Postgresql/General.php index 625a3fa8a3..30e03d7630 100644 --- a/app/Livewire/Project/Database/Postgresql/General.php +++ b/app/Livewire/Project/Database/Postgresql/General.php @@ -176,8 +176,8 @@ class General extends Component $this->postgresDb = $this->database->postgres_db; $this->postgresInitdbArgs = $this->database->postgres_initdb_args; $this->postgresHostAuthMethod = $this->database->postgres_host_auth_method; - $this->postgresConf = $this->database->postgres_conf; - $this->initScripts = $this->database->init_scripts; + $this->postgresConf = $canSeeCredentials ? $this->database->postgres_conf : null; + $this->initScripts = $canSeeCredentials ? $this->database->init_scripts : null; $this->image = $this->database->image; $this->portsMappings = $this->database->ports_mappings; $this->isPublic = $this->database->is_public; diff --git a/app/Livewire/Project/Database/Redis/General.php b/app/Livewire/Project/Database/Redis/General.php index afa8e3b571..5c96bef77a 100644 --- a/app/Livewire/Project/Database/Redis/General.php +++ b/app/Livewire/Project/Database/Redis/General.php @@ -42,12 +42,14 @@ class General extends Component public string $redisUsername; - public string $redisPassword; + public ?string $redisPassword = null; public string $redisVersion; public bool $isPasswordHiddenForMember = false; + public bool $isPasswordLocked = false; + protected $listeners = [ 'envsUpdated' => 'refresh', ]; @@ -68,9 +70,11 @@ class General extends Component 'redisUsername' => ValidationPatterns::databaseIdentifierRules( enforcePattern: $this->redisUsername !== $this->database->redis_username, ), - 'redisPassword' => ValidationPatterns::databasePasswordRules( - enforcePattern: $this->redisPassword !== $this->database->redis_password, - ), + 'redisPassword' => $this->database->isCredentialLocked() + ? ['nullable'] + : ValidationPatterns::databasePasswordRules( + enforcePattern: $this->redisPassword !== $this->database->redis_password, + ), ]; } @@ -143,7 +147,6 @@ class General extends Component } else { $this->name = $this->database->name; $this->description = $this->database->description; - $this->redisConf = $this->database->redis_conf; $this->image = $this->database->image; $this->portsMappings = $this->database->ports_mappings; $this->isPublic = $this->database->is_public; @@ -154,7 +157,9 @@ class General extends Component $this->redisVersion = $this->database->getRedisVersion(); $this->redisUsername = $this->database->redis_username; $canSeeCredentials = auth()->user()?->can('update', $this->database) ?? false; - $this->redisPassword = $canSeeCredentials ? $this->database->redis_password : ''; + $this->redisConf = $canSeeCredentials ? $this->database->redis_conf : null; + $this->isPasswordLocked = $this->database->isCredentialLocked(); + $this->redisPassword = $canSeeCredentials && ! $this->isPasswordLocked ? $this->database->redis_password : null; } } @@ -193,10 +198,13 @@ class General extends Component ['value' => $this->redisUsername, 'resourceable_id' => $this->database->id] ); } - $this->database->runtime_environment_variables()->updateOrCreate( - ['key' => 'REDIS_PASSWORD'], - ['value' => $this->redisPassword, 'resourceable_id' => $this->database->id] - ); + // The form never loads a locked password, so saving its empty value would overwrite it. + if (! $this->database->isCredentialLocked()) { + $this->database->runtime_environment_variables()->updateOrCreate( + ['key' => 'REDIS_PASSWORD'], + ['value' => $this->redisPassword, 'resourceable_id' => $this->database->id] + ); + } $this->dispatch('success', 'Database updated.'); $this->dispatch('databaseUpdated'); diff --git a/app/Livewire/Project/Service/EditCompose.php b/app/Livewire/Project/Service/EditCompose.php index b52d5a63dc..a8652c8509 100644 --- a/app/Livewire/Project/Service/EditCompose.php +++ b/app/Livewire/Project/Service/EditCompose.php @@ -54,12 +54,11 @@ class EditCompose extends Component { if ($toModel) { $this->service->docker_compose_raw = $this->dockerComposeRaw; - $this->service->docker_compose = $this->dockerCompose; $this->service->is_container_label_escape_enabled = $this->isContainerLabelEscapeEnabled; } else { $canViewCompose = auth()->user()?->can('update', $this->service) ?? false; $this->dockerComposeRaw = $canViewCompose ? $this->service->docker_compose_raw : null; - $this->dockerCompose = $canViewCompose ? $this->service->docker_compose : null; + $this->dockerCompose = $canViewCompose ? redactLockedValuesInGeneratedCompose($this->service->docker_compose, $this->service) : null; $this->isContainerLabelEscapeEnabled = $this->service->is_container_label_escape_enabled ?? false; } } diff --git a/app/Livewire/Project/Service/StackForm.php b/app/Livewire/Project/Service/StackForm.php index d8cb7dacf4..9247049d6d 100644 --- a/app/Livewire/Project/Service/StackForm.php +++ b/app/Livewire/Project/Service/StackForm.php @@ -81,7 +81,7 @@ class StackForm extends Component $this->service->name = $this->name; $this->service->description = $this->description; $this->service->docker_compose_raw = $this->dockerComposeRaw; - $this->service->docker_compose = $this->dockerCompose; + // docker_compose is generated by parse(); the property holds a redacted display copy. $this->service->connect_to_docker_network = $this->connectToDockerNetwork; } else { // Sync FROM model (on load/refresh) @@ -89,7 +89,7 @@ class StackForm extends Component $this->description = $this->service->description; $canViewCompose = auth()->user()?->can('update', $this->service) ?? false; $this->dockerComposeRaw = $canViewCompose ? $this->service->docker_compose_raw : null; - $this->dockerCompose = $canViewCompose ? $this->service->docker_compose : null; + $this->dockerCompose = $canViewCompose ? redactLockedValuesInGeneratedCompose($this->service->docker_compose, $this->service) : null; $this->connectToDockerNetwork = $this->service->connect_to_docker_network; } } @@ -144,11 +144,17 @@ class StackForm extends Component } /** - * Keys of the service environment variables that are locked (shown once). + * Keys of the extra fields that are locked (shown once), directly or through a referenced variable. */ private function lockedFieldKeys(): Collection { - return $this->service->environment_variables()->where('is_shown_once', true)->pluck('key'); + $referencingLockedKeys = collect($this->service->extraFields()) + ->flatMap(fn ($fields) => collect($fields)) + ->filter(fn ($field) => data_get($field, 'isLocked')) + ->pluck('key'); + + return $this->service->environment_variables()->where('is_shown_once', true)->pluck('key') + ->merge($referencingLockedKeys); } public function saveCompose($raw) diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php index 736075dc7c..22f98dbe95 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php @@ -69,6 +69,8 @@ class All extends Component private ?Collection $composeEnvironmentAssignments = null; + private ?bool $canViewComposeContent = null; + protected $listeners = [ 'saveKey' => 'submit', 'refreshEnvs', @@ -898,10 +900,24 @@ class All extends Component /** @return array{services: list, default: ?string, operator: ?string, required: bool}|null */ private function composeInfoFor(string $key): ?array { - return dockerComposeEnvironmentVariableInfo( + $composeInfo = dockerComposeEnvironmentVariableInfo( $this->resource->docker_compose_raw ?? $this->resource->docker_compose, $key ); + + if ($composeInfo !== null && ! $this->canViewComposeContent()) { + $composeInfo['default'] = null; + } + + return $composeInfo; + } + + /** + * Compose defaults are compose content, which only users who can update the resource may see. + */ + private function canViewComposeContent(): bool + { + return $this->canViewComposeContent ??= auth()->user()?->can('update', $this->resource) ?? false; } private function composeEnvironmentAssignments(): Collection diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php index 6dc835fa95..71ecb1631a 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php @@ -129,6 +129,9 @@ class Show extends Component public function mount() { + if ($this->composeInfo !== null && ! (auth()->user()?->can('update', $this->resource) ?? false)) { + $this->composeInfo['default'] = null; + } $this->syncData(); if ($this->env->getMorphClass() === SharedEnvironmentVariable::class) { $this->isSharedVariable = true; diff --git a/app/Livewire/Server/Transfer.php b/app/Livewire/Server/Transfer.php index fb0a0c2af3..f6f01cc975 100644 --- a/app/Livewire/Server/Transfer.php +++ b/app/Livewire/Server/Transfer.php @@ -102,7 +102,7 @@ class Transfer extends Component public function migrateServer(ServerTransferMigrator $migrator): void { try { - $this->authorize('update', $this->server); + $this->authorize('transfer', $this->server); if ($this->isLocalhost) { throw new \RuntimeException('The Coolify host (localhost) cannot be transferred.'); } diff --git a/app/Models/Service.php b/app/Models/Service.php index 2ebff2ad9c..bce0ac2fef 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -1426,7 +1426,10 @@ class Service extends BaseModel if (filled($field['value']) && str($field['value'])->startsWith('$SERVICE_')) { $searchValue = str($field['value'])->after('$')->value; $newValue = $this->environment_variables()->where('key', $searchValue)->first(); - if ($newValue) { + if ($newValue?->is_shown_once) { + $field['value'] = null; + $field['isLocked'] = true; + } elseif ($newValue) { $field['value'] = $newValue->value; } } diff --git a/app/Models/StandaloneDragonfly.php b/app/Models/StandaloneDragonfly.php index 23d7131244..5484f60bce 100644 --- a/app/Models/StandaloneDragonfly.php +++ b/app/Models/StandaloneDragonfly.php @@ -405,6 +405,15 @@ class StandaloneDragonfly extends BaseModel return $environmentVariable; } + /** + * Whether the password variable is locked (shown once) or reads a locked shared variable. + * The form, the connection URLs, and the API must then not show the password. + */ + public function isCredentialLocked(): bool + { + return (bool) $this->serverPasswordEnvironmentVariable()?->isLockedForLogs(); + } + /** * The server password for connection URLs. A remote secret is not fetched here, so the URL * shows its reference instead of a stale or wrong password. diff --git a/app/Models/StandaloneKeydb.php b/app/Models/StandaloneKeydb.php index d2aa105bd3..fa59e5a51e 100644 --- a/app/Models/StandaloneKeydb.php +++ b/app/Models/StandaloneKeydb.php @@ -406,6 +406,15 @@ class StandaloneKeydb extends BaseModel return $environmentVariable; } + /** + * Whether the password variable is locked (shown once) or reads a locked shared variable. + * The form, the connection URLs, and the API must then not show the password. + */ + public function isCredentialLocked(): bool + { + return (bool) $this->serverPasswordEnvironmentVariable()?->isLockedForLogs(); + } + /** * The server password for connection URLs. A remote secret is not fetched here, so the URL * shows its reference instead of a stale or wrong password. diff --git a/app/Models/StandaloneRedis.php b/app/Models/StandaloneRedis.php index d3c8004ae3..86b4702f49 100644 --- a/app/Models/StandaloneRedis.php +++ b/app/Models/StandaloneRedis.php @@ -414,6 +414,15 @@ class StandaloneRedis extends BaseModel return $environmentVariable; } + /** + * Whether the password variable is locked (shown once) or reads a locked shared variable. + * The form, the connection URLs, and the API must then not show the password. + */ + public function isCredentialLocked(): bool + { + return (bool) $this->runtimeEnvironmentVariable('REDIS_PASSWORD')?->isLockedForLogs(); + } + /** * The server password for connection URLs. A remote secret is not fetched here, so the URL * shows its reference instead of a stale or wrong password. diff --git a/app/Policies/ServerPolicy.php b/app/Policies/ServerPolicy.php index c1369ce67f..2d24355485 100644 --- a/app/Policies/ServerPolicy.php +++ b/app/Policies/ServerPolicy.php @@ -39,6 +39,16 @@ class ServerPolicy return $user->isAdminOfTeam($server->team_id); } + /** + * Determine whether the user can transfer the server to another Coolify instance. + * + * A transfer sends every secret of the server, also locked environment variables, to the target, so only owners may start it. + */ + public function transfer(User $user, Server $server): bool + { + return $user->roleInTeam($server->team_id) === 'owner'; + } + /** * Determine whether the user can delete the model. */ diff --git a/app/Services/DeploymentConfiguration/ConfigurationDiffer.php b/app/Services/DeploymentConfiguration/ConfigurationDiffer.php index 688db3f0a4..874d0424cb 100644 --- a/app/Services/DeploymentConfiguration/ConfigurationDiffer.php +++ b/app/Services/DeploymentConfiguration/ConfigurationDiffer.php @@ -50,8 +50,13 @@ class ConfigurationDiffer } $item = $current ?? $previous; - $sensitive = (bool) data_get($item, 'sensitive', false); $type = $previous === null ? 'added' : ($current === null ? 'removed' : 'changed'); + // A value is hidden when either snapshot hides it. A removed environment variable may + // have been locked after the previous deployment, which no snapshot records, so its + // old value is hidden too. + $sensitive = (bool) data_get($previous, 'sensitive', false) + || (bool) data_get($current, 'sensitive', false) + || ($type === 'removed' && data_get($item, 'section') === 'environment'); $displaySummary = $sensitive && $type === 'changed' ? 'Changed' : null; $diffMode = data_get($item, 'diff_mode', 'default'); diff --git a/app/Traits/HasDatabaseStatusInfo.php b/app/Traits/HasDatabaseStatusInfo.php index edbc01aaae..031e6767a6 100644 --- a/app/Traits/HasDatabaseStatusInfo.php +++ b/app/Traits/HasDatabaseStatusInfo.php @@ -82,7 +82,8 @@ trait HasDatabaseStatusInfo { $this->database->refresh(); $this->isPasswordHiddenForMember = ! (auth()->user()?->can('update', $this->database) ?? false); - if ($this->isPasswordHiddenForMember) { + $isCredentialLocked = method_exists($this->database, 'isCredentialLocked') && $this->database->isCredentialLocked(); + if ($this->isPasswordHiddenForMember || $isCredentialLocked) { $this->dbUrl = null; $this->dbUrlPublic = null; } else { diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 719ef26b88..f34a15aaf2 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -5650,6 +5650,80 @@ function resolveSharedEnvironmentVariables(?string $value, $resource): ?string return str($value)->value(); } +/** + * Replace locked values in the generated compose file of a resource, for display only. + * + * The compose parsers write resolved values into `docker_compose`: environment variables, database + * overrides, and shared variable references. Deployments need those values, so only the displayed + * copy is redacted. Covered: the resource's locked variables (also variables that reference a locked + * shared variable) and locked team, project, and environment variables that the raw compose references. + * Values shorter than 4 characters are not replaced, because they would match unrelated text. + * A parsed compose (array or collection) gets the same replacement in every string value. + * + * @template T of string|array|Collection|null + * + * @param T $compose + * @return T + */ +function redactLockedValuesInGeneratedCompose(string|array|Collection|null $compose, Application|Service $resource): string|array|Collection|null +{ + if (blank($compose)) { + return $compose; + } + + $values = EnvironmentVariable::logRedactionSecretsFor($resource, includePreview: true, resolveRemoteSecrets: false)['locked']; + + preg_match_all('/{{\s*(\w+)\.([^}\s]+)\s*}}/', (string) $resource->docker_compose_raw, $matches, PREG_SET_ORDER); + $references = collect($matches)->filter(fn (array $match): bool => in_array($match[1], ['team', 'project', 'environment'], true)); + $teamId = $resource->team()?->id; + if ($references->isNotEmpty() && $teamId !== null) { + $scopeIds = [ + 'team' => $teamId, + 'project' => $resource->environment?->project_id, + 'environment' => $resource->environment_id, + ]; + $values = SharedEnvironmentVariable::query() + ->where('team_id', $teamId) + ->where('is_shown_once', true) + ->whereIn('key', $references->pluck(2)->unique()->values()) + ->get() + ->filter(fn (SharedEnvironmentVariable $shared): bool => $references->contains(fn (array $match): bool => $match[1] === $shared->type && $match[2] === $shared->key) + && $scopeIds[$shared->type] !== null + && (int) $shared->{"{$shared->type}_id"} === (int) $scopeIds[$shared->type]) + ->pluck('value') + ->merge($values) + ->all(); + } + + $variants = collect($values) + ->filter(fn (mixed $value): bool => is_string($value)) + ->flatMap(fn (string $value): array => [ + $value, + escapeDollarSign($value), + str_replace("'", "''", $value), + substr((string) json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), 1, -1), + ]) + ->filter(fn (string $value): bool => mb_strlen($value) >= 4) + ->unique() + ->mapWithKeys(fn (string $value): array => [$value => '']); + + if ($variants->isEmpty()) { + return $compose; + } + + // strtr() replaces the longest match first and does not search the replaced text again. + $redact = function (mixed $value) use (&$redact, $variants): mixed { + return match (true) { + is_string($value) => strtr($value, $variants->all()), + is_array($value) => array_map($redact, $value), + $value instanceof Collection => $value->map($redact), + default => $value, + }; + }; + + return $redact($compose); +} + /** * Convert an ISO 3166-1 alpha-2 country code into its regional-indicator flag emoji. * diff --git a/resources/views/components/database-status-info.blade.php b/resources/views/components/database-status-info.blade.php index 1ff2473f78..ecd80fe4a6 100644 --- a/resources/views/components/database-status-info.blade.php +++ b/resources/views/components/database-status-info.blade.php @@ -15,6 +15,7 @@ ]) @php + $isCredentialLocked = ! $isPasswordHiddenForMember && method_exists($database, 'isCredentialLocked') && $database->isCredentialLocked(); $urlHelper = 'If you change the user/password/port, this could be different. This is with the default values.'; @endphp @@ -22,6 +23,9 @@ @if ($isPasswordHiddenForMember) + @elseif ($isCredentialLocked) + @else diff --git a/resources/views/livewire/project/database/keydb/general.blade.php b/resources/views/livewire/project/database/keydb/general.blade.php index dc37700634..672171d983 100644 --- a/resources/views/livewire/project/database/keydb/general.blade.php +++ b/resources/views/livewire/project/database/keydb/general.blade.php @@ -89,10 +89,14 @@ id="customDockerRunOptions" label="Custom Docker options" canGate="update" :canResource="$database" /> - + @if ($isPasswordHiddenForMember) + + @else + + @endif - + @if ($isPasswordHiddenForMember) + + @else + + @endif - + @if ($isPasswordHiddenForMember) + + @else + + @endif - + @if ($isPasswordHiddenForMember) + + @else + + @endif - + @if ($isPasswordHiddenForMember) + + @else + + @endif
- @forelse($initScripts ?? [] as $script) - - @empty - - @endforelse + @if ($isPasswordHiddenForMember) + + @else + @forelse($initScripts ?? [] as $script) + + @empty + + @endforelse + @endif
diff --git a/resources/views/livewire/project/database/redis/general.blade.php b/resources/views/livewire/project/database/redis/general.blade.php index f200018a00..c199e525ad 100644 --- a/resources/views/livewire/project/database/redis/general.blade.php +++ b/resources/views/livewire/project/database/redis/general.blade.php @@ -35,6 +35,9 @@ @endif @if ($isPasswordHiddenForMember) + @elseif ($isPasswordLocked) + @else - + @else + + helper="Coolify automatically applies requirepass using the password above. If you override requirepass here, keep both values identical." + label="Custom Redis configuration" rows="10" id="redisConf" canGate="update" + :canResource="$database" /> + @endif - Transfer server Running checks… diff --git a/tests/Feature/ActivityMonitorCrossTeamTest.php b/tests/Feature/ActivityMonitorCrossTeamTest.php index 9c90953091..c68e3518dd 100644 --- a/tests/Feature/ActivityMonitorCrossTeamTest.php +++ b/tests/Feature/ActivityMonitorCrossTeamTest.php @@ -1,7 +1,9 @@ assertSet('activity', null); }); -test('hydrateActivity allows access to own teams activity via team_id', function () { +test('hydrateActivity blocks an activity of the own team that has no resource or server', function () { $ownActivity = Activity::create([ 'log_name' => 'default', 'description' => 'test activity', @@ -44,11 +46,9 @@ test('hydrateActivity allows access to own teams activity via team_id', function $this->actingAs($this->user); session(['currentTeam' => ['id' => $this->team->id]]); - $component = Livewire::test(ActivityMonitor::class) - ->call('newMonitorActivity', $ownActivity->id); - - expect($component->get('activity'))->not->toBeNull(); - expect($component->get('activity')->id)->toBe($ownActivity->id); + Livewire::test(ActivityMonitor::class) + ->call('newMonitorActivity', $ownActivity->id) + ->assertSet('activity', null); }); test('hydrateActivity blocks access to activity without team_id or server_uuid', function () { @@ -132,10 +132,11 @@ test('activityId property is locked and cannot be set from client', function () })->throws(CannotUpdateLockedPropertyException::class); test('closing a process dialog clears the previous activity logs', function () { + $server = Server::factory()->create(['team_id' => $this->team->id]); $activity = Activity::create([ 'log_name' => 'default', 'description' => 'proxy restart activity', - 'properties' => ['team_id' => $this->team->id], + 'properties' => ['team_id' => $this->team->id, 'server_uuid' => $server->uuid], ]); $this->actingAs($this->user); @@ -150,3 +151,79 @@ test('closing a process dialog clears the previous activity logs', function () { ->assertSet('activity', null) ->assertSet('isPollingActive', false); }); + +describe('activity output of a resource', function () { + beforeEach(function () { + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = $this->server->standaloneDockers()->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $this->service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'environment_id' => $project->environments()->firstOrFail()->id, + ]); + $this->serviceActivity = Activity::create([ + 'log_name' => 'default', + 'description' => 'service start', + 'properties' => [ + 'team_id' => $this->team->id, + 'server_uuid' => $this->server->uuid, + 'type_uuid' => $this->service->uuid, + ], + ]); + $this->serverActivity = Activity::create([ + 'log_name' => 'default', + 'description' => 'proxy start', + 'properties' => ['team_id' => $this->team->id, 'server_uuid' => $this->server->uuid], + ]); + }); + + test('a team member cannot read it', function (string $activity) { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + $this->actingAs($member); + session(['currentTeam' => ['id' => $this->team->id]]); + + Livewire::test(ActivityMonitor::class) + ->call('newMonitorActivity', $this->{$activity}->id) + ->assertSet('activity', null); + })->with(['serviceActivity', 'serverActivity']); + + test('a team admin can read it', function (string $activity) { + $admin = User::factory()->create(); + $this->team->members()->attach($admin->id, ['role' => 'admin']); + $this->actingAs($admin); + session(['currentTeam' => ['id' => $this->team->id]]); + + $component = Livewire::test(ActivityMonitor::class) + ->call('newMonitorActivity', $this->{$activity}->id); + + expect($component->get('activity')?->id)->toBe($this->{$activity}->id); + })->with(['serviceActivity', 'serverActivity']); + + test('a member who owns another team cannot read it after switching to that team', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + $this->otherTeam->members()->attach($member->id, ['role' => 'owner']); + $this->actingAs($member); + session(['currentTeam' => ['id' => $this->otherTeam->id]]); + + Livewire::test(ActivityMonitor::class) + ->call('newMonitorActivity', $this->serviceActivity->id) + ->assertSet('activity', null); + }); + + test('an admin of the resource team can read it while the session is on another team', function () { + $admin = User::factory()->create(); + $this->team->members()->attach($admin->id, ['role' => 'admin']); + $this->otherTeam->members()->attach($admin->id, ['role' => 'owner']); + $this->actingAs($admin); + session(['currentTeam' => ['id' => $this->otherTeam->id]]); + + $component = Livewire::test(ActivityMonitor::class) + ->call('newMonitorActivity', $this->serviceActivity->id); + + expect($component->get('activity')?->id)->toBe($this->serviceActivity->id); + }); +}); diff --git a/tests/Feature/Api/ServerTransferApiTest.php b/tests/Feature/Api/ServerTransferApiTest.php index 8debea9663..a6705d7eca 100644 --- a/tests/Feature/Api/ServerTransferApiTest.php +++ b/tests/Feature/Api/ServerTransferApiTest.php @@ -506,6 +506,24 @@ describe('POST /api/v1/servers/{uuid}/migrate', function () { ->assertJsonPath('migration.target_url', 'https://8.8.8.8'); }); + test('rejects a team admin, because a transfer sends every secret to the target', function () { + Queue::fake(); + Http::fake(['*/api/v1/servers/import/check*' => Http::response(['message' => 'ok', 'checks' => []])]); + $admin = User::factory()->create(); + $this->team->members()->attach($admin->id, ['role' => 'admin']); + $adminToken = $admin->createToken('admin-sensitive', ['*', 'read:sensitive'], teamId: $this->team->id)->plainTextToken; + + $this->withHeaders(transferHeaders($adminToken)) + ->postJson("/api/v1/servers/{$this->server->uuid}/migrate", [ + 'target_url' => 'https://8.8.8.8', + 'target_token' => 'target-token', + ]) + ->assertForbidden(); + + Queue::assertNothingPushed(); + expect(data_get($this->server->fresh()->server_metadata, 'migration'))->toBeNull(); + }); + test('rejects a target without https', function () { Queue::fake(); diff --git a/tests/Feature/Authorization/DatabaseConfigAndLockedPasswordVisibilityTest.php b/tests/Feature/Authorization/DatabaseConfigAndLockedPasswordVisibilityTest.php new file mode 100644 index 0000000000..23ef9f4257 --- /dev/null +++ b/tests/Feature/Authorization/DatabaseConfigAndLockedPasswordVisibilityTest.php @@ -0,0 +1,241 @@ + 0]); + + $this->team = Team::factory()->create(); + $this->owner = User::factory()->create(); + $this->member = User::factory()->create(); + $this->team->members()->attach($this->owner->id, ['role' => 'owner']); + $this->team->members()->attach($this->member->id, ['role' => 'member']); + + $server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::where('server_id', $server->id)->first(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $project->id]); +}); + +dataset('database-config-components', [ + 'postgresql' => ['create_standalone_postgresql', PostgresqlGeneral::class, [ + 'postgres_conf' => 'password_encryption = secret-config-value', + 'init_scripts' => [['index' => 0, 'filename' => 'init.sql', 'content' => "CREATE USER app PASSWORD 'secret-config-value';"]], + ], ['postgresConf', 'initScripts']], + 'mysql' => ['create_standalone_mysql', MysqlGeneral::class, ['mysql_conf' => 'password=secret-config-value'], ['mysqlConf']], + 'mariadb' => ['create_standalone_mariadb', MariadbGeneral::class, ['mariadb_conf' => 'password=secret-config-value'], ['mariadbConf']], + 'mongodb' => ['create_standalone_mongodb', MongodbGeneral::class, ['mongo_conf' => 'keyFile: secret-config-value'], ['mongoConf']], + 'redis' => ['create_standalone_redis', RedisGeneral::class, ['redis_conf' => 'requirepass secret-config-value'], ['redisConf']], + 'keydb' => ['create_standalone_keydb', KeydbGeneral::class, ['keydb_conf' => 'requirepass secret-config-value'], ['keydbConf']], +]); + +function createDatabaseWithConfig(string $createDatabase, array $config): mixed +{ + $database = $createDatabase(test()->environment->id, test()->destination); + $database->forceFill($config)->save(); + + return $database->fresh(); +} + +function lockRedisPassword(StandaloneRedis $redis, ?string $value = null): void +{ + $redis->runtime_environment_variables()->where('key', 'REDIS_PASSWORD')->firstOrFail() + ->forceFill(array_filter(['is_shown_once' => true, 'value' => $value], fn ($attribute) => $attribute !== null)) + ->save(); +} + +it('keeps custom configs and init scripts out of member state and html', function (string $createDatabase, string $generalClass, array $config, array $properties) { + $database = createDatabaseWithConfig($createDatabase, $config); + + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + $general = Livewire::test($generalClass, ['database' => $database]); + if (method_exists($generalClass, 'refresh')) { + $general->call('refresh'); + } + foreach ($properties as $property) { + $general->assertSet($property, null); + } + expect(json_encode($general->snapshot))->not->toContain('secret-config-value') + ->and($general->html())->not->toContain('secret-config-value') + ->and($general->html())->toContain('Hidden (only admins can view)'); +})->with('database-config-components'); + +it('keeps custom configs out of a member who switched the session to a team they own', function (string $createDatabase, string $generalClass, array $config, array $properties) { + $database = createDatabaseWithConfig($createDatabase, $config); + $ownTeam = Team::factory()->create(); + $ownTeam->members()->attach($this->member->id, ['role' => 'owner']); + + $this->actingAs($this->member); + session(['currentTeam' => $ownTeam]); + + $general = Livewire::test($generalClass, ['database' => $database]); + foreach ($properties as $property) { + $general->assertSet($property, null); + } + expect(json_encode($general->snapshot))->not->toContain('secret-config-value') + ->and($general->html())->not->toContain('secret-config-value'); +})->with('database-config-components'); + +it('keeps custom configs and init scripts available to an owner', function (string $createDatabase, string $generalClass, array $config, array $properties) { + $database = createDatabaseWithConfig($createDatabase, $config); + + $this->actingAs($this->owner); + session(['currentTeam' => $this->team]); + + $general = Livewire::test($generalClass, ['database' => $database]); + foreach (array_values($config) as $index => $value) { + $general->assertSet($properties[$index], $value); + } +})->with('database-config-components'); + +it('does not let a member save custom configs', function () { + $database = createDatabaseWithConfig('create_standalone_redis', ['redis_conf' => 'requirepass secret-config-value']); + + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(RedisGeneral::class, ['database' => $database]) + ->set('redisConf', 'requirepass changed') + ->call('submit'); + + expect($database->fresh()->redis_conf)->toBe('requirepass secret-config-value'); +}); + +it('does not show a locked redis password to an owner in the form or the connection urls', function () { + $redis = create_standalone_redis($this->environment->id, $this->destination, ['redis_password' => 'locked-redis-secret']); + lockRedisPassword($redis); + + $this->actingAs($this->owner); + session(['currentTeam' => $this->team]); + + $general = Livewire::test(RedisGeneral::class, ['database' => $redis->fresh()]) + ->assertSet('redisPassword', null) + ->assertSet('isPasswordLocked', true); + expect(json_encode($general->snapshot))->not->toContain('locked-redis-secret') + ->and($general->html())->not->toContain('locked-redis-secret') + ->and($general->html())->toContain('Locked (shown once)'); + + $statusInfo = Livewire::test(RedisStatusInfo::class, ['database' => $redis->fresh()]) + ->assertSet('dbUrl', null) + ->assertSet('dbUrlPublic', null); + expect(json_encode($statusInfo->snapshot))->not->toContain('locked-redis-secret') + ->and($statusInfo->html())->not->toContain('locked-redis-secret') + ->and($statusInfo->html())->toContain('Locked (shown once)'); +}); + +it('does not show a locked shared variable that the redis password reads in the connection urls', function () { + $redis = create_standalone_redis($this->environment->id, $this->destination); + SharedEnvironmentVariable::create([ + 'key' => 'REDIS_SECRET', + 'value' => 'locked-shared-secret', + 'type' => 'team', + 'team_id' => $this->team->id, + 'is_shown_once' => true, + ]); + $redis->runtime_environment_variables()->where('key', 'REDIS_PASSWORD')->firstOrFail() + ->forceFill(['value' => '{{team.REDIS_SECRET}}'])->save(); + + $this->actingAs($this->owner); + session(['currentTeam' => $this->team]); + + $statusInfo = Livewire::test(RedisStatusInfo::class, ['database' => $redis->fresh()]) + ->assertSet('dbUrl', null); + expect(json_encode($statusInfo->snapshot))->not->toContain('locked-shared-secret'); +}); + +it('does not show a locked REDIS_PASSWORD override in the keydb and dragonfly connection urls', function (string $createDatabase, string $statusInfoClass) { + $database = $createDatabase($this->environment->id, $this->destination); + $database->forceFill(['legacy_password_quoting' => false])->save(); + EnvironmentVariable::create([ + 'key' => 'REDIS_PASSWORD', + 'value' => 'locked-override-secret', + 'is_shown_once' => true, + 'resourceable_type' => $database::class, + 'resourceable_id' => $database->id, + ]); + + $this->actingAs($this->owner); + session(['currentTeam' => $this->team]); + + $statusInfo = Livewire::test($statusInfoClass, ['database' => $database->fresh()]) + ->assertSet('dbUrl', null); + expect(json_encode($statusInfo->snapshot))->not->toContain('locked-override-secret') + ->and($statusInfo->html())->toContain('Locked (shown once)'); +})->with([ + 'keydb' => ['create_standalone_keydb', KeydbStatusInfo::class], + 'dragonfly' => ['create_standalone_dragonfly', DragonflyStatusInfo::class], +]); + +it('keeps a locked redis password when an owner saves the form', function () { + $redis = create_standalone_redis($this->environment->id, $this->destination, ['redis_password' => 'locked-redis-secret']); + lockRedisPassword($redis); + + $this->actingAs($this->owner); + session(['currentTeam' => $this->team]); + + Livewire::test(RedisGeneral::class, ['database' => $redis->fresh()]) + ->set('name', 'renamed-redis') + ->call('submit') + ->assertHasNoErrors(); + + $redis = $redis->fresh(); + expect($redis->name)->toBe('renamed-redis') + ->and(EnvironmentVariable::query()->where('resourceable_type', StandaloneRedis::class) + ->where('resourceable_id', $redis->id)->where('key', 'REDIS_PASSWORD')->firstOrFail()->value) + ->toBe('locked-redis-secret'); +}); + +it('redacts a locked redis password and its connection urls in the api', function () { + $redis = create_standalone_redis($this->environment->id, $this->destination, ['redis_password' => 'locked-redis-secret']); + lockRedisPassword($redis); + + session(['currentTeam' => $this->team]); + $token = $this->owner->createToken('locked-test', ['read', 'read:sensitive']); + DB::table('personal_access_tokens')->where('id', $token->accessToken->id)->update(['team_id' => $this->team->id]); + $headers = ['Authorization' => 'Bearer '.$token->plainTextToken]; + + $single = $this->withHeaders($headers)->getJson("/api/v1/databases/{$redis->uuid}")->assertOk(); + expect($single->getContent())->not->toContain('locked-redis-secret') + ->and($single->json('internal_db_url'))->toBeNull(); + + $list = $this->withHeaders($headers)->getJson('/api/v1/databases')->assertOk(); + expect($list->getContent())->not->toContain('locked-redis-secret'); +}); + +it('still returns the connection url of an unlocked redis password in the api', function () { + $redis = create_standalone_redis($this->environment->id, $this->destination, ['redis_password' => 'visible-redis-secret']); + + session(['currentTeam' => $this->team]); + $token = $this->owner->createToken('unlocked-test', ['read', 'read:sensitive']); + DB::table('personal_access_tokens')->where('id', $token->accessToken->id)->update(['team_id' => $this->team->id]); + + $this->withHeaders(['Authorization' => 'Bearer '.$token->plainTextToken]) + ->getJson("/api/v1/databases/{$redis->uuid}") + ->assertOk() + ->assertJsonPath('internal_db_url', "redis://default:visible-redis-secret@{$redis->uuid}:6379/0"); +}); diff --git a/tests/Feature/Authorization/EnvironmentVariableComposeDefaultVisibilityTest.php b/tests/Feature/Authorization/EnvironmentVariableComposeDefaultVisibilityTest.php new file mode 100644 index 0000000000..ba59c81fdd --- /dev/null +++ b/tests/Feature/Authorization/EnvironmentVariableComposeDefaultVisibilityTest.php @@ -0,0 +1,113 @@ +withoutVite(); + InstanceSettings::forceCreate(['id' => 0]); + + $this->team = Team::factory()->create(); + $this->owner = User::factory()->create(); + $this->member = User::factory()->create(); + $this->team->members()->attach($this->owner, ['role' => 'owner']); + $this->team->members()->attach($this->member, ['role' => 'member']); + + $server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = $server->standaloneDockers()->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + + $this->application = Application::factory()->create([ + 'build_pack' => 'dockercompose', + 'docker_compose_raw' => "services:\n app:\n image: nginx:alpine\n environment:\n DB_PASSWORD: \${DB_PASSWORD:-hunter2}\n", + 'environment_id' => $project->environments()->firstOrFail()->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ])->fresh(); + + $this->environmentVariable = EnvironmentVariable::create([ + 'key' => 'DB_PASSWORD', + 'value' => 'stored-value', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); +}); + +function mountComposeDefaultComponents(): array +{ + $all = Livewire::test(EnvironmentVariables::class, ['resource' => test()->application]) + ->call('loadEnvironmentVariables'); + + $show = Livewire::test(Show::class, [ + 'env' => test()->environmentVariable, + 'type' => 'application', + 'composeInfo' => dockerComposeEnvironmentVariableInfo(test()->application->docker_compose_raw, 'DB_PASSWORD'), + ]); + + return [$all, $show]; +} + +it('hides compose defaults from a member', function (bool $sessionOnOtherTeam) { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + if ($sessionOnOtherTeam) { + $otherTeam = Team::factory()->create(); + $otherTeam->members()->attach($this->member, ['role' => 'owner']); + session(['currentTeam' => $otherTeam]); + } + + [$all, $show] = mountComposeDefaultComponents(); + + expect($all->instance()->environmentVariablePageRows->first()['composeInfo'])->toBe([ + 'services' => ['app'], + 'default' => null, + 'operator' => ':-', + 'required' => false, + ]) + ->and($show->get('composeInfo')['default'])->toBeNull() + ->and($show->get('composeInfo')['services'])->toBe(['app']); + + foreach ([$all, $show] as $component) { + expect(json_encode($component->snapshot).$component->html())->not->toContain('hunter2'); + } +})->with([ + 'session on resource team' => false, + 'session switched to another team' => true, +]); + +it('shows compose defaults to an owner', function () { + $this->actingAs($this->owner); + session(['currentTeam' => $this->team]); + + [$all, $show] = mountComposeDefaultComponents(); + + expect($all->instance()->environmentVariablePageRows->first()['composeInfo']['default'])->toBe('hunter2') + ->and($all->html())->toContain('hunter2'); + $show->assertSet('composeInfo.default', 'hunter2') + ->assertSee('Compose default'); +}); + +it('does not let a member apply the compose default', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(Show::class, [ + 'env' => $this->environmentVariable, + 'type' => 'application', + 'composeInfo' => dockerComposeEnvironmentVariableInfo($this->application->docker_compose_raw, 'DB_PASSWORD'), + ])->call('useComposeDefault')->assertForbidden(); + + expect($this->environmentVariable->fresh()->value)->toBe('stored-value'); +}); diff --git a/tests/Feature/Authorization/RenderedComposeLockedValueRedactionTest.php b/tests/Feature/Authorization/RenderedComposeLockedValueRedactionTest.php new file mode 100644 index 0000000000..a4fb6566cc --- /dev/null +++ b/tests/Feature/Authorization/RenderedComposeLockedValueRedactionTest.php @@ -0,0 +1,166 @@ +set('app.maintenance.store', 'array'); + InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]); + + $this->team = Team::factory()->create(); + $this->owner = User::factory()->create(); + $this->team->members()->attach($this->owner, ['role' => 'owner']); + + $server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + + SharedEnvironmentVariable::create([ + 'type' => 'team', + 'key' => 'TEAM_SECRET', + 'value' => 'team-shared-locked-secret', + 'team_id' => $this->team->id, + 'is_shown_once' => true, + ]); + + $this->service = Service::factory()->create([ + 'environment_id' => $this->environment->id, + 'server_id' => $server->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'docker_compose_raw' => LOCKED_COMPOSE_RAW, + ]); + $this->application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'build_pack' => 'dockercompose', + 'docker_compose_raw' => LOCKED_COMPOSE_RAW, + ]); + + foreach ([$this->service, $this->application] as $resource) { + $resource->environment_variables()->create(['key' => 'LOCKED_TOKEN', 'value' => 'env-locked-secret-value', 'is_shown_once' => true]); + $resource->environment_variables()->create(['key' => 'PLAIN_TOKEN', 'value' => 'plain-visible-value']); + $resource->parse(); + $resource->refresh(); + } +}); + +function lockedComposeApiToken(): string +{ + $plainTextToken = Str::random(40); + $token = test()->owner->tokens()->create([ + 'name' => 'test-token', + 'token' => hash('sha256', $plainTextToken), + 'abilities' => ['read', 'read:sensitive'], + 'team_id' => test()->team->id, + ]); + + return $token->getKey().'|'.$plainTextToken; +} + +function expectLockedValuesRedacted(?string $text): void +{ + expect($text)->not->toContain('env-locked-secret-value') + ->not->toContain('team-shared-locked-secret') + ->toContain('') + ->toContain('plain-visible-value'); +} + +function expectStoredComposeKeepsRealValues(Application|Service $resource): void +{ + expect($resource->fresh()->docker_compose) + ->not->toContain('') + ->toContain('env-locked-secret-value') + ->toContain('team-shared-locked-secret'); +} + +it('writes the locked values into the generated compose that deployments use', function (string $resource) { + expectStoredComposeKeepsRealValues($this->{$resource}); +})->with(['service', 'application']); + +it('hides locked values in the service compose that an owner sees', function (string $component) { + $this->actingAs($this->owner); + session(['currentTeam' => $this->team]); + + $livewire = Livewire::test($component, ['service' => $this->service, 'serviceId' => $this->service->id]); + + expectLockedValuesRedacted($livewire->get('dockerCompose')); + expect(json_encode($livewire->snapshot).$livewire->html()) + ->not->toContain('env-locked-secret-value') + ->not->toContain('team-shared-locked-secret'); +})->with(['stack form' => [StackForm::class], 'compose editor' => [EditCompose::class]]); + +it('hides locked values in the application compose that an owner sees', function () { + $this->actingAs($this->owner); + session(['currentTeam' => $this->team]); + + $livewire = Livewire::test(General::class, ['application' => $this->application]); + + expectLockedValuesRedacted($livewire->get('dockerCompose')); + expect(json_encode($livewire->snapshot).$livewire->html()) + ->not->toContain('env-locked-secret-value') + ->not->toContain('team-shared-locked-secret'); +}); + +it('keeps the real generated compose when an owner saves the service stack form', function () { + $this->actingAs($this->owner); + session(['currentTeam' => $this->team]); + + Livewire::test(StackForm::class, ['service' => $this->service]) + ->call('submit') + ->assertHasNoErrors(); + + expectStoredComposeKeepsRealValues($this->service); +}); + +it('keeps the real generated compose when an owner saves the application form', function () { + $this->actingAs($this->owner); + session(['currentTeam' => $this->team]); + + Livewire::test(General::class, ['application' => $this->application]) + ->call('submit') + ->assertHasNoErrors(); + + expectStoredComposeKeepsRealValues($this->application); +}); + +it('hides locked values in the generated compose returned by the API', function (string $resource) { + $path = $resource === 'service' + ? '/api/v1/services/'.$this->service->uuid + : '/api/v1/applications/'.$this->application->uuid; + + $response = $this->withToken(lockedComposeApiToken())->getJson($path)->assertOk(); + + expectLockedValuesRedacted($response->json('docker_compose')); + expect($response->json('docker_compose_raw'))->toBe(LOCKED_COMPOSE_RAW); +})->with(['service', 'application']); diff --git a/tests/Feature/Authorization/StackFormPasswordVisibilityTest.php b/tests/Feature/Authorization/StackFormPasswordVisibilityTest.php index 8eafed0f81..df147b5cb5 100644 --- a/tests/Feature/Authorization/StackFormPasswordVisibilityTest.php +++ b/tests/Feature/Authorization/StackFormPasswordVisibilityTest.php @@ -87,3 +87,25 @@ it('hides locked extra-field passwords from an owner and keeps them on save', fu expect($this->masterPassword->fresh()->value)->toBe('drizzle-master-secret'); }); + +it('hides an extra field that references a locked variable and keeps both on save', function () { + $this->masterPassword->update(['value' => '$SERVICE_PASSWORD_64_DRIZZLE']); + $lockedTarget = $this->service->environment_variables()->create([ + 'key' => 'SERVICE_PASSWORD_64_DRIZZLE', + 'value' => 'referenced-locked-secret', + 'is_shown_once' => true, + ]); + $this->actingAs($this->owner); + session(['currentTeam' => $this->team]); + + $component = mountStackForm(); + expect($component->get('fields')['SERVICE_PASSWORD_DRIZZLE']['value'])->toBeNull() + ->and(json_encode($component->snapshot).$component->html())->not->toContain('referenced-locked-secret'); + + $component->set('fields.SERVICE_PASSWORD_DRIZZLE.isLocked', false) + ->set('fields.SERVICE_PASSWORD_DRIZZLE.value', 'attacker-value') + ->call('submit'); + + expect($this->masterPassword->fresh()->value)->toBe('$SERVICE_PASSWORD_64_DRIZZLE') + ->and($lockedTarget->fresh()->value)->toBe('referenced-locked-secret'); +}); diff --git a/tests/Feature/Livewire/ServerTransferUiTest.php b/tests/Feature/Livewire/ServerTransferUiTest.php index 67516af896..0eac93b141 100644 --- a/tests/Feature/Livewire/ServerTransferUiTest.php +++ b/tests/Feature/Livewire/ServerTransferUiTest.php @@ -144,6 +144,28 @@ test('transfer to another instance runs in a queued job', function () { expect(data_get($this->server->fresh()->server_metadata, 'migration.status'))->toBe('running'); }); +test('a team admin cannot start a transfer, because it sends every secret to the target', function () { + Queue::fake(); + Http::fake(['*/api/v1/servers/import/check*' => Http::response(['message' => 'ok', 'checks' => []])]); + $admin = User::factory()->create(); + $this->team->members()->attach($admin->id, ['role' => 'admin']); + $this->actingAs($admin); + + Livewire::test(Transfer::class, ['server_uuid' => $this->server->uuid]) + ->set('targetUrl', 'https://8.8.8.8') + ->set('targetToken', 'target-token') + ->call('migrateServer') + ->assertSet('targetToken', ''); + + Queue::assertNotPushed(MigrateServerToInstanceJob::class); + expect(data_get($this->server->fresh()->server_metadata, 'migration'))->toBeNull(); +}); + +test('the transfer confirmation says that locked secrets are sent to the target', function () { + Livewire::test(Transfer::class, ['server_uuid' => $this->server->uuid]) + ->assertSee('every environment variable value, also locked ones'); +}); + test('transfer page shows the error of a failed transfer', function () { $this->server->forceFill(['server_metadata' => ['migration' => [ 'status' => 'failed', diff --git a/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php b/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php index ed5e43e592..caa48c3f9a 100644 --- a/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php +++ b/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php @@ -407,6 +407,33 @@ it('describes added unlocked environment variables with their value', function ( ->and(json_encode($diff->toArray()))->toContain('new-secret'); }); +it('hides the old value of an environment variable that was locked after the deployment and then deleted', function () { + $application = snapshotTestApplication(); + $variable = EnvironmentVariable::create([ + 'key' => 'API_TOKEN', + 'value' => 'deployed-secret', + 'is_buildtime' => false, + 'is_runtime' => true, + 'is_preview' => false, + 'is_shown_once' => false, + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + ]); + markSnapshotTestApplicationDeployed($application->refresh()); + + $variable->update(['is_shown_once' => true]); + $variable->delete(); + $diff = $application->refresh()->pendingDeploymentConfigurationDiff(); + $change = collect($diff->changes())->firstWhere('label', 'API_TOKEN'); + + expect($change)->not->toBeNull() + ->and($change['type'])->toBe('removed') + ->and($change['sensitive'])->toBeTrue() + ->and($change['old_display_value'])->toBe('••••••••') + ->and($change['new_display_value'])->toBe('-') + ->and(json_encode($diff->toArray()))->not->toContain('deployed-secret'); +}); + it('does not report Docker Compose preview volumes as pending production changes', function () { $application = snapshotTestApplication(['build_pack' => 'dockercompose']); $previewVolume = fn (): LocalPersistentVolume => LocalPersistentVolume::create([