diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index e4fff22459..ab063ca8d3 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -33,6 +33,15 @@ class ApplicationsController extends Controller { + private function exposeFileStorageContentIfAllowed(LocalFileVolume|LocalPersistentVolume $storage): LocalFileVolume|LocalPersistentVolume + { + if (request()->attributes->get('can_read_sensitive', false) === true) { + $storage->makeVisible(['content']); + } + + return $storage; + } + private function removeSensitiveData($application) { $application->makeHidden([ @@ -41,8 +50,8 @@ private function removeSensitiveData($application) 'resourceable_id', 'resourceable_type', ]); - if (request()->attributes->get('can_read_sensitive', false) === false) { - $application->makeHidden([ + if (request()->attributes->get('can_read_sensitive', false) === true) { + $application->makeVisible([ 'custom_labels', 'dockerfile', 'docker_compose', @@ -51,10 +60,14 @@ private function removeSensitiveData($application) 'manual_webhook_secret_gitea', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', - 'private_key_id', + 'http_basic_auth_password', 'value', 'real_value', - 'http_basic_auth_password', + ]); + $this->exposeNestedServerSecrets($application); + } else { + $application->makeHidden([ + 'private_key_id', ]); } @@ -65,6 +78,34 @@ private function removeSensitiveData($application) return serializeApiResponse($application); } + /** + * Expose sensitive fields on eager-loaded nested Server + ServerSetting + * relations for callers with the `read:sensitive` or `root` token ability. + * Models hide these by default via $hidden; this re-exposes them per-request. + */ + private function exposeNestedServerSecrets($model): void + { + $server = $model->destination?->server ?? null; + if (! $server) { + return; + } + $server->makeVisible([ + 'logdrain_axiom_api_key', + 'logdrain_newrelic_license_key', + ]); + $settings = $server->settings ?? null; + if ($settings) { + $settings->makeVisible([ + 'sentinel_token', + 'sentinel_custom_url', + 'logdrain_newrelic_license_key', + 'logdrain_axiom_api_key', + 'logdrain_custom_config', + 'logdrain_custom_config_parser', + ]); + } + } + #[OA\Get( summary: 'List', description: 'List all applications.', @@ -117,8 +158,12 @@ public function applications(Request $request) } $tagName = $request->query('tag'); + $applicationRelations = $request->attributes->get('can_read_sensitive', false) === true + ? ['destination.server.settings'] + : []; $applications = Application::ownedByCurrentTeamAPI($teamId) + ->with($applicationRelations) ->when($tagName, function ($query, $tagName) { $query->whereHas('tags', function ($query) use ($tagName) { $query->where('name', $tagName); @@ -2003,7 +2048,7 @@ public function application_by_uuid(Request $request) if (! $uuid) { return response()->json(['message' => 'UUID is required.'], 400); } - $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first(); if (! $application) { return response()->json(['message' => 'Application not found.'], 404); } @@ -2091,7 +2136,7 @@ public function logs_by_uuid(Request $request) if (! $uuid) { return response()->json(['message' => 'UUID is required.'], 400); } - $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first(); if (! $application) { return response()->json(['message' => 'Application not found.'], 404); } @@ -2185,7 +2230,7 @@ public function delete_by_uuid(Request $request) if (! $request->uuid) { return response()->json(['message' => 'UUID is required.'], 404); } - $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first(); if (! $application) { return response()->json([ @@ -2398,7 +2443,7 @@ public function update_by_uuid(Request $request) return $return; } - $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first(); if (! $application) { return response()->json([ 'message' => 'Application not found', @@ -3998,6 +4043,7 @@ public function storages(Request $request): JsonResponse $persistentStorages = $application->persistentStorages->sortBy('id')->values(); $fileStorages = $application->fileStorages->sortBy('id')->values(); + $fileStorages->each(fn (LocalFileVolume $storage) => $this->exposeFileStorageContentIfAllowed($storage)); return response()->json([ 'persistent_storages' => $persistentStorages, @@ -4212,7 +4258,7 @@ public function update_storage(Request $request): JsonResponse 'mount_path' => $storage->mount_path ?? null, ]); - return response()->json($storage); + return response()->json($this->exposeFileStorageContentIfAllowed($storage)); } #[OA\Post( @@ -4446,7 +4492,7 @@ public function create_storage(Request $request): JsonResponse 'mount_path' => $storage->mount_path, ]); - return response()->json($storage, 201); + return response()->json($this->exposeFileStorageContentIfAllowed($storage), 201); } #[OA\Delete( diff --git a/app/Http/Controllers/Api/CloudProviderTokensController.php b/app/Http/Controllers/Api/CloudProviderTokensController.php index ad6eeb9822..a6f196f137 100644 --- a/app/Http/Controllers/Api/CloudProviderTokensController.php +++ b/app/Http/Controllers/Api/CloudProviderTokensController.php @@ -16,9 +16,14 @@ private function removeSensitiveData($token) { $token->makeHidden([ 'id', - 'token', ]); + if (request()->attributes->get('can_read_sensitive', false) === true) { + $token->makeVisible([ + 'token', + ]); + } + return serializeApiResponse($token); } diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index a9f12006fb..d6622f29e6 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -20,6 +20,7 @@ use App\Models\Server; use App\Models\StandalonePostgresql; use App\Support\ValidationPatterns; +use Illuminate\Database\Eloquent\Model; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -27,28 +28,111 @@ class DatabasesController extends Controller { - private function removeSensitiveData($database) + private function exposeFileStorageContentIfAllowed(LocalFileVolume|LocalPersistentVolume $storage): LocalFileVolume|LocalPersistentVolume + { + if (request()->attributes->get('can_read_sensitive', false) === true) { + $storage->makeVisible(['content']); + } + + return $storage; + } + + private function removeSensitiveData($database, bool $loadNestedServerSecrets = false) { $database->makeHidden([ 'id', 'laravel_through_key', ]); - if (request()->attributes->get('can_read_sensitive', false) === false) { - $database->makeHidden([ + if (request()->attributes->get('can_read_sensitive', false) === true) { + $database->makeVisible([ 'internal_db_url', 'external_db_url', + 'init_scripts', 'postgres_password', 'dragonfly_password', 'redis_password', 'mongo_initdb_root_password', 'keydb_password', 'clickhouse_admin_password', + 'mysql_password', + 'mysql_root_password', + 'mariadb_password', + 'mariadb_root_password', ]); + $this->exposeNestedServerSecrets($database); + } else { + $this->hideNestedServerSecrets($database, $loadNestedServerSecrets); } return serializeApiResponse($database); } + private function hideNestedServerSecrets(Model $model, bool $loadRelations = false): void + { + if ($loadRelations) { + $server = data_get($model, 'destination.server'); + } else { + if (! $model->relationLoaded('destination')) { + return; + } + + $destination = $model->getRelation('destination'); + if (! $destination || ! $destination->relationLoaded('server')) { + return; + } + + $server = $destination->getRelation('server'); + } + + if (! $server) { + return; + } + + $server->makeHidden([ + 'logdrain_axiom_api_key', + 'logdrain_newrelic_license_key', + ]); + + if ($loadRelations || $server->relationLoaded('settings')) { + $server->settings->makeHidden([ + 'sentinel_token', + 'sentinel_custom_url', + 'logdrain_newrelic_license_key', + 'logdrain_axiom_api_key', + 'logdrain_custom_config', + 'logdrain_custom_config_parser', + ]); + } + } + + /** + * Expose sensitive fields on eager-loaded nested Server + ServerSetting + * relations for callers with the `read:sensitive` or `root` token ability. + */ + private function exposeNestedServerSecrets(Model $model): void + { + $server = $model->destination?->server; + if ($server === null) { + return; + } + + $server->makeVisible([ + 'logdrain_axiom_api_key', + 'logdrain_newrelic_license_key', + ]); + + if ($server->settings !== null) { + $server->settings->makeVisible([ + 'sentinel_token', + 'sentinel_custom_url', + 'logdrain_newrelic_license_key', + 'logdrain_axiom_api_key', + 'logdrain_custom_config', + 'logdrain_custom_config_parser', + ]); + } + } + #[OA\Get( summary: 'List', description: 'List all databases.', @@ -85,8 +169,12 @@ public function databases(Request $request) } $projects = Project::where('team_id', $teamId)->get(); $databases = collect(); + $databaseRelations = $request->attributes->get('can_read_sensitive', false) === true + ? ['destination.server.settings'] + : []; + foreach ($projects as $project) { - $databases = $databases->merge($project->databases()); + $databases = $databases->merge($project->databases($databaseRelations)); } $databaseIds = $databases->pluck('id')->toArray(); @@ -228,7 +316,7 @@ public function database_by_uuid(Request $request) $this->authorize('view', $database); - return response()->json($this->removeSensitiveData($database)); + return response()->json($this->removeSensitiveData($database, loadNestedServerSecrets: true)); } #[OA\Patch( @@ -3080,8 +3168,8 @@ private function removeSensitiveEnvData($env) 'resourceable_id', 'resourceable_type', ]); - if (request()->attributes->get('can_read_sensitive', false) === false) { - $env->makeHidden([ + if (request()->attributes->get('can_read_sensitive', false) === true) { + $env->makeVisible([ 'value', 'real_value', ]); @@ -3721,6 +3809,7 @@ public function storages(Request $request): JsonResponse $persistentStorages = $database->persistentStorages->sortBy('id')->values(); $fileStorages = $database->fileStorages->sortBy('id')->values(); + $fileStorages->each(fn (LocalFileVolume $storage) => $this->exposeFileStorageContentIfAllowed($storage)); return response()->json([ 'persistent_storages' => $persistentStorages, @@ -3959,7 +4048,7 @@ public function create_storage(Request $request): JsonResponse 'mount_path' => $storage->mount_path, ]); - return response()->json($storage, 201); + return response()->json($this->exposeFileStorageContentIfAllowed($storage), 201); } #[OA\Patch( @@ -4166,7 +4255,7 @@ public function update_storage(Request $request): JsonResponse 'mount_path' => $storage->mount_path ?? null, ]); - return response()->json($storage); + return response()->json($this->exposeFileStorageContentIfAllowed($storage)); } #[OA\Delete( diff --git a/app/Http/Controllers/Api/DeployController.php b/app/Http/Controllers/Api/DeployController.php index 365b43f51d..34f47d2891 100644 --- a/app/Http/Controllers/Api/DeployController.php +++ b/app/Http/Controllers/Api/DeployController.php @@ -24,6 +24,10 @@ private function removeSensitiveData($deployment) $deployment->makeHidden([ 'logs', ]); + } else { + $deployment->makeVisible([ + 'logs', + ]); } return serializeApiResponse($deployment); @@ -698,6 +702,9 @@ public function get_application_deployments(Request $request) $this->authorize('view', $application); $deployments = $application->deployments($skip, $take); + if ($request->attributes->get('can_read_sensitive', false) === true) { + $deployments['deployments']->each->makeVisible(['logs']); + } return response()->json($deployments); } diff --git a/app/Http/Controllers/Api/GithubController.php b/app/Http/Controllers/Api/GithubController.php index 16dfc81360..5c073e9c0a 100644 --- a/app/Http/Controllers/Api/GithubController.php +++ b/app/Http/Controllers/Api/GithubController.php @@ -17,10 +17,17 @@ class GithubController extends Controller { private function removeSensitiveData($githubApp) { - $githubApp->makeHidden([ - 'client_secret', - 'webhook_secret', - ]); + if (request()->attributes->get('can_read_sensitive', false) === true) { + $githubApp->makeVisible([ + 'client_secret', + 'webhook_secret', + ]); + } else { + $githubApp->makeHidden([ + 'client_secret', + 'webhook_secret', + ]); + } return serializeApiResponse($githubApp); } diff --git a/app/Http/Controllers/Api/ProjectController.php b/app/Http/Controllers/Api/ProjectController.php index 92f19c7aef..ea3b54e808 100644 --- a/app/Http/Controllers/Api/ProjectController.php +++ b/app/Http/Controllers/Api/ProjectController.php @@ -166,6 +166,9 @@ public function environment_details(Request $request) return response()->json(['message' => 'Environment not found.'], 404); } $environment = $environment->load(['applications', 'postgresqls', 'redis', 'mongodbs', 'mysqls', 'mariadbs', 'services']); + collect(['applications', 'postgresqls', 'redis', 'mongodbs', 'mysqls', 'mariadbs', 'services']) + ->flatMap(fn (string $relation) => $environment->{$relation}) + ->each(fn ($resource) => exposeSensitiveFields($resource)); return response()->json(serializeApiResponse($environment)); } diff --git a/app/Http/Controllers/Api/ResourcesController.php b/app/Http/Controllers/Api/ResourcesController.php index d5dc4a0463..53d542d44b 100644 --- a/app/Http/Controllers/Api/ResourcesController.php +++ b/app/Http/Controllers/Api/ResourcesController.php @@ -56,6 +56,7 @@ public function resources(Request $request) } $resources = $resources->flatten(); $resources = $resources->map(function ($resource) { + exposeSensitiveFields($resource); $payload = $resource->toArray(); $payload['status'] = $resource->status; $payload['type'] = $resource->type(); diff --git a/app/Http/Controllers/Api/SecurityController.php b/app/Http/Controllers/Api/SecurityController.php index 0559bcd99a..25430631b9 100644 --- a/app/Http/Controllers/Api/SecurityController.php +++ b/app/Http/Controllers/Api/SecurityController.php @@ -16,6 +16,10 @@ private function removeSensitiveData($team) $team->makeHidden([ 'private_key', ]); + } else { + $team->makeVisible([ + 'private_key', + ]); } return serializeApiResponse($team); diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index f4efc8577a..a5c74d9719 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -23,9 +23,14 @@ class ServersController extends Controller { private function removeSensitiveDataFromSettings($settings) { - if (request()->attributes->get('can_read_sensitive', false) === false) { - $settings = $settings->makeHidden([ + if (request()->attributes->get('can_read_sensitive', false) === true) { + $settings = $settings->makeVisible([ 'sentinel_token', + 'sentinel_custom_url', + 'logdrain_newrelic_license_key', + 'logdrain_axiom_api_key', + 'logdrain_custom_config', + 'logdrain_custom_config_parser', ]); } @@ -37,8 +42,11 @@ private function removeSensitiveData($server) $server->makeHidden([ 'id', ]); - if (request()->attributes->get('can_read_sensitive', false) === false) { - // Do nothing + if (request()->attributes->get('can_read_sensitive', false) === true) { + $server->makeVisible([ + 'logdrain_axiom_api_key', + 'logdrain_newrelic_license_key', + ]); } return serializeApiResponse($server); diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 3b37e73b99..24aca896b5 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -14,29 +14,45 @@ use App\Models\Server; use App\Models\Service; use App\Support\ValidationPatterns; +use Illuminate\Database\Eloquent\Model; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Validator; use OpenApi\Attributes as OA; use Symfony\Component\Yaml\Yaml; class ServicesController extends Controller { + private function exposeFileStorageContentIfAllowed(LocalFileVolume|LocalPersistentVolume $storage): LocalFileVolume|LocalPersistentVolume + { + if (request()->attributes->get('can_read_sensitive', false) === true) { + $storage->makeVisible(['content']); + } + + return $storage; + } + private function removeSensitiveData($service) { + if ($service instanceof Collection) { + return $service->map(fn (Service $item) => $this->removeSensitiveData($item)); + } + $service->makeHidden([ 'id', 'resourceable', 'resourceable_id', 'resourceable_type', ]); - if (request()->attributes->get('can_read_sensitive', false) === false) { - $service->makeHidden([ + if (request()->attributes->get('can_read_sensitive', false) === true) { + $service->makeVisible([ 'docker_compose_raw', 'docker_compose', 'value', 'real_value', ]); + $this->exposeNestedServerSecrets($service); } if ($service->is_shown_once ?? false) { @@ -46,6 +62,42 @@ private function removeSensitiveData($service) return serializeApiResponse($service); } + /** + * Expose sensitive fields on eager-loaded nested Server + ServerSetting + * relations for callers with the `read:sensitive` or `root` token ability. + * Handles both single models and Eloquent Collections (the listing endpoint + * passes a Collection of Services per project to removeSensitiveData()). + */ + private function exposeNestedServerSecrets(Model|Collection $model): void + { + if ($model instanceof Collection) { + foreach ($model as $item) { + $this->exposeNestedServerSecrets($item); + } + + return; + } + $server = $model->destination?->server ?? $model->server ?? null; + if (! $server) { + return; + } + $server->makeVisible([ + 'logdrain_axiom_api_key', + 'logdrain_newrelic_license_key', + ]); + $settings = $server->settings ?? null; + if ($settings) { + $settings->makeVisible([ + 'sentinel_token', + 'sentinel_custom_url', + 'logdrain_newrelic_license_key', + 'logdrain_axiom_api_key', + 'logdrain_custom_config', + 'logdrain_custom_config_parser', + ]); + } + } + private function applyServiceUrls(Service $service, array $urlsArray, string $teamId, bool $forceDomainOverride = false): ?array { $errors = []; @@ -170,8 +222,12 @@ public function services(Request $request) } $projects = Project::where('team_id', $teamId)->get(); $services = collect(); + $serviceRelations = $request->attributes->get('can_read_sensitive', false) === true + ? ['destination.server.settings'] + : []; + foreach ($projects as $project) { - $services->push($project->services()->get()); + $services->push($project->services()->with($serviceRelations)->get()); } foreach ($services as $service) { $service = $this->removeSensitiveData($service); @@ -726,7 +782,12 @@ public function service_by_uuid(Request $request) $this->authorize('view', $service); - $service = $service->load(['applications', 'databases']); + $serviceRelations = ['applications', 'databases']; + if ($request->attributes->get('can_read_sensitive', false) === true) { + $serviceRelations[] = 'destination.server.settings'; + } + + $service = $service->load($serviceRelations); return response()->json($this->removeSensitiveData($service)); } @@ -2137,6 +2198,8 @@ public function storages(Request $request): JsonResponse ); } + $fileStorages->each(fn (LocalFileVolume $storage) => $this->exposeFileStorageContentIfAllowed($storage)); + return response()->json([ 'persistent_storages' => $persistentStorages->sortBy('id')->values(), 'file_storages' => $fileStorages->sortBy('id')->values(), @@ -2384,7 +2447,7 @@ public function create_storage(Request $request): JsonResponse 'mount_path' => $storage->mount_path, ]); - return response()->json($storage, 201); + return response()->json($this->exposeFileStorageContentIfAllowed($storage), 201); } #[OA\Patch( @@ -2621,7 +2684,7 @@ public function update_storage(Request $request): JsonResponse 'mount_path' => $storage->mount_path ?? null, ]); - return response()->json($storage); + return response()->json($this->exposeFileStorageContentIfAllowed($storage)); } #[OA\Delete( diff --git a/app/Http/Middleware/ApiSensitiveData.php b/app/Http/Middleware/ApiSensitiveData.php index 8d7c51d111..05d1b3e6e9 100644 --- a/app/Http/Middleware/ApiSensitiveData.php +++ b/app/Http/Middleware/ApiSensitiveData.php @@ -11,8 +11,9 @@ public function handle(Request $request, Closure $next) { $token = $request->user()->currentAccessToken(); $hasTokenPermission = $token->can('root') || $token->can('read:sensitive'); - $teamId = (int) data_get($token, 'team_id'); - $isAdmin = $teamId ? $request->user()->isAdminOfTeam($teamId) : false; + $teamId = data_get($token, 'team_id'); + // team_id 0 is the instance-admin team, so a falsy check must not exclude it + $isAdmin = ! is_null($teamId) ? $request->user()->isAdminOfTeam((int) $teamId) : false; // Allow access to sensitive data only if token has permission AND user is admin/owner $request->attributes->add([ diff --git a/app/Models/Application.php b/app/Models/Application.php index 2c408483e0..d46e4366b7 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -176,11 +176,8 @@ class Application extends BaseModel 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'docker_compose_location', - 'docker_compose_pr_location', 'docker_compose', - 'docker_compose_pr', 'docker_compose_raw', - 'docker_compose_pr_raw', 'docker_compose_domains', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', @@ -218,6 +215,24 @@ class Application extends BaseModel protected $appends = ['server_status']; + /** + * Sensitive fields hidden by default in serialized output (toArray/toJson). + * API controllers should call makeVisible([...]) for callers with the + * `read:sensitive` or `root` token ability. Internal serializers (deployment + * job, compose generation) must makeVisible explicitly before toArray(). + */ + protected $hidden = [ + 'http_basic_auth_password', + 'manual_webhook_secret_github', + 'manual_webhook_secret_gitlab', + 'manual_webhook_secret_bitbucket', + 'manual_webhook_secret_gitea', + 'dockerfile', + 'docker_compose', + 'docker_compose_raw', + 'custom_labels', + ]; + protected function casts(): array { return [ @@ -1266,9 +1281,9 @@ private function legacyConfigurationHash(): string { $newConfigHash = base64_encode($this->fqdn.$this->git_repository.$this->git_branch.$this->git_commit_sha.$this->build_pack.$this->static_image.$this->install_command.$this->build_command.$this->start_command.$this->ports_exposes.$this->ports_mappings.$this->custom_network_aliases.$this->base_directory.$this->publish_directory.$this->dockerfile.$this->dockerfile_location.$this->custom_labels.$this->custom_docker_run_options.$this->dockerfile_target_build.$this->redirect.$this->custom_nginx_configuration.$this->settings?->use_build_secrets.$this->settings?->inject_build_args_to_dockerfile.$this->settings?->include_source_commit_in_build); if ($this->pull_request_id === 0 || $this->pull_request_id === null) { - $newConfigHash .= json_encode($this->environment_variables()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort()); + $newConfigHash .= json_encode($this->environment_variables()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->makeVisible('value')->sort()); } else { - $newConfigHash .= json_encode($this->environment_variables_preview()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort()); + $newConfigHash .= json_encode($this->environment_variables_preview()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->makeVisible('value')->sort()); } return md5($newConfigHash); diff --git a/app/Models/ApplicationDeploymentQueue.php b/app/Models/ApplicationDeploymentQueue.php index 53fb8337fe..ee190532c4 100644 --- a/app/Models/ApplicationDeploymentQueue.php +++ b/app/Models/ApplicationDeploymentQueue.php @@ -84,6 +84,7 @@ class ApplicationDeploymentQueue extends Model * @var array */ protected $hidden = [ + 'logs', 'configuration_snapshot', 'configuration_diff', ]; diff --git a/app/Models/CloudInitScript.php b/app/Models/CloudInitScript.php index 2c78cc5827..9d2676aca3 100644 --- a/app/Models/CloudInitScript.php +++ b/app/Models/CloudInitScript.php @@ -12,6 +12,10 @@ class CloudInitScript extends Model 'script', ]; + protected $hidden = [ + 'script', + ]; + protected function casts(): array { return [ diff --git a/app/Models/CloudProviderToken.php b/app/Models/CloudProviderToken.php index 35452553bf..27214f1d1d 100644 --- a/app/Models/CloudProviderToken.php +++ b/app/Models/CloudProviderToken.php @@ -15,6 +15,10 @@ class CloudProviderToken extends BaseModel 'name', ]; + protected $hidden = [ + 'token', + ]; + protected $casts = [ 'token' => 'encrypted', ]; diff --git a/app/Models/DiscordNotificationSettings.php b/app/Models/DiscordNotificationSettings.php index e86598126e..135c921f61 100644 --- a/app/Models/DiscordNotificationSettings.php +++ b/app/Models/DiscordNotificationSettings.php @@ -34,6 +34,10 @@ class DiscordNotificationSettings extends Model 'discord_ping_enabled', ]; + protected $hidden = [ + 'discord_webhook_url', + ]; + protected $casts = [ 'discord_enabled' => 'boolean', 'discord_webhook_url' => 'encrypted', diff --git a/app/Models/EmailNotificationSettings.php b/app/Models/EmailNotificationSettings.php index 1277e45d91..7368bafbf0 100644 --- a/app/Models/EmailNotificationSettings.php +++ b/app/Models/EmailNotificationSettings.php @@ -43,6 +43,16 @@ class EmailNotificationSettings extends Model 'traefik_outdated_email_notifications', ]; + protected $hidden = [ + 'smtp_from_address', + 'smtp_from_name', + 'smtp_recipients', + 'smtp_host', + 'smtp_username', + 'smtp_password', + 'resend_api_key', + ]; + protected $casts = [ 'smtp_enabled' => 'boolean', 'smtp_from_address' => 'encrypted', diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index e346e59d13..89188b31b1 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -80,6 +80,16 @@ class EnvironmentVariable extends BaseModel protected $appends = ['real_value', 'is_shared', 'is_really_required', 'is_buildpack_control', 'is_coolify']; + /** + * Sensitive fields hidden by default in serialized output (toArray/toJson). + * API controllers should call makeVisible([...]) for callers with the + * `read:sensitive` or `root` token ability. + */ + protected $hidden = [ + 'value', + 'real_value', + ]; + protected static function booted() { static::created(function (ModelsEnvironmentVariable $environment_variable) { diff --git a/app/Models/InstanceSettings.php b/app/Models/InstanceSettings.php index 57d3e6ae64..9337404038 100644 --- a/app/Models/InstanceSettings.php +++ b/app/Models/InstanceSettings.php @@ -50,6 +50,17 @@ class InstanceSettings extends Model 'webhook_allow_localhost', ]; + protected $hidden = [ + 'smtp_from_address', + 'smtp_from_name', + 'smtp_recipients', + 'smtp_host', + 'smtp_username', + 'smtp_password', + 'resend_api_key', + 'sentinel_token', + ]; + protected $casts = [ 'smtp_enabled' => 'boolean', 'smtp_from_address' => 'encrypted', diff --git a/app/Models/LocalFileVolume.php b/app/Models/LocalFileVolume.php index 4d18d4ca22..968e6c3d04 100644 --- a/app/Models/LocalFileVolume.php +++ b/app/Models/LocalFileVolume.php @@ -25,6 +25,10 @@ class LocalFileVolume extends BaseModel 'is_preview_suffix_enabled' => 'boolean', ]; + protected $hidden = [ + 'content', + ]; + use HasFactory; protected $fillable = [ diff --git a/app/Models/OauthSetting.php b/app/Models/OauthSetting.php index 08e08d85b1..e7999134a6 100644 --- a/app/Models/OauthSetting.php +++ b/app/Models/OauthSetting.php @@ -13,6 +13,10 @@ class OauthSetting extends Model protected $fillable = ['provider', 'client_id', 'client_secret', 'redirect_uri', 'tenant', 'base_url', 'enabled']; + protected $hidden = [ + 'client_secret', + ]; + protected function clientSecret(): Attribute { return Attribute::make( diff --git a/app/Models/PrivateKey.php b/app/Models/PrivateKey.php index bf42f21c72..d16213ad72 100644 --- a/app/Models/PrivateKey.php +++ b/app/Models/PrivateKey.php @@ -42,6 +42,10 @@ class PrivateKey extends BaseModel 'fingerprint', ]; + protected $hidden = [ + 'private_key', + ]; + protected $casts = [ 'private_key' => 'encrypted', ]; diff --git a/app/Models/Project.php b/app/Models/Project.php index b47e7cf042..5c821b017f 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -5,6 +5,7 @@ use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Support\Collection; use OpenApi\Attributes as OA; #[OA\Schema( @@ -155,9 +156,16 @@ public function isEmpty() $this->services()->count() == 0; } - public function databases() + public function databases(array $with = []): Collection { - return $this->postgresqls()->get()->merge($this->redis()->get())->merge($this->mongodbs()->get())->merge($this->mysqls()->get())->merge($this->mariadbs()->get())->merge($this->keydbs()->get())->merge($this->dragonflies()->get())->merge($this->clickhouses()->get()); + return $this->postgresqls()->with($with)->get() + ->merge($this->redis()->with($with)->get()) + ->merge($this->mongodbs()->with($with)->get()) + ->merge($this->mysqls()->with($with)->get()) + ->merge($this->mariadbs()->with($with)->get()) + ->merge($this->keydbs()->with($with)->get()) + ->merge($this->dragonflies()->with($with)->get()) + ->merge($this->clickhouses()->with($with)->get()); } public function navigateTo() diff --git a/app/Models/PushoverNotificationSettings.php b/app/Models/PushoverNotificationSettings.php index 5ad617ad69..dd0d81cc0e 100644 --- a/app/Models/PushoverNotificationSettings.php +++ b/app/Models/PushoverNotificationSettings.php @@ -34,6 +34,11 @@ class PushoverNotificationSettings extends Model 'traefik_outdated_pushover_notifications', ]; + protected $hidden = [ + 'pushover_user_key', + 'pushover_api_token', + ]; + protected $casts = [ 'pushover_enabled' => 'boolean', 'pushover_user_key' => 'encrypted', diff --git a/app/Models/S3Storage.php b/app/Models/S3Storage.php index fe08984ed5..70703cd520 100644 --- a/app/Models/S3Storage.php +++ b/app/Models/S3Storage.php @@ -32,6 +32,11 @@ class S3Storage extends BaseModel 'unusable_email_sent', ]; + protected $hidden = [ + 'key', + 'secret', + ]; + protected $casts = [ 'is_usable' => 'boolean', 'key' => 'encrypted', diff --git a/app/Models/Server.php b/app/Models/Server.php index f6fc39df9c..c0102116e9 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -254,6 +254,16 @@ public static function flushIdentityMap(): void 'force_disabled' => 'boolean', ]; + /** + * Sensitive fields hidden by default in serialized output (toArray/toJson). + * API controllers should call makeVisible([...]) for callers with the + * `read:sensitive` or `root` token ability. + */ + protected $hidden = [ + 'logdrain_axiom_api_key', + 'logdrain_newrelic_license_key', + ]; + protected $schemalessAttributes = [ 'proxy', ]; diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index 79f62f4b72..e96aab4a3a 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -114,6 +114,20 @@ class ServerSetting extends Model 'connection_timeout' => 'integer', ]; + /** + * Sensitive fields hidden by default in serialized output (toArray/toJson). + * API controllers should call makeVisible([...]) for callers with the + * `read:sensitive` or `root` token ability. + */ + protected $hidden = [ + 'sentinel_token', + 'sentinel_custom_url', + 'logdrain_newrelic_license_key', + 'logdrain_axiom_api_key', + 'logdrain_custom_config', + 'logdrain_custom_config_parser', + ]; + protected static function booted() { static::creating(function ($setting) { diff --git a/app/Models/Service.php b/app/Models/Service.php index 98af0472f1..89438053dd 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -66,6 +66,17 @@ class Service extends BaseModel protected $appends = ['server_status', 'status']; + /** + * Sensitive fields hidden by default in serialized output (toArray/toJson). + * API controllers should call makeVisible([...]) for callers with the + * `read:sensitive` or `root` token ability. Internal compose generators + * must makeVisible explicitly before toArray(). + */ + protected $hidden = [ + 'docker_compose', + 'docker_compose_raw', + ]; + protected static function booted() { static::creating(function ($service) { @@ -94,7 +105,7 @@ public function isConfigurationChanged(bool $save = false) $storages = $applicationStorages->merge($databaseStorages)->implode('updated_at'); $newConfigHash = $images.$domains.$images.$storages; - $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); + $newConfigHash .= json_encode($this->environment_variables()->get('value')->makeVisible('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); if ($oldConfigHash === null) { diff --git a/app/Models/SharedEnvironmentVariable.php b/app/Models/SharedEnvironmentVariable.php index eadc33ec22..8bb2412403 100644 --- a/app/Models/SharedEnvironmentVariable.php +++ b/app/Models/SharedEnvironmentVariable.php @@ -30,6 +30,10 @@ class SharedEnvironmentVariable extends Model 'version', ]; + protected $hidden = [ + 'value', + ]; + protected $casts = [ 'key' => 'string', 'value' => 'encrypted', diff --git a/app/Models/SlackNotificationSettings.php b/app/Models/SlackNotificationSettings.php index d4f125fb5d..62603685e9 100644 --- a/app/Models/SlackNotificationSettings.php +++ b/app/Models/SlackNotificationSettings.php @@ -33,6 +33,10 @@ class SlackNotificationSettings extends Model 'traefik_outdated_slack_notifications', ]; + protected $hidden = [ + 'slack_webhook_url', + ]; + protected $casts = [ 'slack_enabled' => 'boolean', 'slack_webhook_url' => 'encrypted', diff --git a/app/Models/SslCertificate.php b/app/Models/SslCertificate.php index eb2175d44b..2311cea729 100644 --- a/app/Models/SslCertificate.php +++ b/app/Models/SslCertificate.php @@ -20,6 +20,10 @@ class SslCertificate extends Model 'is_ca_certificate', ]; + protected $hidden = [ + 'ssl_private_key', + ]; + protected $casts = [ 'ssl_certificate' => 'encrypted', 'ssl_private_key' => 'encrypted', diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php index b104be642d..9db5f21b78 100644 --- a/app/Models/StandaloneClickhouse.php +++ b/app/Models/StandaloneClickhouse.php @@ -54,6 +54,17 @@ class StandaloneClickhouse extends BaseModel protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; + /** + * Sensitive fields hidden by default in serialized output (toArray/toJson). + * API controllers should call makeVisible([...]) for callers with the + * `read:sensitive` or `root` token ability. + */ + protected $hidden = [ + 'clickhouse_admin_password', + 'internal_db_url', + 'external_db_url', + ]; + protected $casts = [ 'health_check_enabled' => 'boolean', 'health_check_interval' => 'integer', @@ -123,7 +134,7 @@ public function isConfigurationChanged(bool $save = false) { $newConfigHash = $this->image.$this->ports_mappings; $newConfigHash .= $this->healthCheckConfigurationHash(); - $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); + $newConfigHash .= json_encode($this->environment_variables()->get('value')->makeVisible('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); if ($oldConfigHash === null) { diff --git a/app/Models/StandaloneDragonfly.php b/app/Models/StandaloneDragonfly.php index 2232ec7725..769d9f00c4 100644 --- a/app/Models/StandaloneDragonfly.php +++ b/app/Models/StandaloneDragonfly.php @@ -53,6 +53,17 @@ class StandaloneDragonfly extends BaseModel protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; + /** + * Sensitive fields hidden by default in serialized output (toArray/toJson). + * API controllers should call makeVisible([...]) for callers with the + * `read:sensitive` or `root` token ability. + */ + protected $hidden = [ + 'dragonfly_password', + 'internal_db_url', + 'external_db_url', + ]; + protected $casts = [ 'health_check_enabled' => 'boolean', 'health_check_interval' => 'integer', @@ -122,7 +133,7 @@ public function isConfigurationChanged(bool $save = false) { $newConfigHash = $this->image.$this->ports_mappings; $newConfigHash .= $this->healthCheckConfigurationHash(); - $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); + $newConfigHash .= json_encode($this->environment_variables()->get('value')->makeVisible('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); if ($oldConfigHash === null) { diff --git a/app/Models/StandaloneKeydb.php b/app/Models/StandaloneKeydb.php index b9f9f765b4..15a1fe2f82 100644 --- a/app/Models/StandaloneKeydb.php +++ b/app/Models/StandaloneKeydb.php @@ -54,6 +54,17 @@ class StandaloneKeydb extends BaseModel protected $appends = ['internal_db_url', 'external_db_url', 'server_status']; + /** + * Sensitive fields hidden by default in serialized output (toArray/toJson). + * API controllers should call makeVisible([...]) for callers with the + * `read:sensitive` or `root` token ability. + */ + protected $hidden = [ + 'keydb_password', + 'internal_db_url', + 'external_db_url', + ]; + protected $casts = [ 'health_check_enabled' => 'boolean', 'health_check_interval' => 'integer', @@ -123,7 +134,7 @@ public function isConfigurationChanged(bool $save = false) { $newConfigHash = $this->image.$this->ports_mappings.$this->keydb_conf; $newConfigHash .= $this->healthCheckConfigurationHash(); - $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); + $newConfigHash .= json_encode($this->environment_variables()->get('value')->makeVisible('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); if ($oldConfigHash === null) { diff --git a/app/Models/StandaloneMariadb.php b/app/Models/StandaloneMariadb.php index cd94b6c9bf..378d36395d 100644 --- a/app/Models/StandaloneMariadb.php +++ b/app/Models/StandaloneMariadb.php @@ -57,6 +57,18 @@ class StandaloneMariadb extends BaseModel protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; + /** + * Sensitive fields hidden by default in serialized output (toArray/toJson). + * API controllers should call makeVisible([...]) for callers with the + * `read:sensitive` or `root` token ability. + */ + protected $hidden = [ + 'mariadb_password', + 'mariadb_root_password', + 'internal_db_url', + 'external_db_url', + ]; + protected $casts = [ 'health_check_enabled' => 'boolean', 'health_check_interval' => 'integer', @@ -126,7 +138,7 @@ public function isConfigurationChanged(bool $save = false) { $newConfigHash = $this->image.$this->ports_mappings.$this->mariadb_conf; $newConfigHash .= $this->healthCheckConfigurationHash(); - $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); + $newConfigHash .= json_encode($this->environment_variables()->get('value')->makeVisible('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); if ($oldConfigHash === null) { diff --git a/app/Models/StandaloneMongodb.php b/app/Models/StandaloneMongodb.php index 7d2ffbd74f..1010ca5f37 100644 --- a/app/Models/StandaloneMongodb.php +++ b/app/Models/StandaloneMongodb.php @@ -57,6 +57,17 @@ class StandaloneMongodb extends BaseModel protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; + /** + * Sensitive fields hidden by default in serialized output (toArray/toJson). + * API controllers should call makeVisible([...]) for callers with the + * `read:sensitive` or `root` token ability. + */ + protected $hidden = [ + 'mongo_initdb_root_password', + 'internal_db_url', + 'external_db_url', + ]; + protected $casts = [ 'health_check_enabled' => 'boolean', 'health_check_interval' => 'integer', @@ -132,7 +143,7 @@ public function isConfigurationChanged(bool $save = false) { $newConfigHash = $this->image.$this->ports_mappings.$this->mongo_conf; $newConfigHash .= $this->healthCheckConfigurationHash(); - $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); + $newConfigHash .= json_encode($this->environment_variables()->get('value')->makeVisible('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); if ($oldConfigHash === null) { diff --git a/app/Models/StandaloneMysql.php b/app/Models/StandaloneMysql.php index f752312d34..90828bf012 100644 --- a/app/Models/StandaloneMysql.php +++ b/app/Models/StandaloneMysql.php @@ -58,6 +58,18 @@ class StandaloneMysql extends BaseModel protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; + /** + * Sensitive fields hidden by default in serialized output (toArray/toJson). + * API controllers should call makeVisible([...]) for callers with the + * `read:sensitive` or `root` token ability. + */ + protected $hidden = [ + 'mysql_password', + 'mysql_root_password', + 'internal_db_url', + 'external_db_url', + ]; + protected $casts = [ 'health_check_enabled' => 'boolean', 'health_check_interval' => 'integer', @@ -128,7 +140,7 @@ public function isConfigurationChanged(bool $save = false) { $newConfigHash = $this->image.$this->ports_mappings.$this->mysql_conf; $newConfigHash .= $this->healthCheckConfigurationHash(); - $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); + $newConfigHash .= json_encode($this->environment_variables()->get('value')->makeVisible('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); if ($oldConfigHash === null) { diff --git a/app/Models/StandalonePostgresql.php b/app/Models/StandalonePostgresql.php index 04d2291b39..e7db812858 100644 --- a/app/Models/StandalonePostgresql.php +++ b/app/Models/StandalonePostgresql.php @@ -60,6 +60,18 @@ class StandalonePostgresql extends BaseModel protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; + /** + * Sensitive fields hidden by default in serialized output (toArray/toJson). + * API controllers should call makeVisible([...]) for callers with the + * `read:sensitive` or `root` token ability. + */ + protected $hidden = [ + 'postgres_password', + 'init_scripts', + 'internal_db_url', + 'external_db_url', + ]; + protected $casts = [ 'health_check_enabled' => 'boolean', 'health_check_interval' => 'integer', @@ -170,7 +182,7 @@ public function isConfigurationChanged(bool $save = false) { $newConfigHash = $this->image.$this->ports_mappings.$this->postgres_initdb_args.$this->postgres_host_auth_method; $newConfigHash .= $this->healthCheckConfigurationHash(); - $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); + $newConfigHash .= json_encode($this->environment_variables()->get('value')->makeVisible('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); if ($oldConfigHash === null) { diff --git a/app/Models/StandaloneRedis.php b/app/Models/StandaloneRedis.php index efb0254fbc..3262611903 100644 --- a/app/Models/StandaloneRedis.php +++ b/app/Models/StandaloneRedis.php @@ -53,6 +53,17 @@ class StandaloneRedis extends BaseModel protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; + /** + * Sensitive fields hidden by default in serialized output (toArray/toJson). + * API controllers should call makeVisible([...]) for callers with the + * `read:sensitive` or `root` token ability. + */ + protected $hidden = [ + 'redis_password', + 'internal_db_url', + 'external_db_url', + ]; + protected $casts = [ 'health_check_enabled' => 'boolean', 'health_check_interval' => 'integer', @@ -127,7 +138,7 @@ public function isConfigurationChanged(bool $save = false) { $newConfigHash = $this->image.$this->ports_mappings.$this->redis_conf; $newConfigHash .= $this->healthCheckConfigurationHash(); - $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); + $newConfigHash .= json_encode($this->environment_variables()->get('value')->makeVisible('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); if ($oldConfigHash === null) { diff --git a/app/Models/TelegramNotificationSettings.php b/app/Models/TelegramNotificationSettings.php index 4930f45d43..8c644f9bcf 100644 --- a/app/Models/TelegramNotificationSettings.php +++ b/app/Models/TelegramNotificationSettings.php @@ -49,6 +49,25 @@ class TelegramNotificationSettings extends Model 'telegram_notifications_traefik_outdated_thread_id', ]; + protected $hidden = [ + 'telegram_token', + 'telegram_chat_id', + 'telegram_notifications_deployment_success_thread_id', + 'telegram_notifications_deployment_failure_thread_id', + 'telegram_notifications_status_change_thread_id', + 'telegram_notifications_backup_success_thread_id', + 'telegram_notifications_backup_failure_thread_id', + 'telegram_notifications_scheduled_task_success_thread_id', + 'telegram_notifications_scheduled_task_failure_thread_id', + 'telegram_notifications_docker_cleanup_success_thread_id', + 'telegram_notifications_docker_cleanup_failure_thread_id', + 'telegram_notifications_server_disk_usage_thread_id', + 'telegram_notifications_server_reachable_thread_id', + 'telegram_notifications_server_unreachable_thread_id', + 'telegram_notifications_server_patch_thread_id', + 'telegram_notifications_traefik_outdated_thread_id', + ]; + protected $casts = [ 'telegram_enabled' => 'boolean', 'telegram_token' => 'encrypted', @@ -75,7 +94,8 @@ class TelegramNotificationSettings extends Model 'telegram_notifications_backup_failure_thread_id' => 'encrypted', 'telegram_notifications_scheduled_task_success_thread_id' => 'encrypted', 'telegram_notifications_scheduled_task_failure_thread_id' => 'encrypted', - 'telegram_notifications_docker_cleanup_thread_id' => 'encrypted', + 'telegram_notifications_docker_cleanup_success_thread_id' => 'encrypted', + 'telegram_notifications_docker_cleanup_failure_thread_id' => 'encrypted', 'telegram_notifications_server_disk_usage_thread_id' => 'encrypted', 'telegram_notifications_server_reachable_thread_id' => 'encrypted', 'telegram_notifications_server_unreachable_thread_id' => 'encrypted', diff --git a/app/Models/WebhookNotificationSettings.php b/app/Models/WebhookNotificationSettings.php index 7310061816..c6a81b50a8 100644 --- a/app/Models/WebhookNotificationSettings.php +++ b/app/Models/WebhookNotificationSettings.php @@ -33,6 +33,10 @@ class WebhookNotificationSettings extends Model 'traefik_outdated_webhook_notifications', ]; + protected $hidden = [ + 'webhook_url', + ]; + protected function casts(): array { return [ diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index 386982052f..c75fbc7055 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -6,6 +6,7 @@ use App\Rules\ValidGitBranch; use App\Support\ValidationPatterns; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Eloquent\Model; use Illuminate\Http\Request; use Illuminate\Validation\Rule; @@ -87,6 +88,20 @@ function serializeApiResponse($data) } } +/** + * Re-expose a model's `$hidden` sensitive fields when the current API request + * carries the `read:sensitive` or `root` token ability (set by the + * ApiSensitiveData middleware). + */ +function exposeSensitiveFields(Model $model): Model +{ + if (request()->attributes->get('can_read_sensitive', false) === true && filled($model->getHidden())) { + $model->makeVisible($model->getHidden()); + } + + return $model; +} + function sharedDataApplications() { return [ diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index 8d1a3369bc..0a2dfda9d7 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -803,7 +803,7 @@ "category": "backend", "logo": "svgs/convex.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-05-09T19:26:30+05:30", + "template_last_updated_at": "2026-06-12T10:45:52+02:00", "port": "6791" }, "cryptgeon": { @@ -1779,7 +1779,7 @@ "category": "devtools", "logo": "svgs/gitea.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-06-01T07:54:27-05:00" + "template_last_updated_at": "2026-06-06T00:11:24+02:00" }, "gitea-with-mariadb": { "documentation": "https://docs.gitea.com?utm_source=coolify.io", @@ -2361,7 +2361,7 @@ "category": "automation", "logo": "svgs/inngest.png", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-07-02T13:25:47+02:00", "port": "8288" }, "invoice-ninja": { diff --git a/templates/service-templates.json b/templates/service-templates.json index 2ad3008f51..4d97d8d5e2 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -803,7 +803,7 @@ "category": "backend", "logo": "svgs/convex.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-05-09T19:26:30+05:30", + "template_last_updated_at": "2026-06-12T10:45:52+02:00", "port": "6791" }, "cryptgeon": { @@ -1779,7 +1779,7 @@ "category": "devtools", "logo": "svgs/gitea.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-06-01T07:54:27-05:00" + "template_last_updated_at": "2026-06-06T00:11:24+02:00" }, "gitea-with-mariadb": { "documentation": "https://docs.gitea.com?utm_source=coolify.io", @@ -2361,7 +2361,7 @@ "category": "automation", "logo": "svgs/inngest.png", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-07-02T13:25:47+02:00", "port": "8288" }, "invoice-ninja": { diff --git a/tests/Feature/Api/CloudProviderTokenApiTest.php b/tests/Feature/Api/CloudProviderTokenApiTest.php index 1a24b5eca4..42b2d09723 100644 --- a/tests/Feature/Api/CloudProviderTokenApiTest.php +++ b/tests/Feature/Api/CloudProviderTokenApiTest.php @@ -78,6 +78,63 @@ $response = $this->getJson('/api/v1/cloud-tokens'); $response->assertStatus(401); }); + + test('read token does not include provider token values', function () { + CloudProviderToken::create([ + 'team_id' => $this->team->id, + 'name' => 'Hidden Token', + 'provider' => 'hetzner', + 'token' => 'hidden-cloud-provider-token', + ]); + + $readToken = $this->user->createToken('read-token', ['read'])->plainTextToken; + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$readToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/cloud-tokens'); + + $response->assertSuccessful(); + expect($response->getContent())->not->toContain('"token":'); + }); + + test('read sensitive token includes provider token values', function () { + CloudProviderToken::create([ + 'team_id' => $this->team->id, + 'name' => 'Visible Token', + 'provider' => 'hetzner', + 'token' => 'visible-cloud-provider-token', + ]); + + $readSensitiveToken = $this->user->createToken('read-sensitive-token', ['read', 'read:sensitive'])->plainTextToken; + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$readSensitiveToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/cloud-tokens'); + + $response->assertSuccessful(); + $response->assertJsonFragment(['token' => 'visible-cloud-provider-token']); + }); + + test('root token includes provider token values', function () { + CloudProviderToken::create([ + 'team_id' => $this->team->id, + 'name' => 'Root Visible Token', + 'provider' => 'hetzner', + 'token' => 'root-visible-cloud-provider-token', + ]); + + $rootToken = $this->user->createToken('root-token', ['root'])->plainTextToken; + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$rootToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/cloud-tokens'); + + $response->assertSuccessful(); + $response->assertJsonFragment(['token' => 'root-visible-cloud-provider-token']); + }); }); describe('GET /api/v1/cloud-tokens/{uuid}', function () { @@ -120,6 +177,44 @@ $response->assertStatus(404); }); + + test('read token does not include provider token value by UUID', function () { + $token = CloudProviderToken::create([ + 'team_id' => $this->team->id, + 'name' => 'Hidden Token Detail', + 'provider' => 'hetzner', + 'token' => 'hidden-cloud-provider-token-detail', + ]); + + $readToken = $this->user->createToken('read-token', ['read'])->plainTextToken; + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$readToken, + 'Content-Type' => 'application/json', + ])->getJson("/api/v1/cloud-tokens/{$token->uuid}"); + + $response->assertSuccessful(); + expect($response->getContent())->not->toContain('"token":'); + }); + + test('read sensitive token includes provider token value by UUID', function () { + $token = CloudProviderToken::create([ + 'team_id' => $this->team->id, + 'name' => 'Visible Token Detail', + 'provider' => 'hetzner', + 'token' => 'visible-cloud-provider-token-detail', + ]); + + $readSensitiveToken = $this->user->createToken('read-sensitive-token', ['read', 'read:sensitive'])->plainTextToken; + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$readSensitiveToken, + 'Content-Type' => 'application/json', + ])->getJson("/api/v1/cloud-tokens/{$token->uuid}"); + + $response->assertSuccessful(); + $response->assertJsonFragment(['token' => 'visible-cloud-provider-token-detail']); + }); }); describe('POST /api/v1/cloud-tokens', function () { @@ -294,8 +389,11 @@ 'Content-Type' => 'application/json', ])->patchJson("/api/v1/cloud-tokens/{$token->uuid}", []); - $response->assertStatus(422); - $response->assertJsonValidationErrors(['name']); + $response->assertStatus(400); + $response->assertJson([ + 'message' => 'Invalid request.', + 'error' => 'Invalid JSON.', + ]); }); test('cannot update token from another team', function () { diff --git a/tests/Feature/Api/GithubAppsListApiTest.php b/tests/Feature/Api/GithubAppsListApiTest.php index 8f3e6dac5c..9a1f1f3d25 100644 --- a/tests/Feature/Api/GithubAppsListApiTest.php +++ b/tests/Feature/Api/GithubAppsListApiTest.php @@ -11,6 +11,9 @@ uses(RefreshDatabase::class); beforeEach(function () { + config()->set('app.maintenance.driver', 'file'); + config()->set('cache.default', 'array'); + InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]); // Create a team with owner @@ -20,7 +23,7 @@ session(['currentTeam' => $this->team]); // Create an API token for the user - $this->token = $this->user->createToken('test-token'); + $this->token = $this->user->createToken('test-token', ['*']); $this->bearerToken = $this->token->plainTextToken; // Create a private key for the team @@ -31,6 +34,13 @@ ]); }); +function createGithubAppsApiToken($context, array $abilities): string +{ + session(['currentTeam' => $context->team]); + + return $context->user->createToken('github-apps-test-token', $abilities)->plainTextToken; +} + /** * Generate a temporary 2048-bit RSA private key for GitHub Apps API tests. * @@ -96,7 +106,7 @@ function validGithubAppsApiPrivateKey(): string ]); }); - test('does not return sensitive data', function () { + test('does not return sensitive data for read tokens', function () { // Create a GitHub app GithubApp::create([ 'name' => 'Test GitHub App', @@ -111,8 +121,10 @@ function validGithubAppsApiPrivateKey(): string 'team_id' => $this->team->id, ]); + $readToken = createGithubAppsApiToken($this, ['read']); + $response = $this->withHeaders([ - 'Authorization' => 'Bearer '.$this->bearerToken, + 'Authorization' => 'Bearer '.$readToken, ])->getJson('/api/v1/github-apps'); $response->assertStatus(200); @@ -123,6 +135,33 @@ function validGithubAppsApiPrivateKey(): string expect($json[0])->not->toHaveKey('webhook_secret'); }); + test('returns sensitive data for read sensitive tokens', function () { + GithubApp::create([ + 'name' => 'Sensitive GitHub App', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'app_id' => 12345, + 'installation_id' => 67890, + 'client_id' => 'test-client-id', + 'client_secret' => 'secret-should-be-visible', + 'webhook_secret' => 'webhook-secret-should-be-visible', + 'private_key_id' => $this->privateKey->id, + 'team_id' => $this->team->id, + ]); + + $sensitiveToken = createGithubAppsApiToken($this, ['read', 'read:sensitive']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$sensitiveToken, + ])->getJson('/api/v1/github-apps'); + + $response->assertSuccessful(); + $response->assertJsonFragment([ + 'client_secret' => 'secret-should-be-visible', + 'webhook_secret' => 'webhook-secret-should-be-visible', + ]); + }); + test('returns system-wide github apps', function () { // Create a system-wide GitHub app $systemApp = GithubApp::create([ @@ -144,7 +183,7 @@ function validGithubAppsApiPrivateKey(): string $otherUser = User::factory()->create(); $otherTeam->members()->attach($otherUser->id, ['role' => 'owner']); session(['currentTeam' => $otherTeam]); - $otherToken = $otherUser->createToken('other-token'); + $otherToken = $otherUser->createToken('other-token', ['*']); // System-wide apps should be visible to other teams $response = $this->withHeaders([ diff --git a/tests/Feature/DatabaseConfigHashEnvValueTest.php b/tests/Feature/DatabaseConfigHashEnvValueTest.php new file mode 100644 index 0000000000..6278c9809b --- /dev/null +++ b/tests/Feature/DatabaseConfigHashEnvValueTest.php @@ -0,0 +1,46 @@ +create(); + $server = Server::factory()->create(['team_id' => $team->id]); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $destination = $server->standaloneDockers()->firstOrFail(); + + $database = StandalonePostgresql::create([ + 'name' => 'config-hash-db', + 'postgres_user' => 'postgres', + 'postgres_password' => encrypt('password'), + 'postgres_db' => 'app', + 'image' => 'postgres:16-alpine', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + + $variable = $database->environment_variables()->create([ + 'key' => 'APP_SECRET', + 'value' => 'original-value', + ]); + + $database->isConfigurationChanged(save: true); + expect($database->refresh()->isConfigurationChanged())->toBeFalse(); + + $variable->update(['value' => 'changed-value']); + + expect($database->refresh()->isConfigurationChanged())->toBeTrue(); +}); diff --git a/tests/Feature/Security/ApiSensitiveFieldsTest.php b/tests/Feature/Security/ApiSensitiveFieldsTest.php new file mode 100644 index 0000000000..5420ba8c18 --- /dev/null +++ b/tests/Feature/Security/ApiSensitiveFieldsTest.php @@ -0,0 +1,1163 @@ + $team]); + $token = $user->createToken('sensitive-test', $abilities); + DB::table('personal_access_tokens')->where('id', $token->accessToken->id)->update([ + 'team_id' => $team->id, + ]); + + return $token->plainTextToken; +} + +function makeTeamUser(): array +{ + $team = Team::factory()->create(); + $user = User::factory()->create(); + $team->members()->attach($user->id, ['role' => 'owner']); + session(['currentTeam' => $team]); + + return [$team, $user]; +} + +beforeEach(function () { + InstanceSettings::query()->delete(); + $settings = new InstanceSettings; + $settings->id = 0; + $settings->save(); + + [$this->team, $this->user] = makeTeamUser(); + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->server->settings->forceFill([ + 'sentinel_token' => encrypt('super-secret-sentinel-token'), + 'sentinel_custom_url' => 'https://sentinel.internal', + 'logdrain_axiom_api_key' => encrypt('axiom-key-secret'), + 'logdrain_newrelic_license_key' => encrypt('newrelic-key-secret'), + 'logdrain_custom_config' => 'custom-config-data', + 'logdrain_custom_config_parser' => 'custom-parser-data', + ])->saveQuietly(); +}); + +describe('GET /api/v1/servers sensitive field gating', function () { + test('read token does not leak sentinel or logdrain fields', function () { + $token = makeApiToken($this->user, $this->team, ['read']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson('/api/v1/servers'); + + $response->assertStatus(200); + + $body = $response->getContent(); + expect($body)->not->toContain('sentinel_token'); + expect($body)->not->toContain('sentinel_custom_url'); + expect($body)->not->toContain('logdrain_axiom_api_key'); + expect($body)->not->toContain('logdrain_newrelic_license_key'); + expect($body)->not->toContain('logdrain_custom_config'); + }); + + test('read sensitive token sees sentinel and logdrain fields', function () { + $token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson('/api/v1/servers'); + + $response->assertStatus(200); + + $body = $response->getContent(); + expect($body)->toContain('sentinel_token'); + expect($body)->toContain('sentinel_custom_url'); + expect($body)->toContain('logdrain_axiom_api_key'); + }); + + test('root token sees sentinel and logdrain fields', function () { + $token = makeApiToken($this->user, $this->team, ['root']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson('/api/v1/servers'); + + $response->assertStatus(200); + + $body = $response->getContent(); + expect($body)->toContain('sentinel_token'); + }); + + test('read token does not leak sentinel or logdrain fields in server detail', function () { + $token = makeApiToken($this->user, $this->team, ['read']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson("/api/v1/servers/{$this->server->uuid}"); + + $response->assertStatus(200); + + $body = $response->getContent(); + expect($body)->not->toContain('sentinel_token'); + expect($body)->not->toContain('sentinel_custom_url'); + expect($body)->not->toContain('logdrain_axiom_api_key'); + expect($body)->not->toContain('logdrain_newrelic_license_key'); + expect($body)->not->toContain('logdrain_custom_config'); + }); + + test('read sensitive token sees sentinel and logdrain fields in server detail', function () { + $token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson("/api/v1/servers/{$this->server->uuid}"); + + $response->assertStatus(200); + + $body = $response->getContent(); + expect($body)->toContain('sentinel_token'); + expect($body)->toContain('sentinel_custom_url'); + expect($body)->toContain('logdrain_axiom_api_key'); + }); + + test('server resources response does not leak server secrets', function () { + $token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson("/api/v1/servers/{$this->server->uuid}/resources"); + + $response->assertStatus(200); + + $body = $response->getContent(); + expect($body)->not->toContain('sentinel_token'); + expect($body)->not->toContain('logdrain_axiom_api_key'); + expect($body)->not->toContain('logdrain_newrelic_license_key'); + }); +}); + +describe('GET /api/v1/security/keys sensitive field gating', function () { + beforeEach(function () { + PrivateKey::withoutEvents(function () { + PrivateKey::forceCreate([ + 'uuid' => 'private-key-sensitive-test', + 'name' => 'Sensitive key', + 'description' => 'test', + 'private_key' => 'super-secret-private-key', + 'is_git_related' => false, + 'team_id' => $this->team->id, + ]); + }); + }); + + test('read token does not leak private key material', function () { + $token = makeApiToken($this->user, $this->team, ['read']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson('/api/v1/security/keys/private-key-sensitive-test'); + + $response->assertStatus(200); + + expect($response->getContent())->not->toContain('"private_key":'); + }); + + test('read sensitive token sees private key material', function () { + $token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson('/api/v1/security/keys/private-key-sensitive-test'); + + $response->assertStatus(200); + + expect($response->getContent())->toContain('"private_key":'); + }); + + test('read token does not leak private key material in key list', function () { + $token = makeApiToken($this->user, $this->team, ['read']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson('/api/v1/security/keys'); + + $response->assertStatus(200); + + expect($response->getContent())->not->toContain('"private_key":'); + }); + + test('read sensitive token sees private key material in key list', function () { + $token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson('/api/v1/security/keys'); + + $response->assertStatus(200); + + expect($response->getContent())->toContain('"private_key":'); + }); +}); + +describe('GET /api/v1/deployments sensitive field gating', function () { + beforeEach(function () { + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + $destination = $this->server->standaloneDockers()->firstOrFail(); + + $this->application = Application::create([ + 'name' => 'deployment-sensitive-test-app', + 'git_repository' => 'https://github.com/test/test', + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', + 'environment_id' => $this->environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + + ApplicationDeploymentQueue::create([ + 'application_id' => $this->application->id, + 'deployment_uuid' => 'deployment-sensitive-test', + 'pull_request_id' => 0, + 'status' => 'in_progress', + 'logs' => '[{"output":"super-secret-deployment-log"}]', + 'server_id' => $this->server->id, + 'application_name' => $this->application->name, + 'server_name' => $this->server->name, + ]); + }); + + test('read token does not leak deployment logs', function () { + $token = makeApiToken($this->user, $this->team, ['read']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson('/api/v1/deployments/deployment-sensitive-test'); + + $response->assertStatus(200); + + expect($response->getContent())->not->toContain('"logs":'); + }); + + test('read sensitive token sees deployment logs', function () { + $token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson('/api/v1/deployments/deployment-sensitive-test'); + + $response->assertStatus(200); + + expect($response->getContent())->toContain('"logs":'); + }); + + test('read token does not leak deployment logs in deployment list', function () { + $token = makeApiToken($this->user, $this->team, ['read']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson('/api/v1/deployments'); + + $response->assertStatus(200); + + expect($response->getContent())->not->toContain('"logs":'); + }); + + test('read sensitive token sees deployment logs in deployment list', function () { + $token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson('/api/v1/deployments'); + + $response->assertStatus(200); + + expect($response->getContent())->toContain('"logs":'); + }); + + test('read token does not leak deployment logs in application deployment history', function () { + $token = makeApiToken($this->user, $this->team, ['read']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson("/api/v1/deployments/applications/{$this->application->uuid}"); + + $response->assertStatus(200); + + expect($response->getContent())->not->toContain('"logs":'); + }); + + test('read sensitive token sees deployment logs in application deployment history', function () { + $token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson("/api/v1/deployments/applications/{$this->application->uuid}"); + + $response->assertStatus(200); + + expect($response->getContent())->toContain('"logs":'); + }); +}); + +describe('GET /api/v1/applications nested-relation scrubbing', function () { + beforeEach(function () { + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + $destination = $this->server->standaloneDockers()->firstOrFail(); + + $this->application = Application::create([ + 'name' => 'sensitive-test-app', + 'git_repository' => 'https://github.com/test/test', + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', + 'environment_id' => $this->environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + }); + + test('read token does not leak sentinel_token via destination.server.settings', function () { + $token = makeApiToken($this->user, $this->team, ['read']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson('/api/v1/applications'); + + $response->assertStatus(200); + + $body = $response->getContent(); + expect($body)->not->toContain('"sentinel_token":'); + expect($body)->not->toContain('"sentinel_custom_url":'); + expect($body)->not->toContain('"logdrain_axiom_api_key":'); + }); + + test('read-sensitive token sees nested sentinel_token via destination.server.settings', function () { + $token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson('/api/v1/applications'); + + $response->assertStatus(200); + + $body = $response->getContent(); + expect($body)->toContain('"sentinel_token":'); + expect($body)->toContain('"sentinel_custom_url":'); + }); + + test('read token does not leak application detail sensitive fields', function () { + $this->application->forceFill([ + 'manual_webhook_secret_github' => 'super-secret-github-webhook', + 'http_basic_auth_password' => 'super-secret-basic-password', + ])->save(); + + $token = makeApiToken($this->user, $this->team, ['read']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson("/api/v1/applications/{$this->application->uuid}"); + + $response->assertStatus(200); + + $body = $response->getContent(); + expect($body)->not->toContain('"manual_webhook_secret_github":') + ->and($body)->not->toContain('"http_basic_auth_password":') + ->and($body)->not->toContain('"sentinel_token":'); + }); + + test('read sensitive token sees application detail sensitive fields', function () { + $this->application->forceFill([ + 'manual_webhook_secret_github' => 'super-secret-github-webhook', + 'http_basic_auth_password' => 'super-secret-basic-password', + ])->save(); + + $token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson("/api/v1/applications/{$this->application->uuid}"); + + $response->assertStatus(200); + + $body = $response->getContent(); + expect($body)->toContain('"manual_webhook_secret_github":') + ->and($body)->toContain('"http_basic_auth_password":') + ->and($body)->toContain('"sentinel_token":'); + }); + + test('application env responses hide values for read tokens and reveal them for sensitive tokens', function () { + $this->application->environment_variables()->create([ + 'key' => 'APP_SECRET', + 'value' => 'super-secret-app-env', + ]); + + $readToken = makeApiToken($this->user, $this->team, ['read']); + $sensitiveToken = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']); + + $readResponse = $this->withHeaders([ + 'Authorization' => 'Bearer '.$readToken, + ])->getJson("/api/v1/applications/{$this->application->uuid}/envs"); + + auth()->forgetGuards(); + + $sensitiveResponse = $this->withHeaders([ + 'Authorization' => 'Bearer '.$sensitiveToken, + ])->getJson("/api/v1/applications/{$this->application->uuid}/envs"); + + $readResponse->assertStatus(200); + $sensitiveResponse->assertStatus(200); + + expect($readResponse->getContent())->not->toContain('"value":') + ->and($readResponse->getContent())->not->toContain('"real_value":') + ->and($sensitiveResponse->getContent())->toContain('"value":') + ->and($sensitiveResponse->getContent())->toContain('"real_value":'); + }); + + test('application create env response does not include secret values', function () { + $writeToken = makeApiToken($this->user, $this->team, ['write']); + $sensitiveToken = makeApiToken($this->user, $this->team, ['write', 'read:sensitive']); + + $writeResponse = $this->withHeaders([ + 'Authorization' => 'Bearer '.$writeToken, + ])->postJson("/api/v1/applications/{$this->application->uuid}/envs", [ + 'key' => 'APP_CREATE_SECRET', + 'value' => 'super-secret-app-create-env', + ]); + + auth()->forgetGuards(); + + $sensitiveResponse = $this->withHeaders([ + 'Authorization' => 'Bearer '.$sensitiveToken, + ])->postJson("/api/v1/applications/{$this->application->uuid}/envs", [ + 'key' => 'APP_CREATE_SENSITIVE_SECRET', + 'value' => 'super-secret-app-create-sensitive-env', + ]); + + $writeResponse->assertStatus(201); + $sensitiveResponse->assertStatus(201); + + expect($writeResponse->getContent())->not->toContain('"value":') + ->and($writeResponse->getContent())->not->toContain('"real_value":') + ->and($sensitiveResponse->getContent())->not->toContain('"value":') + ->and($sensitiveResponse->getContent())->not->toContain('"real_value":'); + }); + + test('application update env response hides values for write tokens and reveals them for sensitive tokens', function () { + $this->application->environment_variables()->create([ + 'key' => 'APP_UPDATE_SECRET', + 'value' => 'old-app-update-secret', + ]); + + $writeToken = makeApiToken($this->user, $this->team, ['write']); + $sensitiveToken = makeApiToken($this->user, $this->team, ['write', 'read:sensitive']); + + $writeResponse = $this->withHeaders([ + 'Authorization' => 'Bearer '.$writeToken, + ])->patchJson("/api/v1/applications/{$this->application->uuid}/envs", [ + 'key' => 'APP_UPDATE_SECRET', + 'value' => 'hidden-app-update-secret', + 'is_multiline' => false, + 'is_shown_once' => false, + ]); + + auth()->forgetGuards(); + + $sensitiveResponse = $this->withHeaders([ + 'Authorization' => 'Bearer '.$sensitiveToken, + ])->patchJson("/api/v1/applications/{$this->application->uuid}/envs", [ + 'key' => 'APP_UPDATE_SECRET', + 'value' => 'visible-app-update-secret', + 'is_multiline' => false, + 'is_shown_once' => false, + ]); + + $writeResponse->assertStatus(201); + $sensitiveResponse->assertStatus(201); + + expect($writeResponse->getContent())->not->toContain('"value":') + ->and($writeResponse->getContent())->not->toContain('"real_value":') + ->and($sensitiveResponse->getContent())->toContain('"value":') + ->and($sensitiveResponse->getContent())->toContain('"real_value":'); + }); + + test('application bulk env response hides values for write tokens and reveals them for sensitive tokens', function () { + $writeToken = makeApiToken($this->user, $this->team, ['write']); + $sensitiveToken = makeApiToken($this->user, $this->team, ['write', 'read:sensitive']); + + $writeResponse = $this->withHeaders([ + 'Authorization' => 'Bearer '.$writeToken, + ])->patchJson("/api/v1/applications/{$this->application->uuid}/envs/bulk", [ + 'data' => [[ + 'key' => 'APP_BULK_SECRET', + 'value' => 'hidden-app-bulk-secret', + ]], + ]); + + auth()->forgetGuards(); + + $sensitiveResponse = $this->withHeaders([ + 'Authorization' => 'Bearer '.$sensitiveToken, + ])->patchJson("/api/v1/applications/{$this->application->uuid}/envs/bulk", [ + 'data' => [[ + 'key' => 'APP_BULK_SENSITIVE_SECRET', + 'value' => 'visible-app-bulk-secret', + ]], + ]); + + $writeResponse->assertStatus(201); + $sensitiveResponse->assertStatus(201); + + expect($writeResponse->getContent())->not->toContain('"value":') + ->and($writeResponse->getContent())->not->toContain('"real_value":') + ->and($sensitiveResponse->getContent())->toContain('"value":') + ->and($sensitiveResponse->getContent())->toContain('"real_value":'); + }); +}); + +describe('GET /api/v1/databases sensitive field gating', function () { + beforeEach(function () { + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + $destination = $this->server->standaloneDockers()->firstOrFail(); + + $this->database = StandalonePostgresql::create([ + 'name' => 'sensitive-db', + 'description' => 'test', + 'postgres_user' => 'postgres', + 'postgres_password' => encrypt('super-secret-db-password'), + 'postgres_db' => 'app', + 'image' => 'postgres:16-alpine', + 'environment_id' => $this->environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + }); + + test('read token database list does not lazy load nested server relations', function () { + $token = makeApiToken($this->user, $this->team, ['read']); + + $preventedLazyLoading = Model::preventsLazyLoading(); + Model::preventLazyLoading(); + + try { + $response = $this->withoutExceptionHandling()->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson('/api/v1/databases'); + } finally { + Model::preventLazyLoading($preventedLazyLoading); + } + + $response->assertStatus(200); + }); + + test('read token does not leak postgres_password or db urls', function () { + $token = makeApiToken($this->user, $this->team, ['read']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson('/api/v1/databases'); + + $response->assertStatus(200); + + $body = $response->getContent(); + expect($body)->not->toContain('postgres_password'); + expect($body)->not->toContain('internal_db_url'); + expect($body)->not->toContain('external_db_url'); + }); + + test('read sensitive token sees postgres_password and nested sentinel_token', function () { + $token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson('/api/v1/databases'); + + $response->assertStatus(200); + + $body = $response->getContent(); + expect($body)->toContain('"postgres_password":'); + expect($body)->toContain('"internal_db_url":'); + expect($body)->toContain('"sentinel_token":'); + }); + + test('read token does not leak database detail sensitive fields', function () { + $token = makeApiToken($this->user, $this->team, ['read']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson("/api/v1/databases/{$this->database->uuid}"); + + $response->assertStatus(200); + + $body = $response->getContent(); + expect($body)->not->toContain('"postgres_password":') + ->and($body)->not->toContain('"internal_db_url":') + ->and($body)->not->toContain('"external_db_url":') + ->and($body)->not->toContain('"sentinel_token":'); + }); + + test('read sensitive token sees database detail sensitive fields', function () { + $token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson("/api/v1/databases/{$this->database->uuid}"); + + $response->assertStatus(200); + + $body = $response->getContent(); + expect($body)->toContain('"postgres_password":') + ->and($body)->toContain('"internal_db_url":') + ->and($body)->toContain('"sentinel_token":'); + }); + + test('database env responses hide values for read tokens and reveal them for sensitive tokens', function () { + $this->database->environment_variables()->create([ + 'key' => 'DB_SECRET', + 'value' => 'super-secret-db-env', + ]); + + $readToken = makeApiToken($this->user, $this->team, ['read']); + $sensitiveToken = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']); + + $readResponse = $this->withHeaders([ + 'Authorization' => 'Bearer '.$readToken, + ])->getJson("/api/v1/databases/{$this->database->uuid}/envs"); + + auth()->forgetGuards(); + + $sensitiveResponse = $this->withHeaders([ + 'Authorization' => 'Bearer '.$sensitiveToken, + ])->getJson("/api/v1/databases/{$this->database->uuid}/envs"); + + $readResponse->assertStatus(200); + $sensitiveResponse->assertStatus(200); + + expect($readResponse->getContent())->not->toContain('"value":') + ->and($readResponse->getContent())->not->toContain('"real_value":') + ->and($sensitiveResponse->getContent())->toContain('"value":') + ->and($sensitiveResponse->getContent())->toContain('"real_value":'); + }); + + test('database create env response hides values for write tokens and reveals them for sensitive tokens', function () { + $writeToken = makeApiToken($this->user, $this->team, ['write']); + $sensitiveToken = makeApiToken($this->user, $this->team, ['write', 'read:sensitive']); + + $writeResponse = $this->withHeaders([ + 'Authorization' => 'Bearer '.$writeToken, + ])->postJson("/api/v1/databases/{$this->database->uuid}/envs", [ + 'key' => 'DB_CREATE_SECRET', + 'value' => 'hidden-db-create-secret', + ]); + + auth()->forgetGuards(); + + $sensitiveResponse = $this->withHeaders([ + 'Authorization' => 'Bearer '.$sensitiveToken, + ])->postJson("/api/v1/databases/{$this->database->uuid}/envs", [ + 'key' => 'DB_CREATE_SENSITIVE_SECRET', + 'value' => 'visible-db-create-secret', + ]); + + $writeResponse->assertStatus(201); + $sensitiveResponse->assertStatus(201); + + expect($writeResponse->getContent())->not->toContain('"value":') + ->and($writeResponse->getContent())->not->toContain('"real_value":') + ->and($sensitiveResponse->getContent())->toContain('"value":') + ->and($sensitiveResponse->getContent())->toContain('"real_value":'); + }); + + test('database update env response hides values for write tokens and reveals them for sensitive tokens', function () { + $this->database->environment_variables()->create([ + 'key' => 'DB_UPDATE_SECRET', + 'value' => 'old-db-update-secret', + ]); + + $writeToken = makeApiToken($this->user, $this->team, ['write']); + $sensitiveToken = makeApiToken($this->user, $this->team, ['write', 'read:sensitive']); + + $writeResponse = $this->withHeaders([ + 'Authorization' => 'Bearer '.$writeToken, + ])->patchJson("/api/v1/databases/{$this->database->uuid}/envs", [ + 'key' => 'DB_UPDATE_SECRET', + 'value' => 'hidden-db-update-secret', + ]); + + auth()->forgetGuards(); + + $sensitiveResponse = $this->withHeaders([ + 'Authorization' => 'Bearer '.$sensitiveToken, + ])->patchJson("/api/v1/databases/{$this->database->uuid}/envs", [ + 'key' => 'DB_UPDATE_SECRET', + 'value' => 'visible-db-update-secret', + ]); + + $writeResponse->assertStatus(201); + $sensitiveResponse->assertStatus(201); + + expect($writeResponse->getContent())->not->toContain('"value":') + ->and($writeResponse->getContent())->not->toContain('"real_value":') + ->and($sensitiveResponse->getContent())->toContain('"value":') + ->and($sensitiveResponse->getContent())->toContain('"real_value":'); + }); + + test('database bulk env response hides values for write tokens and reveals them for sensitive tokens', function () { + $writeToken = makeApiToken($this->user, $this->team, ['write']); + $sensitiveToken = makeApiToken($this->user, $this->team, ['write', 'read:sensitive']); + + $writeResponse = $this->withHeaders([ + 'Authorization' => 'Bearer '.$writeToken, + ])->patchJson("/api/v1/databases/{$this->database->uuid}/envs/bulk", [ + 'data' => [[ + 'key' => 'DB_BULK_SECRET', + 'value' => 'hidden-db-bulk-secret', + ]], + ]); + + auth()->forgetGuards(); + + $sensitiveResponse = $this->withHeaders([ + 'Authorization' => 'Bearer '.$sensitiveToken, + ])->patchJson("/api/v1/databases/{$this->database->uuid}/envs/bulk", [ + 'data' => [[ + 'key' => 'DB_BULK_SENSITIVE_SECRET', + 'value' => 'visible-db-bulk-secret', + ]], + ]); + + $writeResponse->assertStatus(201); + $sensitiveResponse->assertStatus(201); + + expect($writeResponse->getContent())->not->toContain('"value":') + ->and($writeResponse->getContent())->not->toContain('"real_value":') + ->and($sensitiveResponse->getContent())->toContain('"value":') + ->and($sensitiveResponse->getContent())->toContain('"real_value":'); + }); + + test('project database list can eager load nested destination server settings', function () { + $databases = $this->project->databases(['destination.server.settings']); + $database = $databases->firstWhere('id', $this->database->id); + + expect($database)->not->toBeNull() + ->and($database->relationLoaded('destination'))->toBeTrue() + ->and($database->destination->relationLoaded('server'))->toBeTrue() + ->and($database->destination->server->relationLoaded('settings'))->toBeTrue(); + }); +}); + +describe('GET /api/v1/services sensitive field gating', function () { + beforeEach(function () { + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + + $destination = $this->server->standaloneDockers()->firstOrFail(); + $this->service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + }); + + test('read token does not leak service or nested server sensitive fields', function () { + $token = makeApiToken($this->user, $this->team, ['read']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson('/api/v1/services'); + + $response->assertStatus(200); + + $body = $response->getContent(); + expect($body)->not->toContain('"docker_compose_raw":') + ->and($body)->not->toContain('"sentinel_token":') + ->and($body)->not->toContain('"sentinel_custom_url":'); + }); + + test('read sensitive token sees service and nested server sensitive fields', function () { + $token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson('/api/v1/services'); + + $response->assertStatus(200); + + $body = $response->getContent(); + expect($body)->toContain('"docker_compose_raw":') + ->and($body)->toContain('"sentinel_token":') + ->and($body)->toContain('"sentinel_custom_url":'); + }); + + test('read token does not leak service detail sensitive fields', function () { + $this->service->forceFill([ + 'docker_compose_raw' => 'services: secret', + 'docker_compose' => 'services: rendered', + ])->save(); + + $token = makeApiToken($this->user, $this->team, ['read']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson("/api/v1/services/{$this->service->uuid}"); + + $response->assertStatus(200); + + $body = $response->getContent(); + expect($body)->not->toContain('"docker_compose_raw":') + ->and($body)->not->toContain('"docker_compose":') + ->and($body)->not->toContain('"sentinel_token":'); + }); + + test('read sensitive token sees service detail sensitive fields', function () { + $this->service->forceFill([ + 'docker_compose_raw' => 'services: secret', + 'docker_compose' => 'services: rendered', + ])->save(); + + $token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson("/api/v1/services/{$this->service->uuid}"); + + $response->assertStatus(200); + + $body = $response->getContent(); + expect($body)->toContain('"docker_compose_raw":') + ->and($body)->toContain('"docker_compose":') + ->and($body)->toContain('"sentinel_token":'); + }); + + test('service env responses hide values for read tokens and reveal them for sensitive tokens', function () { + $this->service->environment_variables()->create([ + 'key' => 'SERVICE_SECRET', + 'value' => 'super-secret-service-env', + ]); + + $readToken = makeApiToken($this->user, $this->team, ['read']); + $sensitiveToken = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']); + + $readResponse = $this->withHeaders([ + 'Authorization' => 'Bearer '.$readToken, + ])->getJson("/api/v1/services/{$this->service->uuid}/envs"); + + auth()->forgetGuards(); + + $sensitiveResponse = $this->withHeaders([ + 'Authorization' => 'Bearer '.$sensitiveToken, + ])->getJson("/api/v1/services/{$this->service->uuid}/envs"); + + $readResponse->assertStatus(200); + $sensitiveResponse->assertStatus(200); + + expect($readResponse->getContent())->not->toContain('"value":') + ->and($readResponse->getContent())->not->toContain('"real_value":') + ->and($sensitiveResponse->getContent())->toContain('"value":') + ->and($sensitiveResponse->getContent())->toContain('"real_value":'); + }); + + test('service create env response hides values for write tokens and reveals them for sensitive tokens', function () { + $writeToken = makeApiToken($this->user, $this->team, ['write']); + $sensitiveToken = makeApiToken($this->user, $this->team, ['write', 'read:sensitive']); + + $writeResponse = $this->withHeaders([ + 'Authorization' => 'Bearer '.$writeToken, + ])->postJson("/api/v1/services/{$this->service->uuid}/envs", [ + 'key' => 'SERVICE_CREATE_SECRET', + 'value' => 'hidden-service-create-secret', + ]); + + auth()->forgetGuards(); + + $sensitiveResponse = $this->withHeaders([ + 'Authorization' => 'Bearer '.$sensitiveToken, + ])->postJson("/api/v1/services/{$this->service->uuid}/envs", [ + 'key' => 'SERVICE_CREATE_SENSITIVE_SECRET', + 'value' => 'visible-service-create-secret', + ]); + + $writeResponse->assertStatus(201); + $sensitiveResponse->assertStatus(201); + + expect($writeResponse->getContent())->not->toContain('"value":') + ->and($writeResponse->getContent())->not->toContain('"real_value":') + ->and($sensitiveResponse->getContent())->toContain('"value":') + ->and($sensitiveResponse->getContent())->toContain('"real_value":'); + }); + + test('service update env response hides values for write tokens and reveals them for sensitive tokens', function () { + $this->service->environment_variables()->create([ + 'key' => 'SERVICE_UPDATE_SECRET', + 'value' => 'old-service-update-secret', + ]); + + $writeToken = makeApiToken($this->user, $this->team, ['write']); + $sensitiveToken = makeApiToken($this->user, $this->team, ['write', 'read:sensitive']); + + $writeResponse = $this->withHeaders([ + 'Authorization' => 'Bearer '.$writeToken, + ])->patchJson("/api/v1/services/{$this->service->uuid}/envs", [ + 'key' => 'SERVICE_UPDATE_SECRET', + 'value' => 'hidden-service-update-secret', + ]); + + auth()->forgetGuards(); + + $sensitiveResponse = $this->withHeaders([ + 'Authorization' => 'Bearer '.$sensitiveToken, + ])->patchJson("/api/v1/services/{$this->service->uuid}/envs", [ + 'key' => 'SERVICE_UPDATE_SECRET', + 'value' => 'visible-service-update-secret', + ]); + + $writeResponse->assertStatus(201); + $sensitiveResponse->assertStatus(201); + + expect($writeResponse->getContent())->not->toContain('"value":') + ->and($writeResponse->getContent())->not->toContain('"real_value":') + ->and($sensitiveResponse->getContent())->toContain('"value":') + ->and($sensitiveResponse->getContent())->toContain('"real_value":'); + }); + + test('service bulk env response hides values for write tokens and reveals them for sensitive tokens', function () { + $writeToken = makeApiToken($this->user, $this->team, ['write']); + $sensitiveToken = makeApiToken($this->user, $this->team, ['write', 'read:sensitive']); + + $writeResponse = $this->withHeaders([ + 'Authorization' => 'Bearer '.$writeToken, + ])->patchJson("/api/v1/services/{$this->service->uuid}/envs/bulk", [ + 'data' => [[ + 'key' => 'SERVICE_BULK_SECRET', + 'value' => 'hidden-service-bulk-secret', + ]], + ]); + + auth()->forgetGuards(); + + $sensitiveResponse = $this->withHeaders([ + 'Authorization' => 'Bearer '.$sensitiveToken, + ])->patchJson("/api/v1/services/{$this->service->uuid}/envs/bulk", [ + 'data' => [[ + 'key' => 'SERVICE_BULK_SENSITIVE_SECRET', + 'value' => 'visible-service-bulk-secret', + ]], + ]); + + $writeResponse->assertStatus(201); + $sensitiveResponse->assertStatus(201); + + expect($writeResponse->getContent())->not->toContain('"value":') + ->and($writeResponse->getContent())->not->toContain('"real_value":') + ->and($sensitiveResponse->getContent())->toContain('"value":') + ->and($sensitiveResponse->getContent())->toContain('"real_value":'); + }); + + test('read sensitive service list eager loads nested server settings once', function () { + $secondServer = Server::factory()->create(['team_id' => $this->team->id]); + $secondDestination = $secondServer->standaloneDockers()->firstOrFail(); + + Service::factory()->create([ + 'server_id' => $secondServer->id, + 'destination_id' => $secondDestination->id, + 'destination_type' => $secondDestination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + + $token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']); + $serverSettingsQueries = collect(); + + DB::listen(function ($query) use ($serverSettingsQueries) { + if (str_contains($query->sql, 'from "server_settings"')) { + $serverSettingsQueries->push($query->sql); + } + }); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson('/api/v1/services'); + + $response->assertStatus(200); + + expect($serverSettingsQueries->contains(fn (string $sql) => str_contains($sql, '"server_settings"."server_id" in')))->toBeTrue(); + }); +}); + +describe('GET /api/v1/resources sensitive field gating', function () { + beforeEach(function () { + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + $destination = $this->server->standaloneDockers()->firstOrFail(); + + $this->database = StandalonePostgresql::create([ + 'name' => 'resources-sensitive-db', + 'postgres_user' => 'postgres', + 'postgres_password' => encrypt('super-secret-db-password'), + 'postgres_db' => 'app', + 'image' => 'postgres:16-alpine', + 'environment_id' => $this->environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + + $this->application = Application::create([ + 'name' => 'resources-sensitive-app', + 'git_repository' => 'https://github.com/test/test', + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', + 'http_basic_auth_password' => 'basic-auth-secret', + 'environment_id' => $this->environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + }); + + test('read token does not leak database or application secrets', function () { + $token = makeApiToken($this->user, $this->team, ['read']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson('/api/v1/resources'); + + $response->assertStatus(200); + + $body = $response->getContent(); + expect($body)->not->toContain('postgres_password'); + expect($body)->not->toContain('internal_db_url'); + expect($body)->not->toContain('external_db_url'); + expect($body)->not->toContain('http_basic_auth_password'); + }); + + test('read sensitive token sees database and application secrets', function () { + $token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson('/api/v1/resources'); + + $response->assertStatus(200); + + $body = $response->getContent(); + expect($body)->toContain('"postgres_password":'); + expect($body)->toContain('"internal_db_url":'); + expect($body)->toContain('"http_basic_auth_password":'); + }); + + test('root token sees database secrets', function () { + $token = makeApiToken($this->user, $this->team, ['root']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson('/api/v1/resources'); + + $response->assertStatus(200); + + expect($response->getContent())->toContain('"postgres_password":'); + }); +}); + +describe('GET /api/v1/projects/{uuid}/{environment} sensitive field gating', function () { + beforeEach(function () { + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + $destination = $this->server->standaloneDockers()->firstOrFail(); + + $this->database = StandalonePostgresql::create([ + 'name' => 'environment-sensitive-db', + 'postgres_user' => 'postgres', + 'postgres_password' => encrypt('super-secret-db-password'), + 'postgres_db' => 'app', + 'image' => 'postgres:16-alpine', + 'environment_id' => $this->environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + + $this->application = Application::create([ + 'name' => 'environment-sensitive-app', + 'git_repository' => 'https://github.com/test/test', + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', + 'http_basic_auth_password' => 'basic-auth-secret', + 'environment_id' => $this->environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + }); + + test('read token does not leak database or application secrets', function () { + $token = makeApiToken($this->user, $this->team, ['read']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson("/api/v1/projects/{$this->project->uuid}/{$this->environment->name}"); + + $response->assertStatus(200); + + $body = $response->getContent(); + expect($body)->not->toContain('postgres_password'); + expect($body)->not->toContain('internal_db_url'); + expect($body)->not->toContain('external_db_url'); + expect($body)->not->toContain('http_basic_auth_password'); + }); + + test('read sensitive token sees database and application secrets', function () { + $token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson("/api/v1/projects/{$this->project->uuid}/{$this->environment->name}"); + + $response->assertStatus(200); + + $body = $response->getContent(); + expect($body)->toContain('"postgres_password":'); + expect($body)->toContain('"internal_db_url":'); + expect($body)->toContain('"http_basic_auth_password":'); + }); +}); + +describe('instance admin team (id 0) sensitive gating', function () { + test('read sensitive token owned by team 0 sees sensitive fields', function () { + $team = Team::factory()->create(['id' => 0]); + $user = User::factory()->create(); + $team->members()->attach($user->id, ['role' => 'owner']); + session(['currentTeam' => $team]); + + $server = Server::factory()->create(['team_id' => $team->id]); + $server->settings->forceFill([ + 'sentinel_token' => encrypt('team-zero-sentinel-token'), + ])->saveQuietly(); + + $token = makeApiToken($user, $team, ['read', 'read:sensitive']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson('/api/v1/servers'); + + $response->assertStatus(200); + + expect($response->getContent())->toContain('"sentinel_token":'); + }); +}); diff --git a/tests/Feature/StorageApiTest.php b/tests/Feature/StorageApiTest.php index 92d92f860f..2f5a823749 100644 --- a/tests/Feature/StorageApiTest.php +++ b/tests/Feature/StorageApiTest.php @@ -46,8 +46,15 @@ function createTestApplication($context): Application { - return Application::factory()->create([ + return Application::create([ + 'name' => 'test-storage-app', + 'git_repository' => 'https://github.com/test/test', + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', 'environment_id' => $context->environment->id, + 'destination_id' => $context->destination->id, + 'destination_type' => $context->destination->getMorphClass(), ]); } @@ -65,6 +72,19 @@ function createTestDatabase($context): StandalonePostgresql ]); } +function createStorageApiToken($context, array $abilities): string +{ + $plainTextToken = Str::random(40); + $token = $context->user->tokens()->create([ + 'name' => 'storage-test-token', + 'token' => hash('sha256', $plainTextToken), + 'abilities' => $abilities, + 'team_id' => $context->team->id, + ]); + + return $token->getKey().'|'.$plainTextToken; +} + function createTestServiceApplication($context): array { $service = Service::factory()->create([ @@ -114,6 +134,39 @@ function createTestServiceApplication($context): array $response->assertStatus(404); }); + + test('hides file storage content for read tokens and reveals it for sensitive tokens', function () { + $app = createTestApplication($this); + + LocalFileVolume::create([ + 'fs_path' => '/tmp/config.json', + 'mount_path' => '/app/config.json', + 'content' => '{"secret": "hidden"}', + 'is_directory' => false, + 'resource_id' => $app->id, + 'resource_type' => $app->getMorphClass(), + ]); + + $readToken = createStorageApiToken($this, ['read']); + $sensitiveToken = createStorageApiToken($this, ['read', 'read:sensitive']); + + $readResponse = $this->withHeaders([ + 'Authorization' => 'Bearer '.$readToken, + ])->getJson("/api/v1/applications/{$app->uuid}/storages"); + + auth()->forgetGuards(); + + $sensitiveResponse = $this->withHeaders([ + 'Authorization' => 'Bearer '.$sensitiveToken, + ])->getJson("/api/v1/applications/{$app->uuid}/storages"); + + $readResponse->assertSuccessful(); + $sensitiveResponse->assertSuccessful(); + + expect($readResponse->getContent())->not->toContain('"content":') + ->and($sensitiveResponse->getContent())->toContain('"content":') + ->and($sensitiveResponse->getContent())->toContain('hidden'); + }); }); describe('POST /api/v1/applications/{uuid}/storages', function () { @@ -214,6 +267,39 @@ function createTestServiceApplication($context): array ->exists())->toBeFalse(); }); + test('hides created file storage content for write tokens and reveals it for sensitive tokens', function () { + $app = createTestApplication($this); + $writeToken = createStorageApiToken($this, ['write']); + $sensitiveToken = createStorageApiToken($this, ['write', 'read:sensitive']); + + $writeResponse = $this->withHeaders([ + 'Authorization' => 'Bearer '.$writeToken, + 'Content-Type' => 'application/json', + ])->postJson("/api/v1/applications/{$app->uuid}/storages", [ + 'type' => 'file', + 'mount_path' => '/app/hidden.json', + 'content' => '{"secret": "write-hidden"}', + ]); + + auth()->forgetGuards(); + + $sensitiveResponse = $this->withHeaders([ + 'Authorization' => 'Bearer '.$sensitiveToken, + 'Content-Type' => 'application/json', + ])->postJson("/api/v1/applications/{$app->uuid}/storages", [ + 'type' => 'file', + 'mount_path' => '/app/visible.json', + 'content' => '{"secret": "write-visible"}', + ]); + + $writeResponse->assertCreated(); + $sensitiveResponse->assertCreated(); + + expect($writeResponse->getContent())->not->toContain('"content":') + ->and($sensitiveResponse->getContent())->toContain('"content":') + ->and($sensitiveResponse->getContent())->toContain('write-visible'); + }); + test('rejects persistent storage without name', function () { $app = createTestApplication($this); diff --git a/tests/Unit/Models/SensitiveFieldsHiddenTest.php b/tests/Unit/Models/SensitiveFieldsHiddenTest.php new file mode 100644 index 0000000000..3faece684a --- /dev/null +++ b/tests/Unit/Models/SensitiveFieldsHiddenTest.php @@ -0,0 +1,322 @@ +getHidden(); + + expect($hidden)->toContain( + 'sentinel_token', + 'sentinel_custom_url', + 'logdrain_newrelic_license_key', + 'logdrain_axiom_api_key', + 'logdrain_custom_config', + 'logdrain_custom_config_parser', + ); + }); + + test('Server hides logdrain api keys', function () { + $hidden = (new Server)->getHidden(); + + expect($hidden)->toContain( + 'logdrain_axiom_api_key', + 'logdrain_newrelic_license_key', + ); + }); + + test('Application hides webhook secrets, dockerfile, compose, and labels', function () { + $hidden = (new Application)->getHidden(); + + expect($hidden)->toContain( + 'http_basic_auth_password', + 'manual_webhook_secret_github', + 'manual_webhook_secret_gitlab', + 'manual_webhook_secret_bitbucket', + 'manual_webhook_secret_gitea', + 'dockerfile', + 'docker_compose', + 'docker_compose_raw', + 'custom_labels', + ); + }); + + test('EnvironmentVariable hides value and real_value', function () { + $hidden = (new EnvironmentVariable)->getHidden(); + + expect($hidden)->toContain('value', 'real_value'); + }); + + test('SharedEnvironmentVariable hides value', function () { + $hidden = (new SharedEnvironmentVariable)->getHidden(); + + expect($hidden)->toContain('value'); + }); + + test('Service hides docker_compose and docker_compose_raw', function () { + $hidden = (new Service)->getHidden(); + + expect($hidden)->toContain('docker_compose', 'docker_compose_raw'); + }); + + test('ApplicationDeploymentQueue hides logs', function () { + $hidden = (new ApplicationDeploymentQueue)->getHidden(); + + expect($hidden)->toContain('logs'); + }); + + test('LocalFileVolume hides file content', function () { + $hidden = (new LocalFileVolume)->getHidden(); + + expect($hidden)->toContain('content'); + }); + + test('PrivateKey hides private key material', function () { + $hidden = (new PrivateKey)->getHidden(); + + expect($hidden)->toContain('private_key'); + }); + + test('CloudProviderToken hides provider token', function () { + $hidden = (new CloudProviderToken)->getHidden(); + + expect($hidden)->toContain('token'); + }); + + test('CloudInitScript hides script content', function () { + $hidden = (new CloudInitScript)->getHidden(); + + expect($hidden)->toContain('script'); + }); + + test('S3Storage hides credentials', function () { + $hidden = (new S3Storage)->getHidden(); + + expect($hidden)->toContain('key', 'secret'); + }); + + test('OauthSetting hides client secret', function () { + $hidden = (new OauthSetting)->getHidden(); + + expect($hidden)->toContain('client_secret'); + }); + + test('SslCertificate hides private key', function () { + $hidden = (new SslCertificate)->getHidden(); + + expect($hidden)->toContain('ssl_private_key'); + }); + + test('EmailNotificationSettings hides SMTP and resend secrets', function () { + $hidden = (new EmailNotificationSettings)->getHidden(); + + expect($hidden)->toContain( + 'smtp_from_address', + 'smtp_from_name', + 'smtp_recipients', + 'smtp_host', + 'smtp_username', + 'smtp_password', + 'resend_api_key', + ); + }); + + test('InstanceSettings hides instance notification secrets', function () { + $hidden = (new InstanceSettings)->getHidden(); + + expect($hidden)->toContain( + 'smtp_from_address', + 'smtp_from_name', + 'smtp_recipients', + 'smtp_host', + 'smtp_username', + 'smtp_password', + 'resend_api_key', + 'sentinel_token', + ); + }); + + test('Webhook-style notification settings hide delivery endpoints', function () { + expect((new DiscordNotificationSettings)->getHidden())->toContain('discord_webhook_url'); + expect((new SlackNotificationSettings)->getHidden())->toContain('slack_webhook_url'); + expect((new WebhookNotificationSettings)->getHidden())->toContain('webhook_url'); + }); + + test('PushoverNotificationSettings hides credentials', function () { + $hidden = (new PushoverNotificationSettings)->getHidden(); + + expect($hidden)->toContain('pushover_user_key', 'pushover_api_token'); + }); + + test('TelegramNotificationSettings hides bot, chat, and thread identifiers', function () { + $hidden = (new TelegramNotificationSettings)->getHidden(); + + expect($hidden)->toContain( + 'telegram_token', + 'telegram_chat_id', + 'telegram_notifications_deployment_success_thread_id', + 'telegram_notifications_deployment_failure_thread_id', + 'telegram_notifications_status_change_thread_id', + 'telegram_notifications_backup_success_thread_id', + 'telegram_notifications_backup_failure_thread_id', + 'telegram_notifications_scheduled_task_success_thread_id', + 'telegram_notifications_scheduled_task_failure_thread_id', + 'telegram_notifications_docker_cleanup_success_thread_id', + 'telegram_notifications_docker_cleanup_failure_thread_id', + 'telegram_notifications_server_disk_usage_thread_id', + 'telegram_notifications_server_reachable_thread_id', + 'telegram_notifications_server_unreachable_thread_id', + 'telegram_notifications_server_patch_thread_id', + 'telegram_notifications_traefik_outdated_thread_id', + ); + }); + + test('TelegramNotificationSettings casts actual docker cleanup thread ids as encrypted', function () { + $casts = (new TelegramNotificationSettings)->getCasts(); + + expect($casts)->toMatchArray([ + 'telegram_notifications_docker_cleanup_success_thread_id' => 'encrypted', + 'telegram_notifications_docker_cleanup_failure_thread_id' => 'encrypted', + ]); + }); + + test('StandalonePostgresql hides password, init_scripts, db urls', function () { + $hidden = (new StandalonePostgresql)->getHidden(); + + expect($hidden)->toContain( + 'postgres_password', + 'init_scripts', + 'internal_db_url', + 'external_db_url', + ); + }); + + test('StandaloneMysql hides passwords and db urls', function () { + $hidden = (new StandaloneMysql)->getHidden(); + + expect($hidden)->toContain( + 'mysql_password', + 'mysql_root_password', + 'internal_db_url', + 'external_db_url', + ); + }); + + test('StandaloneMariadb hides passwords and db urls', function () { + $hidden = (new StandaloneMariadb)->getHidden(); + + expect($hidden)->toContain( + 'mariadb_password', + 'mariadb_root_password', + 'internal_db_url', + 'external_db_url', + ); + }); + + test('StandaloneMongodb hides root password and db urls', function () { + $hidden = (new StandaloneMongodb)->getHidden(); + + expect($hidden)->toContain( + 'mongo_initdb_root_password', + 'internal_db_url', + 'external_db_url', + ); + }); + + test('StandaloneRedis hides password and db urls', function () { + $hidden = (new StandaloneRedis)->getHidden(); + + expect($hidden)->toContain( + 'redis_password', + 'internal_db_url', + 'external_db_url', + ); + }); + + test('StandaloneClickhouse hides password and db urls', function () { + $hidden = (new StandaloneClickhouse)->getHidden(); + + expect($hidden)->toContain( + 'clickhouse_admin_password', + 'internal_db_url', + 'external_db_url', + ); + }); + + test('StandaloneKeydb hides password and db urls', function () { + $hidden = (new StandaloneKeydb)->getHidden(); + + expect($hidden)->toContain( + 'keydb_password', + 'internal_db_url', + 'external_db_url', + ); + }); + + test('StandaloneDragonfly hides password and db urls', function () { + $hidden = (new StandaloneDragonfly)->getHidden(); + + expect($hidden)->toContain( + 'dragonfly_password', + 'internal_db_url', + 'external_db_url', + ); + }); +}); + +describe('Sensitive fields are absent from toArray() by default', function () { + test('ServerSetting::toArray() excludes sentinel_custom_url by default', function () { + $setting = new ServerSetting; + $setting->setRawAttributes([ + 'server_id' => 1, + 'sentinel_custom_url' => 'https://secret.example.com', + 'wildcard_domain' => 'public.example.com', + ], sync: true); + + $array = $setting->toArray(); + + expect($array)->not->toHaveKey('sentinel_custom_url'); + expect($array)->toHaveKey('wildcard_domain'); + }); + + test('ServerSetting::toArray() includes sentinel_custom_url after makeVisible', function () { + $setting = new ServerSetting; + $setting->setRawAttributes([ + 'server_id' => 1, + 'sentinel_custom_url' => 'https://secret.example.com', + ], sync: true); + + $setting->makeVisible(['sentinel_custom_url']); + $array = $setting->toArray(); + + expect($array['sentinel_custom_url'])->toBe('https://secret.example.com'); + }); +});