fix(api): hide sensitive fields by default, expose via makeVisible for privileged tokens (#9893)

This commit is contained in:
Andras Bacsai 2026-07-07 13:31:41 +02:00 committed by GitHub
commit 6f52f7a22f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
50 changed files with 2320 additions and 66 deletions

View file

@ -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(

View file

@ -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);
}

View file

@ -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(

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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));
}

View file

@ -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();

View file

@ -16,6 +16,10 @@ private function removeSensitiveData($team)
$team->makeHidden([
'private_key',
]);
} else {
$team->makeVisible([
'private_key',
]);
}
return serializeApiResponse($team);

View file

@ -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);

View file

@ -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(

View file

@ -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([

View file

@ -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);

View file

@ -84,6 +84,7 @@ class ApplicationDeploymentQueue extends Model
* @var array<int, string>
*/
protected $hidden = [
'logs',
'configuration_snapshot',
'configuration_diff',
];

View file

@ -12,6 +12,10 @@ class CloudInitScript extends Model
'script',
];
protected $hidden = [
'script',
];
protected function casts(): array
{
return [

View file

@ -15,6 +15,10 @@ class CloudProviderToken extends BaseModel
'name',
];
protected $hidden = [
'token',
];
protected $casts = [
'token' => 'encrypted',
];

View file

@ -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',

View file

@ -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',

View file

@ -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) {

View file

@ -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',

View file

@ -25,6 +25,10 @@ class LocalFileVolume extends BaseModel
'is_preview_suffix_enabled' => 'boolean',
];
protected $hidden = [
'content',
];
use HasFactory;
protected $fillable = [

View file

@ -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(

View file

@ -42,6 +42,10 @@ class PrivateKey extends BaseModel
'fingerprint',
];
protected $hidden = [
'private_key',
];
protected $casts = [
'private_key' => 'encrypted',
];

View file

@ -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()

View file

@ -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',

View file

@ -32,6 +32,11 @@ class S3Storage extends BaseModel
'unusable_email_sent',
];
protected $hidden = [
'key',
'secret',
];
protected $casts = [
'is_usable' => 'boolean',
'key' => 'encrypted',

View file

@ -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',
];

View file

@ -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) {

View file

@ -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) {

View file

@ -30,6 +30,10 @@ class SharedEnvironmentVariable extends Model
'version',
];
protected $hidden = [
'value',
];
protected $casts = [
'key' => 'string',
'value' => 'encrypted',

View file

@ -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',

View file

@ -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',

View file

@ -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) {

View file

@ -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) {

View file

@ -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) {

View file

@ -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) {

View file

@ -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) {

View file

@ -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) {

View file

@ -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) {

View file

@ -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) {

View file

@ -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',

View file

@ -33,6 +33,10 @@ class WebhookNotificationSettings extends Model
'traefik_outdated_webhook_notifications',
];
protected $hidden = [
'webhook_url',
];
protected function casts(): array
{
return [

View file

@ -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 [

View file

@ -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": {

View file

@ -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": {

View file

@ -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 () {

View file

@ -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([

View file

@ -0,0 +1,46 @@
<?php
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
/**
* Regression test: EnvironmentVariable hides `value` in serialized output, so the
* config hash must makeVisible('value') before json_encode otherwise editing an
* env var value no longer flips the hash and no restart is suggested.
*/
it('detects environment variable value change in database config hash', function () {
$team = Team::factory()->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();
});

File diff suppressed because it is too large Load diff

View file

@ -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);

View file

@ -0,0 +1,322 @@
<?php
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\CloudInitScript;
use App\Models\CloudProviderToken;
use App\Models\DiscordNotificationSettings;
use App\Models\EmailNotificationSettings;
use App\Models\EnvironmentVariable;
use App\Models\InstanceSettings;
use App\Models\LocalFileVolume;
use App\Models\OauthSetting;
use App\Models\PrivateKey;
use App\Models\PushoverNotificationSettings;
use App\Models\S3Storage;
use App\Models\Server;
use App\Models\ServerSetting;
use App\Models\Service;
use App\Models\SharedEnvironmentVariable;
use App\Models\SlackNotificationSettings;
use App\Models\SslCertificate;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Models\TelegramNotificationSettings;
use App\Models\WebhookNotificationSettings;
describe('Sensitive model fields are hidden by default', function () {
test('ServerSetting hides sentinel and logdrain secrets', function () {
$hidden = (new ServerSetting)->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');
});
});