fix(api): hide sensitive fields by default, expose via makeVisible for privileged tokens (#9893)
This commit is contained in:
commit
6f52f7a22f
50 changed files with 2320 additions and 66 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@ private function removeSensitiveData($team)
|
|||
$team->makeHidden([
|
||||
'private_key',
|
||||
]);
|
||||
} else {
|
||||
$team->makeVisible([
|
||||
'private_key',
|
||||
]);
|
||||
}
|
||||
|
||||
return serializeApiResponse($team);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ class ApplicationDeploymentQueue extends Model
|
|||
* @var array<int, string>
|
||||
*/
|
||||
protected $hidden = [
|
||||
'logs',
|
||||
'configuration_snapshot',
|
||||
'configuration_diff',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@ class CloudInitScript extends Model
|
|||
'script',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'script',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@ class CloudProviderToken extends BaseModel
|
|||
'name',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'token',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'token' => 'encrypted',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -25,6 +25,10 @@ class LocalFileVolume extends BaseModel
|
|||
'is_preview_suffix_enabled' => 'boolean',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'content',
|
||||
];
|
||||
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -42,6 +42,10 @@ class PrivateKey extends BaseModel
|
|||
'fingerprint',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'private_key',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'private_key' => 'encrypted',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -32,6 +32,11 @@ class S3Storage extends BaseModel
|
|||
'unusable_email_sent',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'key',
|
||||
'secret',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_usable' => 'boolean',
|
||||
'key' => 'encrypted',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,10 @@ class SharedEnvironmentVariable extends Model
|
|||
'version',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'value',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'key' => 'string',
|
||||
'value' => 'encrypted',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -33,6 +33,10 @@ class WebhookNotificationSettings extends Model
|
|||
'traefik_outdated_webhook_notifications',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'webhook_url',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
|
|
|
|||
|
|
@ -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 [
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
46
tests/Feature/DatabaseConfigHashEnvValueTest.php
Normal file
46
tests/Feature/DatabaseConfigHashEnvValueTest.php
Normal 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();
|
||||
});
|
||||
1163
tests/Feature/Security/ApiSensitiveFieldsTest.php
Normal file
1163
tests/Feature/Security/ApiSensitiveFieldsTest.php
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
322
tests/Unit/Models/SensitiveFieldsHiddenTest.php
Normal file
322
tests/Unit/Models/SensitiveFieldsHiddenTest.php
Normal 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');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue