fix(auth): hide locked and admin-only secrets in forms, compose, and the API

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-10-24 21:46:58 +02:00
co-authored by Claude Opus 5.5
parent 6fc14f3615
commit d8d33c69ce
41 changed files with 994 additions and 96 deletions
@@ -152,7 +152,12 @@ class ApplicationsController extends Controller
$application->settings?->makeHidden(['id', 'application_id', 'created_at', 'updated_at']);
}
return serializeApiResponse($application);
$payload = serializeApiResponse($application);
if ($application instanceof Application && filled(data_get($payload, 'docker_compose'))) {
$payload['docker_compose'] = redactLockedValuesInGeneratedCompose($payload['docker_compose'], $application);
}
return $payload;
}
/**
@@ -237,7 +237,19 @@ class DatabasesController extends Controller
$this->hideNestedServerSecrets($database, $loadNestedServerSecrets);
}
return serializeApiResponse($database);
$payload = serializeApiResponse($database);
// A locked password variable is never shown again, also not inside the connection URLs.
if (request()->attributes->get('can_read_sensitive', false) === true
&& method_exists($database, 'isCredentialLocked') && $database->isCredentialLocked()) {
foreach (['internal_db_url', 'external_db_url'] as $key) {
if ($payload->has($key)) {
$payload->put($key, null);
}
}
}
return $payload;
}
private function hideNestedServerSecrets(Model $model, bool $loadRelations = false): void
@@ -30,7 +30,7 @@ class ServerTransferController extends Controller
#[OA\Post(
summary: 'Migrate server to another Coolify instance',
description: 'Start a transfer in the background: export this server, import and claim it on the target instance (with the given token), then disable automations here. Follow the progress with GET /servers/{uuid}/transfer. The target URL must use https. Requires read:sensitive and write.',
description: 'Start a transfer in the background: export this server, import and claim it on the target instance (with the given token), then disable automations here. All secrets of the server, also locked environment variables, are sent to the target. Follow the progress with GET /servers/{uuid}/transfer. The target URL must use https. Requires read:sensitive and write, and the owner role of the server team.',
path: '/servers/{uuid}/migrate',
operationId: 'migrate-server-between-instances',
security: [['bearerAuth' => []]],
@@ -50,7 +50,7 @@ class ServerTransferController extends Controller
),
responses: [
new OA\Response(response: 202, description: 'Transfer started'),
new OA\Response(response: 403, description: 'Missing sensitive permission'),
new OA\Response(response: 403, description: 'Missing sensitive permission or not an owner of the server team'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 422, description: 'Validation or remote import failed'),
]
@@ -64,7 +64,7 @@ class ServerTransferController extends Controller
if (! $this->canReadSensitive($request)) {
return response()->json([
'message' => 'Migrating a server requires a token with read:sensitive (or root) ability and an admin/owner team role.',
'message' => 'Migrating a server requires a token with read:sensitive (or root) ability and the owner team role.',
], 403);
}
@@ -73,7 +73,7 @@ class ServerTransferController extends Controller
return response()->json(['message' => 'Server not found.'], 404);
}
$this->authorize('update', $server);
$this->authorize('transfer', $server);
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
@@ -76,7 +76,12 @@ class ServicesController extends Controller
$service->makeHidden(['value', 'real_value']);
}
return serializeApiResponse($service);
$payload = serializeApiResponse($service);
if ($service instanceof Service && filled(data_get($payload, 'docker_compose'))) {
$payload['docker_compose'] = redactLockedValuesInGeneratedCompose($payload['docker_compose'], $service);
}
return $payload;
}
/**
+26 -28
View File
@@ -3,7 +3,9 @@
namespace App\Livewire;
use App\Models\Server;
use App\Models\Service;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Livewire\Attributes\Locked;
use Livewire\Component;
use Spatie\Activitylog\Models\Activity;
@@ -79,41 +81,37 @@ class ActivityMonitor extends Component
return;
}
$currentTeamId = currentTeam()?->id;
$this->activity = $this->canReadActivity($activity) ? $activity : null;
}
// Check team_id stored directly in activity properties
$activityTeamId = data_get($activity, 'properties.team_id');
if ($activityTeamId !== null) {
if ((int) $activityTeamId !== (int) $currentTeamId) {
$this->activity = null;
/**
* Activity output can contain secrets, so only users who can edit the resource that the
* activity ran for (or its server) may read it. The policy uses the team of that resource,
* not the session team.
*/
private function canReadActivity(Activity $activity): bool
{
$user = auth()->user();
if (! $user) {
return false;
}
return;
}
$subject = $activity->subject;
if ($subject instanceof Model) {
return $user->can('update', $subject);
}
$this->activity = $activity;
return;
// Service starts store the service uuid instead of a subject.
$typeUuid = data_get($activity, 'properties.type_uuid');
if (filled($typeUuid) && ($service = Service::where('uuid', $typeUuid)->first())) {
return $user->can('update', $service);
}
// Fallback: verify ownership via the server that ran the command
$serverUuid = data_get($activity, 'properties.server_uuid');
if ($serverUuid) {
$server = Server::where('uuid', $serverUuid)->first();
if ($server && (int) $server->team_id !== (int) $currentTeamId) {
$this->activity = null;
$server = filled($serverUuid) ? Server::where('uuid', $serverUuid)->first() : null;
return;
}
if ($server) {
$this->activity = $activity;
return;
}
}
// Fail closed: no team_id and no server_uuid means we cannot verify ownership
$this->activity = null;
// Fail closed: an activity without a resource or server cannot be authorized.
return $server !== null && $user->can('update', $server);
}
public function polling()
+5 -2
View File
@@ -322,6 +322,8 @@ class General extends Component
$this->parsedServices = collect([
'services' => collect(data_get($this->parsedServices, 'services', []))->map(fn () => []),
]);
} elseif (isset($this->parsedServices)) {
$this->parsedServices = redactLockedValuesInGeneratedCompose($this->parsedServices, $this->application);
}
if ($this->application->build_pack === 'dockercompose') {
// Only update if user has permission
@@ -411,7 +413,7 @@ class General extends Component
$this->application->docker_registry_image_name = $this->dockerRegistryImageName;
$this->application->docker_registry_image_tag = $this->dockerRegistryImageTag;
$this->application->docker_compose_location = $this->dockerComposeLocation;
$this->application->docker_compose = $this->dockerCompose;
// docker_compose is generated by the parser; the property holds a redacted display copy.
$this->application->docker_compose_raw = $this->dockerComposeRaw;
$this->application->docker_compose_custom_start_command = $this->dockerComposeCustomStartCommand;
$this->application->docker_compose_custom_build_command = $this->dockerComposeCustomBuildCommand;
@@ -468,7 +470,7 @@ class General extends Component
$this->dockerRegistryImageTag = $this->application->docker_registry_image_tag;
$this->dockerComposeLocation = $this->application->docker_compose_location;
$canViewCompose = auth()->user()?->can('update', $this->application) ?? false;
$this->dockerCompose = $canViewCompose ? $this->application->docker_compose : null;
$this->dockerCompose = $canViewCompose ? redactLockedValuesInGeneratedCompose($this->application->docker_compose, $this->application) : null;
$this->dockerComposeRaw = $canViewCompose ? $this->application->docker_compose_raw : null;
$this->dockerComposeCustomStartCommand = $this->application->docker_compose_custom_start_command;
$this->dockerComposeCustomBuildCommand = $this->application->docker_compose_custom_build_command;
@@ -573,6 +575,7 @@ class General extends Component
return;
}
$this->parsedServices = redactLockedValuesInGeneratedCompose($this->parsedServices, $this->application);
// Refresh parsedServiceDomains to reflect any changes in docker_compose_domains
$this->application->refresh();
@@ -129,8 +129,8 @@ class General extends Component
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
$this->keydbConf = $this->database->keydb_conf;
$canSeeCredentials = auth()->user()?->can('update', $this->database) ?? false;
$this->keydbConf = $canSeeCredentials ? $this->database->keydb_conf : null;
$this->keydbPassword = $canSeeCredentials ? $this->database->keydb_password : '';
$this->image = $this->database->image;
$this->portsMappings = $this->database->ports_mappings;
@@ -159,7 +159,7 @@ class General extends Component
$this->mariadbUser = $this->database->mariadb_user;
$this->mariadbPassword = $canSeeCredentials ? $this->database->mariadb_password : '';
$this->mariadbDatabase = $this->database->mariadb_database;
$this->mariadbConf = $this->database->mariadb_conf;
$this->mariadbConf = $canSeeCredentials ? $this->database->mariadb_conf : null;
$this->image = $this->database->image;
$this->portsMappings = $this->database->ports_mappings;
$this->isPublic = $this->database->is_public;
@@ -146,9 +146,9 @@ class General extends Component
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
$this->mongoConf = $this->database->mongo_conf;
$this->mongoInitdbRootUsername = $this->database->mongo_initdb_root_username;
$canSeeCredentials = auth()->user()?->can('update', $this->database) ?? false;
$this->mongoConf = $canSeeCredentials ? $this->database->mongo_conf : null;
$this->mongoInitdbRootUsername = $this->database->mongo_initdb_root_username;
$this->mongoInitdbRootPassword = $canSeeCredentials ? $this->database->mongo_initdb_root_password : '';
$this->mongoInitdbDatabase = $this->database->mongo_initdb_database;
$this->image = $this->database->image;
@@ -159,7 +159,7 @@ class General extends Component
$this->mysqlUser = $this->database->mysql_user;
$this->mysqlPassword = $canSeeCredentials ? $this->database->mysql_password : '';
$this->mysqlDatabase = $this->database->mysql_database;
$this->mysqlConf = $this->database->mysql_conf;
$this->mysqlConf = $canSeeCredentials ? $this->database->mysql_conf : null;
$this->image = $this->database->image;
$this->portsMappings = $this->database->ports_mappings;
$this->isPublic = $this->database->is_public;
@@ -176,8 +176,8 @@ class General extends Component
$this->postgresDb = $this->database->postgres_db;
$this->postgresInitdbArgs = $this->database->postgres_initdb_args;
$this->postgresHostAuthMethod = $this->database->postgres_host_auth_method;
$this->postgresConf = $this->database->postgres_conf;
$this->initScripts = $this->database->init_scripts;
$this->postgresConf = $canSeeCredentials ? $this->database->postgres_conf : null;
$this->initScripts = $canSeeCredentials ? $this->database->init_scripts : null;
$this->image = $this->database->image;
$this->portsMappings = $this->database->ports_mappings;
$this->isPublic = $this->database->is_public;
+18 -10
View File
@@ -42,12 +42,14 @@ class General extends Component
public string $redisUsername;
public string $redisPassword;
public ?string $redisPassword = null;
public string $redisVersion;
public bool $isPasswordHiddenForMember = false;
public bool $isPasswordLocked = false;
protected $listeners = [
'envsUpdated' => 'refresh',
];
@@ -68,9 +70,11 @@ class General extends Component
'redisUsername' => ValidationPatterns::databaseIdentifierRules(
enforcePattern: $this->redisUsername !== $this->database->redis_username,
),
'redisPassword' => ValidationPatterns::databasePasswordRules(
enforcePattern: $this->redisPassword !== $this->database->redis_password,
),
'redisPassword' => $this->database->isCredentialLocked()
? ['nullable']
: ValidationPatterns::databasePasswordRules(
enforcePattern: $this->redisPassword !== $this->database->redis_password,
),
];
}
@@ -143,7 +147,6 @@ class General extends Component
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
$this->redisConf = $this->database->redis_conf;
$this->image = $this->database->image;
$this->portsMappings = $this->database->ports_mappings;
$this->isPublic = $this->database->is_public;
@@ -154,7 +157,9 @@ class General extends Component
$this->redisVersion = $this->database->getRedisVersion();
$this->redisUsername = $this->database->redis_username;
$canSeeCredentials = auth()->user()?->can('update', $this->database) ?? false;
$this->redisPassword = $canSeeCredentials ? $this->database->redis_password : '';
$this->redisConf = $canSeeCredentials ? $this->database->redis_conf : null;
$this->isPasswordLocked = $this->database->isCredentialLocked();
$this->redisPassword = $canSeeCredentials && ! $this->isPasswordLocked ? $this->database->redis_password : null;
}
}
@@ -193,10 +198,13 @@ class General extends Component
['value' => $this->redisUsername, 'resourceable_id' => $this->database->id]
);
}
$this->database->runtime_environment_variables()->updateOrCreate(
['key' => 'REDIS_PASSWORD'],
['value' => $this->redisPassword, 'resourceable_id' => $this->database->id]
);
// The form never loads a locked password, so saving its empty value would overwrite it.
if (! $this->database->isCredentialLocked()) {
$this->database->runtime_environment_variables()->updateOrCreate(
['key' => 'REDIS_PASSWORD'],
['value' => $this->redisPassword, 'resourceable_id' => $this->database->id]
);
}
$this->dispatch('success', 'Database updated.');
$this->dispatch('databaseUpdated');
+1 -2
View File
@@ -54,12 +54,11 @@ class EditCompose extends Component
{
if ($toModel) {
$this->service->docker_compose_raw = $this->dockerComposeRaw;
$this->service->docker_compose = $this->dockerCompose;
$this->service->is_container_label_escape_enabled = $this->isContainerLabelEscapeEnabled;
} else {
$canViewCompose = auth()->user()?->can('update', $this->service) ?? false;
$this->dockerComposeRaw = $canViewCompose ? $this->service->docker_compose_raw : null;
$this->dockerCompose = $canViewCompose ? $this->service->docker_compose : null;
$this->dockerCompose = $canViewCompose ? redactLockedValuesInGeneratedCompose($this->service->docker_compose, $this->service) : null;
$this->isContainerLabelEscapeEnabled = $this->service->is_container_label_escape_enabled ?? false;
}
}
+10 -4
View File
@@ -81,7 +81,7 @@ class StackForm extends Component
$this->service->name = $this->name;
$this->service->description = $this->description;
$this->service->docker_compose_raw = $this->dockerComposeRaw;
$this->service->docker_compose = $this->dockerCompose;
// docker_compose is generated by parse(); the property holds a redacted display copy.
$this->service->connect_to_docker_network = $this->connectToDockerNetwork;
} else {
// Sync FROM model (on load/refresh)
@@ -89,7 +89,7 @@ class StackForm extends Component
$this->description = $this->service->description;
$canViewCompose = auth()->user()?->can('update', $this->service) ?? false;
$this->dockerComposeRaw = $canViewCompose ? $this->service->docker_compose_raw : null;
$this->dockerCompose = $canViewCompose ? $this->service->docker_compose : null;
$this->dockerCompose = $canViewCompose ? redactLockedValuesInGeneratedCompose($this->service->docker_compose, $this->service) : null;
$this->connectToDockerNetwork = $this->service->connect_to_docker_network;
}
}
@@ -144,11 +144,17 @@ class StackForm extends Component
}
/**
* Keys of the service environment variables that are locked (shown once).
* Keys of the extra fields that are locked (shown once), directly or through a referenced variable.
*/
private function lockedFieldKeys(): Collection
{
return $this->service->environment_variables()->where('is_shown_once', true)->pluck('key');
$referencingLockedKeys = collect($this->service->extraFields())
->flatMap(fn ($fields) => collect($fields))
->filter(fn ($field) => data_get($field, 'isLocked'))
->pluck('key');
return $this->service->environment_variables()->where('is_shown_once', true)->pluck('key')
->merge($referencingLockedKeys);
}
public function saveCompose($raw)
@@ -69,6 +69,8 @@ class All extends Component
private ?Collection $composeEnvironmentAssignments = null;
private ?bool $canViewComposeContent = null;
protected $listeners = [
'saveKey' => 'submit',
'refreshEnvs',
@@ -898,10 +900,24 @@ class All extends Component
/** @return array{services: list<string>, default: ?string, operator: ?string, required: bool}|null */
private function composeInfoFor(string $key): ?array
{
return dockerComposeEnvironmentVariableInfo(
$composeInfo = dockerComposeEnvironmentVariableInfo(
$this->resource->docker_compose_raw ?? $this->resource->docker_compose,
$key
);
if ($composeInfo !== null && ! $this->canViewComposeContent()) {
$composeInfo['default'] = null;
}
return $composeInfo;
}
/**
* Compose defaults are compose content, which only users who can update the resource may see.
*/
private function canViewComposeContent(): bool
{
return $this->canViewComposeContent ??= auth()->user()?->can('update', $this->resource) ?? false;
}
private function composeEnvironmentAssignments(): Collection
@@ -129,6 +129,9 @@ class Show extends Component
public function mount()
{
if ($this->composeInfo !== null && ! (auth()->user()?->can('update', $this->resource) ?? false)) {
$this->composeInfo['default'] = null;
}
$this->syncData();
if ($this->env->getMorphClass() === SharedEnvironmentVariable::class) {
$this->isSharedVariable = true;
+1 -1
View File
@@ -102,7 +102,7 @@ class Transfer extends Component
public function migrateServer(ServerTransferMigrator $migrator): void
{
try {
$this->authorize('update', $this->server);
$this->authorize('transfer', $this->server);
if ($this->isLocalhost) {
throw new \RuntimeException('The Coolify host (localhost) cannot be transferred.');
}
+4 -1
View File
@@ -1426,7 +1426,10 @@ class Service extends BaseModel
if (filled($field['value']) && str($field['value'])->startsWith('$SERVICE_')) {
$searchValue = str($field['value'])->after('$')->value;
$newValue = $this->environment_variables()->where('key', $searchValue)->first();
if ($newValue) {
if ($newValue?->is_shown_once) {
$field['value'] = null;
$field['isLocked'] = true;
} elseif ($newValue) {
$field['value'] = $newValue->value;
}
}
+9
View File
@@ -405,6 +405,15 @@ class StandaloneDragonfly extends BaseModel
return $environmentVariable;
}
/**
* Whether the password variable is locked (shown once) or reads a locked shared variable.
* The form, the connection URLs, and the API must then not show the password.
*/
public function isCredentialLocked(): bool
{
return (bool) $this->serverPasswordEnvironmentVariable()?->isLockedForLogs();
}
/**
* The server password for connection URLs. A remote secret is not fetched here, so the URL
* shows its reference instead of a stale or wrong password.
+9
View File
@@ -406,6 +406,15 @@ class StandaloneKeydb extends BaseModel
return $environmentVariable;
}
/**
* Whether the password variable is locked (shown once) or reads a locked shared variable.
* The form, the connection URLs, and the API must then not show the password.
*/
public function isCredentialLocked(): bool
{
return (bool) $this->serverPasswordEnvironmentVariable()?->isLockedForLogs();
}
/**
* The server password for connection URLs. A remote secret is not fetched here, so the URL
* shows its reference instead of a stale or wrong password.
+9
View File
@@ -414,6 +414,15 @@ class StandaloneRedis extends BaseModel
return $environmentVariable;
}
/**
* Whether the password variable is locked (shown once) or reads a locked shared variable.
* The form, the connection URLs, and the API must then not show the password.
*/
public function isCredentialLocked(): bool
{
return (bool) $this->runtimeEnvironmentVariable('REDIS_PASSWORD')?->isLockedForLogs();
}
/**
* The server password for connection URLs. A remote secret is not fetched here, so the URL
* shows its reference instead of a stale or wrong password.
+10
View File
@@ -39,6 +39,16 @@ class ServerPolicy
return $user->isAdminOfTeam($server->team_id);
}
/**
* Determine whether the user can transfer the server to another Coolify instance.
*
* A transfer sends every secret of the server, also locked environment variables, to the target, so only owners may start it.
*/
public function transfer(User $user, Server $server): bool
{
return $user->roleInTeam($server->team_id) === 'owner';
}
/**
* Determine whether the user can delete the model.
*/
@@ -50,8 +50,13 @@ class ConfigurationDiffer
}
$item = $current ?? $previous;
$sensitive = (bool) data_get($item, 'sensitive', false);
$type = $previous === null ? 'added' : ($current === null ? 'removed' : 'changed');
// A value is hidden when either snapshot hides it. A removed environment variable may
// have been locked after the previous deployment, which no snapshot records, so its
// old value is hidden too.
$sensitive = (bool) data_get($previous, 'sensitive', false)
|| (bool) data_get($current, 'sensitive', false)
|| ($type === 'removed' && data_get($item, 'section') === 'environment');
$displaySummary = $sensitive && $type === 'changed' ? 'Changed' : null;
$diffMode = data_get($item, 'diff_mode', 'default');
+2 -1
View File
@@ -82,7 +82,8 @@ trait HasDatabaseStatusInfo
{
$this->database->refresh();
$this->isPasswordHiddenForMember = ! (auth()->user()?->can('update', $this->database) ?? false);
if ($this->isPasswordHiddenForMember) {
$isCredentialLocked = method_exists($this->database, 'isCredentialLocked') && $this->database->isCredentialLocked();
if ($this->isPasswordHiddenForMember || $isCredentialLocked) {
$this->dbUrl = null;
$this->dbUrlPublic = null;
} else {
+74
View File
@@ -5650,6 +5650,80 @@ function resolveSharedEnvironmentVariables(?string $value, $resource): ?string
return str($value)->value();
}
/**
* Replace locked values in the generated compose file of a resource, for display only.
*
* The compose parsers write resolved values into `docker_compose`: environment variables, database
* overrides, and shared variable references. Deployments need those values, so only the displayed
* copy is redacted. Covered: the resource's locked variables (also variables that reference a locked
* shared variable) and locked team, project, and environment variables that the raw compose references.
* Values shorter than 4 characters are not replaced, because they would match unrelated text.
* A parsed compose (array or collection) gets the same replacement in every string value.
*
* @template T of string|array|Collection|null
*
* @param T $compose
* @return T
*/
function redactLockedValuesInGeneratedCompose(string|array|Collection|null $compose, Application|Service $resource): string|array|Collection|null
{
if (blank($compose)) {
return $compose;
}
$values = EnvironmentVariable::logRedactionSecretsFor($resource, includePreview: true, resolveRemoteSecrets: false)['locked'];
preg_match_all('/{{\s*(\w+)\.([^}\s]+)\s*}}/', (string) $resource->docker_compose_raw, $matches, PREG_SET_ORDER);
$references = collect($matches)->filter(fn (array $match): bool => in_array($match[1], ['team', 'project', 'environment'], true));
$teamId = $resource->team()?->id;
if ($references->isNotEmpty() && $teamId !== null) {
$scopeIds = [
'team' => $teamId,
'project' => $resource->environment?->project_id,
'environment' => $resource->environment_id,
];
$values = SharedEnvironmentVariable::query()
->where('team_id', $teamId)
->where('is_shown_once', true)
->whereIn('key', $references->pluck(2)->unique()->values())
->get()
->filter(fn (SharedEnvironmentVariable $shared): bool => $references->contains(fn (array $match): bool => $match[1] === $shared->type && $match[2] === $shared->key)
&& $scopeIds[$shared->type] !== null
&& (int) $shared->{"{$shared->type}_id"} === (int) $scopeIds[$shared->type])
->pluck('value')
->merge($values)
->all();
}
$variants = collect($values)
->filter(fn (mixed $value): bool => is_string($value))
->flatMap(fn (string $value): array => [
$value,
escapeDollarSign($value),
str_replace("'", "''", $value),
substr((string) json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), 1, -1),
])
->filter(fn (string $value): bool => mb_strlen($value) >= 4)
->unique()
->mapWithKeys(fn (string $value): array => [$value => '<locked>']);
if ($variants->isEmpty()) {
return $compose;
}
// strtr() replaces the longest match first and does not search the replaced text again.
$redact = function (mixed $value) use (&$redact, $variants): mixed {
return match (true) {
is_string($value) => strtr($value, $variants->all()),
is_array($value) => array_map($redact, $value),
$value instanceof Collection => $value->map($redact),
default => $value,
};
};
return $redact($compose);
}
/**
* Convert an ISO 3166-1 alpha-2 country code into its regional-indicator flag emoji.
*
@@ -15,6 +15,7 @@
])
@php
$isCredentialLocked = ! $isPasswordHiddenForMember && method_exists($database, 'isCredentialLocked') && $database->isCredentialLocked();
$urlHelper = 'If you change the user/password/port, this could be different. This is with the default values.';
@endphp
@@ -22,6 +23,9 @@
@if ($isPasswordHiddenForMember)
<x-forms.input :label="$label . ' URL (internal)'" disabled value="Hidden (only admins can view)" />
<x-forms.input :label="$label . ' URL (public)'" disabled value="Hidden (only admins can view)" />
@elseif ($isCredentialLocked)
<x-forms.input :label="$label . ' URL (internal)'" disabled value="Locked (shown once)"
helper="The password variable is locked, so the URL that contains it is not shown." />
@else
<x-forms.input :label="$label . ' URL (internal)'" :helper="$urlHelper" type="password" readonly copyable
wire:model="dbUrl" canGate="update" :canResource="$database" />
@@ -89,10 +89,14 @@
id="customDockerRunOptions" label="Custom Docker options" canGate="update"
:canResource="$database" />
<x-application.settings-subsection id="configuration-section" title="Configuration" helper="Override only the KeyDB directives you need. All other defaults remain active.">
<x-forms.textarea
helper="<a target='_blank' class='underline' href='https://raw.githubusercontent.com/Snapchat/KeyDB/unstable/keydb.conf'>View the default KeyDB configuration</a>"
label="Custom KeyDB configuration" rows="10" id="keydbConf" canGate="update"
:canResource="$database" />
@if ($isPasswordHiddenForMember)
<x-forms.input label="Custom KeyDB configuration" disabled value="Hidden (only admins can view)" />
@else
<x-forms.textarea
helper="<a target='_blank' class='underline' href='https://raw.githubusercontent.com/Snapchat/KeyDB/unstable/keydb.conf'>View the default KeyDB configuration</a>"
label="Custom KeyDB configuration" rows="10" id="keydbConf" canGate="update"
:canResource="$database" />
@endif
</x-application.settings-subsection>
<x-application.settings-subsection id="log-delivery-section" title="Log delivery" helper="Forward container logs to the drain configured on the server.">
<x-forms.listbox canGate="update" :canResource="$database" id="isLogDrainEnabled" label="Log drain" live onChange="instantSaveAdvanced"
@@ -94,8 +94,12 @@
id="customDockerRunOptions" label="Custom Docker options" canGate="update"
:canResource="$database" />
<x-application.settings-subsection id="configuration-section" title="Configuration" helper="Override the MariaDB configuration used by this container.">
<x-forms.textarea label="Custom MariaDB configuration" rows="10" id="mariadbConf"
canGate="update" :canResource="$database" />
@if ($isPasswordHiddenForMember)
<x-forms.input label="Custom MariaDB configuration" disabled value="Hidden (only admins can view)" />
@else
<x-forms.textarea label="Custom MariaDB configuration" rows="10" id="mariadbConf"
canGate="update" :canResource="$database" />
@endif
</x-application.settings-subsection>
<x-application.settings-subsection id="log-delivery-section" title="Log delivery" helper="Forward container logs to the drain configured on the server.">
<x-forms.listbox canGate="update" :canResource="$database" id="isLogDrainEnabled" label="Log drain" live onChange="instantSaveAdvanced"
@@ -91,8 +91,12 @@
id="customDockerRunOptions" label="Custom Docker options" canGate="update"
:canResource="$database" />
<x-application.settings-subsection id="configuration-section" title="Configuration" helper="Override the MongoDB configuration used by this container.">
<x-forms.textarea label="Custom MongoDB configuration" rows="10" id="mongoConf"
canGate="update" :canResource="$database" />
@if ($isPasswordHiddenForMember)
<x-forms.input label="Custom MongoDB configuration" disabled value="Hidden (only admins can view)" />
@else
<x-forms.textarea label="Custom MongoDB configuration" rows="10" id="mongoConf"
canGate="update" :canResource="$database" />
@endif
</x-application.settings-subsection>
<x-application.settings-subsection id="log-delivery-section" title="Log delivery" helper="Forward container logs to the drain configured on the server.">
<x-forms.listbox canGate="update" :canResource="$database" id="isLogDrainEnabled" label="Log drain" live onChange="instantSaveAdvanced"
@@ -94,8 +94,12 @@
id="customDockerRunOptions" label="Custom Docker options" canGate="update"
:canResource="$database" />
<x-application.settings-subsection id="configuration-section" title="Configuration" helper="Override the MySQL configuration used by this container.">
<x-forms.textarea label="Custom MySQL configuration" rows="10" id="mysqlConf"
canGate="update" :canResource="$database" />
@if ($isPasswordHiddenForMember)
<x-forms.input label="Custom MySQL configuration" disabled value="Hidden (only admins can view)" />
@else
<x-forms.textarea label="Custom MySQL configuration" rows="10" id="mysqlConf"
canGate="update" :canResource="$database" />
@endif
</x-application.settings-subsection>
<x-application.settings-subsection id="log-delivery-section" title="Log delivery" helper="Forward container logs to the drain configured on the server.">
<x-forms.listbox canGate="update" :canResource="$database" id="isLogDrainEnabled" label="Log drain" live onChange="instantSaveAdvanced"
@@ -96,8 +96,12 @@
id="customDockerRunOptions" label="Custom Docker options" canGate="update"
:canResource="$database" />
<x-application.settings-subsection id="configuration-section" title="Configuration" helper="Override the PostgreSQL configuration used by this container.">
<x-forms.textarea label="Custom PostgreSQL configuration" rows="10" id="postgresConf"
canGate="update" :canResource="$database" />
@if ($isPasswordHiddenForMember)
<x-forms.input label="Custom PostgreSQL configuration" disabled value="Hidden (only admins can view)" />
@else
<x-forms.textarea label="Custom PostgreSQL configuration" rows="10" id="postgresConf"
canGate="update" :canResource="$database" />
@endif
</x-application.settings-subsection>
<x-application.settings-subsection id="log-delivery-section" title="Log delivery" helper="Forward container logs to the drain configured on the server.">
<x-forms.listbox canGate="update" :canResource="$database" id="isLogDrainEnabled" label="Log drain" live onChange="instantSaveAdvanced"
@@ -126,13 +130,18 @@
@endcan
</x-slot:actions>
<div class="divide-y divide-neutral-200 dark:divide-border-subtle">
@forelse($initScripts ?? [] as $script)
<livewire:project.database.init-script :database="$database" :script="$script"
:wire:key="'init-script-'.md5($script['filename'])" />
@empty
<x-empty title="No initialization scripts"
description="Add a SQL file to run during the first PostgreSQL initialization." />
@endforelse
@if ($isPasswordHiddenForMember)
<x-empty title="Hidden (only admins can view)"
description="Initialization scripts can contain credentials." />
@else
@forelse($initScripts ?? [] as $script)
<livewire:project.database.init-script :database="$database" :script="$script"
:wire:key="'init-script-'.md5($script['filename'])" />
@empty
<x-empty title="No initialization scripts"
description="Add a SQL file to run during the first PostgreSQL initialization." />
@endforelse
@endif
</div>
</x-application.settings-section>
</div>
@@ -35,6 +35,9 @@
@endif
@if ($isPasswordHiddenForMember)
<x-forms.input label="Password" disabled value="Hidden (only admins can view)" />
@elseif ($isPasswordLocked)
<x-forms.input label="Password" disabled value="Locked (shown once)"
helper="REDIS_PASSWORD is locked. Its value is never shown again." />
@else
<x-forms.input label="Password" id="redisPassword" type="password"
:required="!$database->started_at"
@@ -93,12 +96,16 @@
id="customDockerRunOptions" label="Custom Docker options" canGate="update"
:canResource="$database" />
<x-application.settings-subsection id="configuration-section" title="Configuration" helper="Override only the Redis directives you need. All other defaults remain active.">
<x-forms.textarea placeholder="# maxmemory 256mb
@if ($isPasswordHiddenForMember)
<x-forms.input label="Custom Redis configuration" disabled value="Hidden (only admins can view)" />
@else
<x-forms.textarea placeholder="# maxmemory 256mb
# maxmemory-policy allkeys-lru
# timeout 300"
helper="Coolify automatically applies requirepass using the password above. If you override requirepass here, keep both values identical."
label="Custom Redis configuration" rows="10" id="redisConf" canGate="update"
:canResource="$database" />
helper="Coolify automatically applies requirepass using the password above. If you override requirepass here, keep both values identical."
label="Custom Redis configuration" rows="10" id="redisConf" canGate="update"
:canResource="$database" />
@endif
</x-application.settings-subsection>
<x-application.settings-subsection id="log-delivery-section" title="Log delivery" helper="Forward container logs to the drain configured on the server.">
<x-forms.listbox canGate="update" :canResource="$database" id="isLogDrainEnabled" label="Log drain" live onChange="instantSaveAdvanced"
@@ -114,13 +114,14 @@
<div>
<x-modal-confirmation title="Transfer this server?" submitAction="migrateServer"
:confirmWithText="false" :confirmWithPassword="false" step2ButtonText="Transfer server"
warningMessage="All secrets of this server and its resources are sent to the target instance: private keys, credentials, and every environment variable value, also locked ones. Continue only if you trust that instance."
:actions="[
'Coolify first checks this server, the target instance, and the token. If a check fails, nothing is changed.',
'The server will be transferred to the target instance.',
'Automations for this server will be disabled on this instance.',
]">
<x-slot:trigger>
<x-forms.button canGate="update" :canResource="$server" wire:loading.attr="disabled"
<x-forms.button canGate="transfer" :canResource="$server" wire:loading.attr="disabled"
wire:target="migrateServer">
<span wire:loading.remove wire:target="migrateServer">Transfer server</span>
<span wire:loading wire:target="migrateServer">Running checks…</span>
+84 -7
View File
@@ -1,7 +1,9 @@
<?php
use App\Livewire\ActivityMonitor;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -34,7 +36,7 @@ test('hydrateActivity blocks access to another teams activity via team_id', func
->assertSet('activity', null);
});
test('hydrateActivity allows access to own teams activity via team_id', function () {
test('hydrateActivity blocks an activity of the own team that has no resource or server', function () {
$ownActivity = Activity::create([
'log_name' => 'default',
'description' => 'test activity',
@@ -44,11 +46,9 @@ test('hydrateActivity allows access to own teams activity via team_id', function
$this->actingAs($this->user);
session(['currentTeam' => ['id' => $this->team->id]]);
$component = Livewire::test(ActivityMonitor::class)
->call('newMonitorActivity', $ownActivity->id);
expect($component->get('activity'))->not->toBeNull();
expect($component->get('activity')->id)->toBe($ownActivity->id);
Livewire::test(ActivityMonitor::class)
->call('newMonitorActivity', $ownActivity->id)
->assertSet('activity', null);
});
test('hydrateActivity blocks access to activity without team_id or server_uuid', function () {
@@ -132,10 +132,11 @@ test('activityId property is locked and cannot be set from client', function ()
})->throws(CannotUpdateLockedPropertyException::class);
test('closing a process dialog clears the previous activity logs', function () {
$server = Server::factory()->create(['team_id' => $this->team->id]);
$activity = Activity::create([
'log_name' => 'default',
'description' => 'proxy restart activity',
'properties' => ['team_id' => $this->team->id],
'properties' => ['team_id' => $this->team->id, 'server_uuid' => $server->uuid],
]);
$this->actingAs($this->user);
@@ -150,3 +151,79 @@ test('closing a process dialog clears the previous activity logs', function () {
->assertSet('activity', null)
->assertSet('isPollingActive', false);
});
describe('activity output of a resource', function () {
beforeEach(function () {
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$destination = $this->server->standaloneDockers()->firstOrFail();
$project = Project::factory()->create(['team_id' => $this->team->id]);
$this->service = Service::factory()->create([
'server_id' => $this->server->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
'environment_id' => $project->environments()->firstOrFail()->id,
]);
$this->serviceActivity = Activity::create([
'log_name' => 'default',
'description' => 'service start',
'properties' => [
'team_id' => $this->team->id,
'server_uuid' => $this->server->uuid,
'type_uuid' => $this->service->uuid,
],
]);
$this->serverActivity = Activity::create([
'log_name' => 'default',
'description' => 'proxy start',
'properties' => ['team_id' => $this->team->id, 'server_uuid' => $this->server->uuid],
]);
});
test('a team member cannot read it', function (string $activity) {
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
$this->actingAs($member);
session(['currentTeam' => ['id' => $this->team->id]]);
Livewire::test(ActivityMonitor::class)
->call('newMonitorActivity', $this->{$activity}->id)
->assertSet('activity', null);
})->with(['serviceActivity', 'serverActivity']);
test('a team admin can read it', function (string $activity) {
$admin = User::factory()->create();
$this->team->members()->attach($admin->id, ['role' => 'admin']);
$this->actingAs($admin);
session(['currentTeam' => ['id' => $this->team->id]]);
$component = Livewire::test(ActivityMonitor::class)
->call('newMonitorActivity', $this->{$activity}->id);
expect($component->get('activity')?->id)->toBe($this->{$activity}->id);
})->with(['serviceActivity', 'serverActivity']);
test('a member who owns another team cannot read it after switching to that team', function () {
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
$this->otherTeam->members()->attach($member->id, ['role' => 'owner']);
$this->actingAs($member);
session(['currentTeam' => ['id' => $this->otherTeam->id]]);
Livewire::test(ActivityMonitor::class)
->call('newMonitorActivity', $this->serviceActivity->id)
->assertSet('activity', null);
});
test('an admin of the resource team can read it while the session is on another team', function () {
$admin = User::factory()->create();
$this->team->members()->attach($admin->id, ['role' => 'admin']);
$this->otherTeam->members()->attach($admin->id, ['role' => 'owner']);
$this->actingAs($admin);
session(['currentTeam' => ['id' => $this->otherTeam->id]]);
$component = Livewire::test(ActivityMonitor::class)
->call('newMonitorActivity', $this->serviceActivity->id);
expect($component->get('activity')?->id)->toBe($this->serviceActivity->id);
});
});
@@ -506,6 +506,24 @@ describe('POST /api/v1/servers/{uuid}/migrate', function () {
->assertJsonPath('migration.target_url', 'https://8.8.8.8');
});
test('rejects a team admin, because a transfer sends every secret to the target', function () {
Queue::fake();
Http::fake(['*/api/v1/servers/import/check*' => Http::response(['message' => 'ok', 'checks' => []])]);
$admin = User::factory()->create();
$this->team->members()->attach($admin->id, ['role' => 'admin']);
$adminToken = $admin->createToken('admin-sensitive', ['*', 'read:sensitive'], teamId: $this->team->id)->plainTextToken;
$this->withHeaders(transferHeaders($adminToken))
->postJson("/api/v1/servers/{$this->server->uuid}/migrate", [
'target_url' => 'https://8.8.8.8',
'target_token' => 'target-token',
])
->assertForbidden();
Queue::assertNothingPushed();
expect(data_get($this->server->fresh()->server_metadata, 'migration'))->toBeNull();
});
test('rejects a target without https', function () {
Queue::fake();
@@ -0,0 +1,241 @@
<?php
use App\Livewire\Project\Database\Dragonfly\StatusInfo as DragonflyStatusInfo;
use App\Livewire\Project\Database\Keydb\General as KeydbGeneral;
use App\Livewire\Project\Database\Keydb\StatusInfo as KeydbStatusInfo;
use App\Livewire\Project\Database\Mariadb\General as MariadbGeneral;
use App\Livewire\Project\Database\Mongodb\General as MongodbGeneral;
use App\Livewire\Project\Database\Mysql\General as MysqlGeneral;
use App\Livewire\Project\Database\Postgresql\General as PostgresqlGeneral;
use App\Livewire\Project\Database\Redis\General as RedisGeneral;
use App\Livewire\Project\Database\Redis\StatusInfo as RedisStatusInfo;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\SharedEnvironmentVariable;
use App\Models\StandaloneDocker;
use App\Models\StandaloneRedis;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0]);
$this->team = Team::factory()->create();
$this->owner = User::factory()->create();
$this->member = User::factory()->create();
$this->team->members()->attach($this->owner->id, ['role' => 'owner']);
$this->team->members()->attach($this->member->id, ['role' => 'member']);
$server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::where('server_id', $server->id)->first();
$project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $project->id]);
});
dataset('database-config-components', [
'postgresql' => ['create_standalone_postgresql', PostgresqlGeneral::class, [
'postgres_conf' => 'password_encryption = secret-config-value',
'init_scripts' => [['index' => 0, 'filename' => 'init.sql', 'content' => "CREATE USER app PASSWORD 'secret-config-value';"]],
], ['postgresConf', 'initScripts']],
'mysql' => ['create_standalone_mysql', MysqlGeneral::class, ['mysql_conf' => 'password=secret-config-value'], ['mysqlConf']],
'mariadb' => ['create_standalone_mariadb', MariadbGeneral::class, ['mariadb_conf' => 'password=secret-config-value'], ['mariadbConf']],
'mongodb' => ['create_standalone_mongodb', MongodbGeneral::class, ['mongo_conf' => 'keyFile: secret-config-value'], ['mongoConf']],
'redis' => ['create_standalone_redis', RedisGeneral::class, ['redis_conf' => 'requirepass secret-config-value'], ['redisConf']],
'keydb' => ['create_standalone_keydb', KeydbGeneral::class, ['keydb_conf' => 'requirepass secret-config-value'], ['keydbConf']],
]);
function createDatabaseWithConfig(string $createDatabase, array $config): mixed
{
$database = $createDatabase(test()->environment->id, test()->destination);
$database->forceFill($config)->save();
return $database->fresh();
}
function lockRedisPassword(StandaloneRedis $redis, ?string $value = null): void
{
$redis->runtime_environment_variables()->where('key', 'REDIS_PASSWORD')->firstOrFail()
->forceFill(array_filter(['is_shown_once' => true, 'value' => $value], fn ($attribute) => $attribute !== null))
->save();
}
it('keeps custom configs and init scripts out of member state and html', function (string $createDatabase, string $generalClass, array $config, array $properties) {
$database = createDatabaseWithConfig($createDatabase, $config);
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
$general = Livewire::test($generalClass, ['database' => $database]);
if (method_exists($generalClass, 'refresh')) {
$general->call('refresh');
}
foreach ($properties as $property) {
$general->assertSet($property, null);
}
expect(json_encode($general->snapshot))->not->toContain('secret-config-value')
->and($general->html())->not->toContain('secret-config-value')
->and($general->html())->toContain('Hidden (only admins can view)');
})->with('database-config-components');
it('keeps custom configs out of a member who switched the session to a team they own', function (string $createDatabase, string $generalClass, array $config, array $properties) {
$database = createDatabaseWithConfig($createDatabase, $config);
$ownTeam = Team::factory()->create();
$ownTeam->members()->attach($this->member->id, ['role' => 'owner']);
$this->actingAs($this->member);
session(['currentTeam' => $ownTeam]);
$general = Livewire::test($generalClass, ['database' => $database]);
foreach ($properties as $property) {
$general->assertSet($property, null);
}
expect(json_encode($general->snapshot))->not->toContain('secret-config-value')
->and($general->html())->not->toContain('secret-config-value');
})->with('database-config-components');
it('keeps custom configs and init scripts available to an owner', function (string $createDatabase, string $generalClass, array $config, array $properties) {
$database = createDatabaseWithConfig($createDatabase, $config);
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
$general = Livewire::test($generalClass, ['database' => $database]);
foreach (array_values($config) as $index => $value) {
$general->assertSet($properties[$index], $value);
}
})->with('database-config-components');
it('does not let a member save custom configs', function () {
$database = createDatabaseWithConfig('create_standalone_redis', ['redis_conf' => 'requirepass secret-config-value']);
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(RedisGeneral::class, ['database' => $database])
->set('redisConf', 'requirepass changed')
->call('submit');
expect($database->fresh()->redis_conf)->toBe('requirepass secret-config-value');
});
it('does not show a locked redis password to an owner in the form or the connection urls', function () {
$redis = create_standalone_redis($this->environment->id, $this->destination, ['redis_password' => 'locked-redis-secret']);
lockRedisPassword($redis);
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
$general = Livewire::test(RedisGeneral::class, ['database' => $redis->fresh()])
->assertSet('redisPassword', null)
->assertSet('isPasswordLocked', true);
expect(json_encode($general->snapshot))->not->toContain('locked-redis-secret')
->and($general->html())->not->toContain('locked-redis-secret')
->and($general->html())->toContain('Locked (shown once)');
$statusInfo = Livewire::test(RedisStatusInfo::class, ['database' => $redis->fresh()])
->assertSet('dbUrl', null)
->assertSet('dbUrlPublic', null);
expect(json_encode($statusInfo->snapshot))->not->toContain('locked-redis-secret')
->and($statusInfo->html())->not->toContain('locked-redis-secret')
->and($statusInfo->html())->toContain('Locked (shown once)');
});
it('does not show a locked shared variable that the redis password reads in the connection urls', function () {
$redis = create_standalone_redis($this->environment->id, $this->destination);
SharedEnvironmentVariable::create([
'key' => 'REDIS_SECRET',
'value' => 'locked-shared-secret',
'type' => 'team',
'team_id' => $this->team->id,
'is_shown_once' => true,
]);
$redis->runtime_environment_variables()->where('key', 'REDIS_PASSWORD')->firstOrFail()
->forceFill(['value' => '{{team.REDIS_SECRET}}'])->save();
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
$statusInfo = Livewire::test(RedisStatusInfo::class, ['database' => $redis->fresh()])
->assertSet('dbUrl', null);
expect(json_encode($statusInfo->snapshot))->not->toContain('locked-shared-secret');
});
it('does not show a locked REDIS_PASSWORD override in the keydb and dragonfly connection urls', function (string $createDatabase, string $statusInfoClass) {
$database = $createDatabase($this->environment->id, $this->destination);
$database->forceFill(['legacy_password_quoting' => false])->save();
EnvironmentVariable::create([
'key' => 'REDIS_PASSWORD',
'value' => 'locked-override-secret',
'is_shown_once' => true,
'resourceable_type' => $database::class,
'resourceable_id' => $database->id,
]);
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
$statusInfo = Livewire::test($statusInfoClass, ['database' => $database->fresh()])
->assertSet('dbUrl', null);
expect(json_encode($statusInfo->snapshot))->not->toContain('locked-override-secret')
->and($statusInfo->html())->toContain('Locked (shown once)');
})->with([
'keydb' => ['create_standalone_keydb', KeydbStatusInfo::class],
'dragonfly' => ['create_standalone_dragonfly', DragonflyStatusInfo::class],
]);
it('keeps a locked redis password when an owner saves the form', function () {
$redis = create_standalone_redis($this->environment->id, $this->destination, ['redis_password' => 'locked-redis-secret']);
lockRedisPassword($redis);
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
Livewire::test(RedisGeneral::class, ['database' => $redis->fresh()])
->set('name', 'renamed-redis')
->call('submit')
->assertHasNoErrors();
$redis = $redis->fresh();
expect($redis->name)->toBe('renamed-redis')
->and(EnvironmentVariable::query()->where('resourceable_type', StandaloneRedis::class)
->where('resourceable_id', $redis->id)->where('key', 'REDIS_PASSWORD')->firstOrFail()->value)
->toBe('locked-redis-secret');
});
it('redacts a locked redis password and its connection urls in the api', function () {
$redis = create_standalone_redis($this->environment->id, $this->destination, ['redis_password' => 'locked-redis-secret']);
lockRedisPassword($redis);
session(['currentTeam' => $this->team]);
$token = $this->owner->createToken('locked-test', ['read', 'read:sensitive']);
DB::table('personal_access_tokens')->where('id', $token->accessToken->id)->update(['team_id' => $this->team->id]);
$headers = ['Authorization' => 'Bearer '.$token->plainTextToken];
$single = $this->withHeaders($headers)->getJson("/api/v1/databases/{$redis->uuid}")->assertOk();
expect($single->getContent())->not->toContain('locked-redis-secret')
->and($single->json('internal_db_url'))->toBeNull();
$list = $this->withHeaders($headers)->getJson('/api/v1/databases')->assertOk();
expect($list->getContent())->not->toContain('locked-redis-secret');
});
it('still returns the connection url of an unlocked redis password in the api', function () {
$redis = create_standalone_redis($this->environment->id, $this->destination, ['redis_password' => 'visible-redis-secret']);
session(['currentTeam' => $this->team]);
$token = $this->owner->createToken('unlocked-test', ['read', 'read:sensitive']);
DB::table('personal_access_tokens')->where('id', $token->accessToken->id)->update(['team_id' => $this->team->id]);
$this->withHeaders(['Authorization' => 'Bearer '.$token->plainTextToken])
->getJson("/api/v1/databases/{$redis->uuid}")
->assertOk()
->assertJsonPath('internal_db_url', "redis://default:visible-redis-secret@{$redis->uuid}:6379/0");
});
@@ -0,0 +1,113 @@
<?php
use App\Livewire\Project\Shared\EnvironmentVariable\All as EnvironmentVariables;
use App\Livewire\Project\Shared\EnvironmentVariable\Show;
use App\Models\Application;
use App\Models\EnvironmentVariable;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->withoutVite();
InstanceSettings::forceCreate(['id' => 0]);
$this->team = Team::factory()->create();
$this->owner = User::factory()->create();
$this->member = User::factory()->create();
$this->team->members()->attach($this->owner, ['role' => 'owner']);
$this->team->members()->attach($this->member, ['role' => 'member']);
$server = Server::factory()->create(['team_id' => $this->team->id]);
$destination = $server->standaloneDockers()->firstOrFail();
$project = Project::factory()->create(['team_id' => $this->team->id]);
$this->application = Application::factory()->create([
'build_pack' => 'dockercompose',
'docker_compose_raw' => "services:\n app:\n image: nginx:alpine\n environment:\n DB_PASSWORD: \${DB_PASSWORD:-hunter2}\n",
'environment_id' => $project->environments()->firstOrFail()->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
])->fresh();
$this->environmentVariable = EnvironmentVariable::create([
'key' => 'DB_PASSWORD',
'value' => 'stored-value',
'resourceable_type' => Application::class,
'resourceable_id' => $this->application->id,
]);
});
function mountComposeDefaultComponents(): array
{
$all = Livewire::test(EnvironmentVariables::class, ['resource' => test()->application])
->call('loadEnvironmentVariables');
$show = Livewire::test(Show::class, [
'env' => test()->environmentVariable,
'type' => 'application',
'composeInfo' => dockerComposeEnvironmentVariableInfo(test()->application->docker_compose_raw, 'DB_PASSWORD'),
]);
return [$all, $show];
}
it('hides compose defaults from a member', function (bool $sessionOnOtherTeam) {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
if ($sessionOnOtherTeam) {
$otherTeam = Team::factory()->create();
$otherTeam->members()->attach($this->member, ['role' => 'owner']);
session(['currentTeam' => $otherTeam]);
}
[$all, $show] = mountComposeDefaultComponents();
expect($all->instance()->environmentVariablePageRows->first()['composeInfo'])->toBe([
'services' => ['app'],
'default' => null,
'operator' => ':-',
'required' => false,
])
->and($show->get('composeInfo')['default'])->toBeNull()
->and($show->get('composeInfo')['services'])->toBe(['app']);
foreach ([$all, $show] as $component) {
expect(json_encode($component->snapshot).$component->html())->not->toContain('hunter2');
}
})->with([
'session on resource team' => false,
'session switched to another team' => true,
]);
it('shows compose defaults to an owner', function () {
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
[$all, $show] = mountComposeDefaultComponents();
expect($all->instance()->environmentVariablePageRows->first()['composeInfo']['default'])->toBe('hunter2')
->and($all->html())->toContain('hunter2');
$show->assertSet('composeInfo.default', 'hunter2')
->assertSee('Compose default');
});
it('does not let a member apply the compose default', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(Show::class, [
'env' => $this->environmentVariable,
'type' => 'application',
'composeInfo' => dockerComposeEnvironmentVariableInfo($this->application->docker_compose_raw, 'DB_PASSWORD'),
])->call('useComposeDefault')->assertForbidden();
expect($this->environmentVariable->fresh()->value)->toBe('stored-value');
});
@@ -0,0 +1,166 @@
<?php
use App\Livewire\Project\Application\General;
use App\Livewire\Project\Service\EditCompose;
use App\Livewire\Project\Service\StackForm;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\SharedEnvironmentVariable;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
const LOCKED_COMPOSE_RAW = <<<'YAML'
services:
web:
image: nginx:alpine
environment:
LOCKED_TOKEN: ""
PLAIN_TOKEN: ""
TEAM_SECRET: '{{team.TEAM_SECRET}}'
YAML;
beforeEach(function () {
Queue::fake();
config()->set('app.maintenance.store', 'array');
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
$this->team = Team::factory()->create();
$this->owner = User::factory()->create();
$this->team->members()->attach($this->owner, ['role' => 'owner']);
$server = Server::factory()->create(['team_id' => $this->team->id]);
$destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
SharedEnvironmentVariable::create([
'type' => 'team',
'key' => 'TEAM_SECRET',
'value' => 'team-shared-locked-secret',
'team_id' => $this->team->id,
'is_shown_once' => true,
]);
$this->service = Service::factory()->create([
'environment_id' => $this->environment->id,
'server_id' => $server->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
'docker_compose_raw' => LOCKED_COMPOSE_RAW,
]);
$this->application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
'build_pack' => 'dockercompose',
'docker_compose_raw' => LOCKED_COMPOSE_RAW,
]);
foreach ([$this->service, $this->application] as $resource) {
$resource->environment_variables()->create(['key' => 'LOCKED_TOKEN', 'value' => 'env-locked-secret-value', 'is_shown_once' => true]);
$resource->environment_variables()->create(['key' => 'PLAIN_TOKEN', 'value' => 'plain-visible-value']);
$resource->parse();
$resource->refresh();
}
});
function lockedComposeApiToken(): string
{
$plainTextToken = Str::random(40);
$token = test()->owner->tokens()->create([
'name' => 'test-token',
'token' => hash('sha256', $plainTextToken),
'abilities' => ['read', 'read:sensitive'],
'team_id' => test()->team->id,
]);
return $token->getKey().'|'.$plainTextToken;
}
function expectLockedValuesRedacted(?string $text): void
{
expect($text)->not->toContain('env-locked-secret-value')
->not->toContain('team-shared-locked-secret')
->toContain('<locked>')
->toContain('plain-visible-value');
}
function expectStoredComposeKeepsRealValues(Application|Service $resource): void
{
expect($resource->fresh()->docker_compose)
->not->toContain('<locked>')
->toContain('env-locked-secret-value')
->toContain('team-shared-locked-secret');
}
it('writes the locked values into the generated compose that deployments use', function (string $resource) {
expectStoredComposeKeepsRealValues($this->{$resource});
})->with(['service', 'application']);
it('hides locked values in the service compose that an owner sees', function (string $component) {
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
$livewire = Livewire::test($component, ['service' => $this->service, 'serviceId' => $this->service->id]);
expectLockedValuesRedacted($livewire->get('dockerCompose'));
expect(json_encode($livewire->snapshot).$livewire->html())
->not->toContain('env-locked-secret-value')
->not->toContain('team-shared-locked-secret');
})->with(['stack form' => [StackForm::class], 'compose editor' => [EditCompose::class]]);
it('hides locked values in the application compose that an owner sees', function () {
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
$livewire = Livewire::test(General::class, ['application' => $this->application]);
expectLockedValuesRedacted($livewire->get('dockerCompose'));
expect(json_encode($livewire->snapshot).$livewire->html())
->not->toContain('env-locked-secret-value')
->not->toContain('team-shared-locked-secret');
});
it('keeps the real generated compose when an owner saves the service stack form', function () {
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
Livewire::test(StackForm::class, ['service' => $this->service])
->call('submit')
->assertHasNoErrors();
expectStoredComposeKeepsRealValues($this->service);
});
it('keeps the real generated compose when an owner saves the application form', function () {
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
Livewire::test(General::class, ['application' => $this->application])
->call('submit')
->assertHasNoErrors();
expectStoredComposeKeepsRealValues($this->application);
});
it('hides locked values in the generated compose returned by the API', function (string $resource) {
$path = $resource === 'service'
? '/api/v1/services/'.$this->service->uuid
: '/api/v1/applications/'.$this->application->uuid;
$response = $this->withToken(lockedComposeApiToken())->getJson($path)->assertOk();
expectLockedValuesRedacted($response->json('docker_compose'));
expect($response->json('docker_compose_raw'))->toBe(LOCKED_COMPOSE_RAW);
})->with(['service', 'application']);
@@ -87,3 +87,25 @@ it('hides locked extra-field passwords from an owner and keeps them on save', fu
expect($this->masterPassword->fresh()->value)->toBe('drizzle-master-secret');
});
it('hides an extra field that references a locked variable and keeps both on save', function () {
$this->masterPassword->update(['value' => '$SERVICE_PASSWORD_64_DRIZZLE']);
$lockedTarget = $this->service->environment_variables()->create([
'key' => 'SERVICE_PASSWORD_64_DRIZZLE',
'value' => 'referenced-locked-secret',
'is_shown_once' => true,
]);
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
$component = mountStackForm();
expect($component->get('fields')['SERVICE_PASSWORD_DRIZZLE']['value'])->toBeNull()
->and(json_encode($component->snapshot).$component->html())->not->toContain('referenced-locked-secret');
$component->set('fields.SERVICE_PASSWORD_DRIZZLE.isLocked', false)
->set('fields.SERVICE_PASSWORD_DRIZZLE.value', 'attacker-value')
->call('submit');
expect($this->masterPassword->fresh()->value)->toBe('$SERVICE_PASSWORD_64_DRIZZLE')
->and($lockedTarget->fresh()->value)->toBe('referenced-locked-secret');
});
@@ -144,6 +144,28 @@ test('transfer to another instance runs in a queued job', function () {
expect(data_get($this->server->fresh()->server_metadata, 'migration.status'))->toBe('running');
});
test('a team admin cannot start a transfer, because it sends every secret to the target', function () {
Queue::fake();
Http::fake(['*/api/v1/servers/import/check*' => Http::response(['message' => 'ok', 'checks' => []])]);
$admin = User::factory()->create();
$this->team->members()->attach($admin->id, ['role' => 'admin']);
$this->actingAs($admin);
Livewire::test(Transfer::class, ['server_uuid' => $this->server->uuid])
->set('targetUrl', 'https://8.8.8.8')
->set('targetToken', 'target-token')
->call('migrateServer')
->assertSet('targetToken', '');
Queue::assertNotPushed(MigrateServerToInstanceJob::class);
expect(data_get($this->server->fresh()->server_metadata, 'migration'))->toBeNull();
});
test('the transfer confirmation says that locked secrets are sent to the target', function () {
Livewire::test(Transfer::class, ['server_uuid' => $this->server->uuid])
->assertSee('every environment variable value, also locked ones');
});
test('transfer page shows the error of a failed transfer', function () {
$this->server->forceFill(['server_metadata' => ['migration' => [
'status' => 'failed',
@@ -407,6 +407,33 @@ it('describes added unlocked environment variables with their value', function (
->and(json_encode($diff->toArray()))->toContain('new-secret');
});
it('hides the old value of an environment variable that was locked after the deployment and then deleted', function () {
$application = snapshotTestApplication();
$variable = EnvironmentVariable::create([
'key' => 'API_TOKEN',
'value' => 'deployed-secret',
'is_buildtime' => false,
'is_runtime' => true,
'is_preview' => false,
'is_shown_once' => false,
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
markSnapshotTestApplicationDeployed($application->refresh());
$variable->update(['is_shown_once' => true]);
$variable->delete();
$diff = $application->refresh()->pendingDeploymentConfigurationDiff();
$change = collect($diff->changes())->firstWhere('label', 'API_TOKEN');
expect($change)->not->toBeNull()
->and($change['type'])->toBe('removed')
->and($change['sensitive'])->toBeTrue()
->and($change['old_display_value'])->toBe('••••••••')
->and($change['new_display_value'])->toBe('-')
->and(json_encode($diff->toArray()))->not->toContain('deployed-secret');
});
it('does not report Docker Compose preview volumes as pending production changes', function () {
$application = snapshotTestApplication(['build_pack' => 'dockercompose']);
$previewVolume = fn (): LocalPersistentVolume => LocalPersistentVolume::create([