diff --git a/app/Livewire/Project/Database/Clickhouse/General.php b/app/Livewire/Project/Database/Clickhouse/General.php
index 2cd0bfecee..68452e78af 100644
--- a/app/Livewire/Project/Database/Clickhouse/General.php
+++ b/app/Livewire/Project/Database/Clickhouse/General.php
@@ -73,7 +73,7 @@ class General extends Component
return handleError($e, $this);
}
- $this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false;
+ $this->isPasswordHiddenForMember = ! (auth()->user()?->can('update', $this->database) ?? false);
}
protected function rules(): array
diff --git a/app/Livewire/Project/Database/Dragonfly/General.php b/app/Livewire/Project/Database/Dragonfly/General.php
index b3c5bcc945..3c6b27e306 100644
--- a/app/Livewire/Project/Database/Dragonfly/General.php
+++ b/app/Livewire/Project/Database/Dragonfly/General.php
@@ -65,7 +65,7 @@ class General extends Component
return handleError($e, $this);
}
- $this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false;
+ $this->isPasswordHiddenForMember = ! (auth()->user()?->can('update', $this->database) ?? false);
}
protected function rules(): array
diff --git a/app/Livewire/Project/Database/Keydb/General.php b/app/Livewire/Project/Database/Keydb/General.php
index fd84bad209..b677e6ec59 100644
--- a/app/Livewire/Project/Database/Keydb/General.php
+++ b/app/Livewire/Project/Database/Keydb/General.php
@@ -67,7 +67,7 @@ class General extends Component
return handleError($e, $this);
}
- $this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false;
+ $this->isPasswordHiddenForMember = ! (auth()->user()?->can('update', $this->database) ?? false);
}
protected function rules(): array
diff --git a/app/Livewire/Project/Database/Mariadb/General.php b/app/Livewire/Project/Database/Mariadb/General.php
index 747389522d..d12a1f74b3 100644
--- a/app/Livewire/Project/Database/Mariadb/General.php
+++ b/app/Livewire/Project/Database/Mariadb/General.php
@@ -129,7 +129,7 @@ class General extends Component
return handleError($e, $this);
}
- $this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false;
+ $this->isPasswordHiddenForMember = ! (auth()->user()?->can('update', $this->database) ?? false);
}
private function syncData(bool $toModel = false): void
diff --git a/app/Livewire/Project/Database/Mongodb/General.php b/app/Livewire/Project/Database/Mongodb/General.php
index 517a3e5a42..e1d029dfd6 100644
--- a/app/Livewire/Project/Database/Mongodb/General.php
+++ b/app/Livewire/Project/Database/Mongodb/General.php
@@ -122,7 +122,7 @@ class General extends Component
return handleError($e, $this);
}
- $this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false;
+ $this->isPasswordHiddenForMember = ! (auth()->user()?->can('update', $this->database) ?? false);
}
private function syncData(bool $toModel = false): void
diff --git a/app/Livewire/Project/Database/Mysql/General.php b/app/Livewire/Project/Database/Mysql/General.php
index ddac8ab829..65c3a71e5f 100644
--- a/app/Livewire/Project/Database/Mysql/General.php
+++ b/app/Livewire/Project/Database/Mysql/General.php
@@ -129,7 +129,7 @@ class General extends Component
return handleError($e, $this);
}
- $this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false;
+ $this->isPasswordHiddenForMember = ! (auth()->user()?->can('update', $this->database) ?? false);
}
private function syncData(bool $toModel = false): void
diff --git a/app/Livewire/Project/Database/Postgresql/General.php b/app/Livewire/Project/Database/Postgresql/General.php
index 3d11fa6b7a..625a3fa8a3 100644
--- a/app/Livewire/Project/Database/Postgresql/General.php
+++ b/app/Livewire/Project/Database/Postgresql/General.php
@@ -143,7 +143,7 @@ class General extends Component
return handleError($e, $this);
}
- $this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false;
+ $this->isPasswordHiddenForMember = ! (auth()->user()?->can('update', $this->database) ?? false);
}
private function syncData(bool $toModel = false): void
diff --git a/app/Livewire/Project/Database/Redis/General.php b/app/Livewire/Project/Database/Redis/General.php
index 3ab0d3eda9..afa8e3b571 100644
--- a/app/Livewire/Project/Database/Redis/General.php
+++ b/app/Livewire/Project/Database/Redis/General.php
@@ -122,7 +122,7 @@ class General extends Component
return handleError($e, $this);
}
- $this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false;
+ $this->isPasswordHiddenForMember = ! (auth()->user()?->can('update', $this->database) ?? false);
}
private function syncData(bool $toModel = false): void
diff --git a/app/Livewire/Project/Service/StackForm.php b/app/Livewire/Project/Service/StackForm.php
index 9c55156489..d8cb7dacf4 100644
--- a/app/Livewire/Project/Service/StackForm.php
+++ b/app/Livewire/Project/Service/StackForm.php
@@ -45,6 +45,9 @@ class StackForm extends Component
// Add dynamic field rules
foreach ($this->fields ?? collect() as $key => $field) {
+ if (data_get($field, 'isLocked')) {
+ continue;
+ }
$rules = data_get($field, 'rules', 'nullable');
$baseRules["fields.$key.value"] = $rules;
}
@@ -128,16 +131,24 @@ class StackForm extends Component
return $group;
});
- $this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false;
- if ($this->isPasswordHiddenForMember) {
- $this->fields = $this->fields->map(function ($field) {
- if (data_get($field, 'isPassword')) {
- $field['value'] = null;
- }
+ $this->isPasswordHiddenForMember = ! (auth()->user()?->can('manageEnvironment', $this->service) ?? false);
+ $lockedKeys = $this->lockedFieldKeys();
+ $this->fields = $this->fields->map(function ($field) use ($lockedKeys) {
+ $field['isLocked'] = $lockedKeys->contains(data_get($field, 'key'));
+ if ($field['isLocked'] || ($this->isPasswordHiddenForMember && data_get($field, 'isPassword'))) {
+ $field['value'] = null;
+ }
- return $field;
- });
- }
+ return $field;
+ });
+ }
+
+ /**
+ * Keys of the service environment variables that are locked (shown once).
+ */
+ private function lockedFieldKeys(): Collection
+ {
+ return $this->service->environment_variables()->where('is_shown_once', true)->pluck('key');
}
public function saveCompose($raw)
@@ -173,7 +184,10 @@ class StackForm extends Component
// Use transaction to ensure atomicity - if parse fails, save is rolled back
DB::transaction(function () {
$this->service->save();
- $this->service->saveExtraFields($this->fields);
+ $lockedKeys = $this->lockedFieldKeys();
+ $this->service->saveExtraFields(
+ $this->fields->reject(fn ($field) => $lockedKeys->contains(data_get($field, 'key')))
+ );
$this->service->parse();
});
// Refresh and write files after a successful commit
diff --git a/app/Livewire/Server/Security/TerminalAccess.php b/app/Livewire/Server/Security/TerminalAccess.php
index 2406bdcb33..49b7e9bc06 100644
--- a/app/Livewire/Server/Security/TerminalAccess.php
+++ b/app/Livewire/Server/Security/TerminalAccess.php
@@ -36,11 +36,6 @@ class TerminalAccess extends Component
try {
$this->authorize('update', $this->server);
- // Check if user is admin or owner
- if (! auth()->user()->isAdmin()) {
- throw new \Exception('Only team administrators and owners can modify terminal access.');
- }
-
// Verify password
if (! verifyPasswordConfirmation($password, $this)) {
return 'The provided password is incorrect.';
diff --git a/app/Livewire/Storage/Form.php b/app/Livewire/Storage/Form.php
index ebf43d5019..6cd161306e 100644
--- a/app/Livewire/Storage/Form.php
+++ b/app/Livewire/Storage/Form.php
@@ -121,7 +121,7 @@ class Form extends Component
{
$this->syncData(false);
- $this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false;
+ $this->isPasswordHiddenForMember = ! (auth()->user()?->can('update', $this->storage) ?? false);
}
public function testConnection()
diff --git a/app/Models/Team.php b/app/Models/Team.php
index 9d02d229a5..fb4076eaec 100644
--- a/app/Models/Team.php
+++ b/app/Models/Team.php
@@ -80,7 +80,7 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen
});
static::updating(function ($team) {
- if (auth()->user()?->isMember()) {
+ if (auth()->user()?->roleInTeam($team->id) === 'member') {
throw new \Exception('You are not allowed to update this team.');
}
});
diff --git a/app/Traits/HasDatabaseStatusInfo.php b/app/Traits/HasDatabaseStatusInfo.php
index 33a8be0d0c..edbc01aaae 100644
--- a/app/Traits/HasDatabaseStatusInfo.php
+++ b/app/Traits/HasDatabaseStatusInfo.php
@@ -75,7 +75,6 @@ trait HasDatabaseStatusInfo
public function mount(): void
{
- $this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false;
$this->refresh();
}
diff --git a/resources/views/livewire/project/service/stack-form.blade.php b/resources/views/livewire/project/service/stack-form.blade.php
index e9a07c199b..59405e03f1 100644
--- a/resources/views/livewire/project/service/stack-form.blade.php
+++ b/resources/views/livewire/project/service/stack-form.blade.php
@@ -77,7 +77,9 @@
@endif
- @if ($isPasswordHiddenForMember && data_get($field, 'isPassword'))
+ @if (data_get($field, 'isLocked'))
+
+ @elseif ($isPasswordHiddenForMember && data_get($field, 'isPassword'))
@else
- @if (auth()->user()->isAdmin())
+ @can('update', $server)
- @endif
+ @endcan
diff --git a/tests/Feature/Authorization/DatabaseCredentialVisibilityTest.php b/tests/Feature/Authorization/DatabaseCredentialVisibilityTest.php
index 0ce2d7b624..a8540d6cf6 100644
--- a/tests/Feature/Authorization/DatabaseCredentialVisibilityTest.php
+++ b/tests/Feature/Authorization/DatabaseCredentialVisibilityTest.php
@@ -97,3 +97,15 @@ it('keeps database credentials available to an owner', function (string $createD
->call('refresh')
->assertSet('dbUrl', $database->fresh()->internal_db_url);
})->with('database-credential-components');
+
+it('hides the password fields for a member after the session switches to a team they own', function (string $createDatabase, string $generalClass, string $statusInfoClass, array $credentials) {
+ $database = createDatabaseWithCredential($createDatabase, $credentials);
+ $ownTeam = Team::factory()->create();
+ $ownTeam->members()->attach($this->member->id, ['role' => 'owner']);
+
+ $this->actingAs($this->member);
+ session(['currentTeam' => $ownTeam]);
+
+ Livewire::test($generalClass, ['database' => $database])->assertSet('isPasswordHiddenForMember', true);
+ Livewire::test($statusInfoClass, ['database' => $database])->assertSet('isPasswordHiddenForMember', true);
+})->with('database-credential-components');
diff --git a/tests/Feature/Authorization/LivewireStoredSecretAuthorizationTest.php b/tests/Feature/Authorization/LivewireStoredSecretAuthorizationTest.php
index d1f16f0186..d1b1cde601 100644
--- a/tests/Feature/Authorization/LivewireStoredSecretAuthorizationTest.php
+++ b/tests/Feature/Authorization/LivewireStoredSecretAuthorizationTest.php
@@ -158,3 +158,16 @@ it('keeps S3 credentials available to an owner who can update the storage', func
->assertSet('key', 'stored-access-key')
->assertSet('secret', 'stored-secret-key');
});
+
+it('hides the S3 credential fields for a member after the session switches to a team they own', function () {
+ $storage = createS3StorageWithSecrets($this->team);
+ $ownTeam = Team::factory()->create();
+ $ownTeam->members()->attach($this->member, ['role' => 'owner']);
+
+ $this->actingAs($this->member);
+ session(['currentTeam' => $ownTeam]);
+
+ Livewire::test(StorageForm::class, ['storage' => $storage])
+ ->assertSet('isPasswordHiddenForMember', true)
+ ->assertSet('secret', '');
+});
diff --git a/tests/Feature/Authorization/StackFormPasswordVisibilityTest.php b/tests/Feature/Authorization/StackFormPasswordVisibilityTest.php
new file mode 100644
index 0000000000..8eafed0f81
--- /dev/null
+++ b/tests/Feature/Authorization/StackFormPasswordVisibilityTest.php
@@ -0,0 +1,89 @@
+ 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->service = Service::factory()->create([
+ 'docker_compose_raw' => "services:\n drizzle:\n image: ghcr.io/drizzle-team/gateway:latest\n",
+ 'server_id' => $server->id,
+ 'destination_id' => $destination->id,
+ 'destination_type' => $destination->getMorphClass(),
+ 'environment_id' => $project->environments()->firstOrFail()->id,
+ ]);
+ ServiceApplication::create([
+ 'uuid' => (string) Str::uuid(),
+ 'service_id' => $this->service->id,
+ 'name' => 'drizzle',
+ 'image' => 'ghcr.io/drizzle-team/gateway:latest',
+ ]);
+ $this->masterPassword = $this->service->environment_variables()->create([
+ 'key' => 'SERVICE_PASSWORD_DRIZZLE',
+ 'value' => 'drizzle-master-secret',
+ ]);
+});
+
+function mountStackForm(): mixed
+{
+ return Livewire::test(StackForm::class, ['service' => test()->service]);
+}
+
+function expectNoMasterPassword(mixed $component): void
+{
+ expect($component->get('fields')['SERVICE_PASSWORD_DRIZZLE']['value'])->toBeNull()
+ ->and(json_encode($component->snapshot).$component->html())->not->toContain('drizzle-master-secret');
+}
+
+it('hides extra-field passwords from a member after the session switches to a team they own', function () {
+ $ownTeam = Team::factory()->create();
+ $ownTeam->members()->attach($this->member, ['role' => 'owner']);
+ $this->actingAs($this->member);
+ session(['currentTeam' => $ownTeam]);
+
+ expectNoMasterPassword(mountStackForm());
+});
+
+it('shows extra-field passwords to an owner of the service team', function () {
+ $this->actingAs($this->owner);
+ session(['currentTeam' => $this->team]);
+
+ expect(mountStackForm()->get('fields')['SERVICE_PASSWORD_DRIZZLE']['value'])->toBe('drizzle-master-secret');
+});
+
+it('hides locked extra-field passwords from an owner and keeps them on save', function () {
+ $this->masterPassword->update(['is_shown_once' => true]);
+ $this->actingAs($this->owner);
+ session(['currentTeam' => $this->team]);
+
+ $component = mountStackForm();
+ expectNoMasterPassword($component);
+
+ $component->set('fields.SERVICE_PASSWORD_DRIZZLE.isLocked', false)
+ ->set('fields.SERVICE_PASSWORD_DRIZZLE.value', 'attacker-value')
+ ->call('submit');
+
+ expect($this->masterPassword->fresh()->value)->toBe('drizzle-master-secret');
+});
diff --git a/tests/Feature/Authorization/TeamAuthorizationTest.php b/tests/Feature/Authorization/TeamAuthorizationTest.php
index 7d437c823f..66c6cb4cb8 100644
--- a/tests/Feature/Authorization/TeamAuthorizationTest.php
+++ b/tests/Feature/Authorization/TeamAuthorizationTest.php
@@ -385,3 +385,24 @@ test('member cannot update an existing team at model level', function () {
expect(fn () => $this->team->update(['name' => 'Hacked']))
->toThrow(Exception::class, 'You are not allowed to update this team.');
});
+
+test('member cannot update a team at model level while the session is on a team they own', function () {
+ $ownTeam = Team::factory()->create();
+ $this->member->teams()->attach($ownTeam, ['role' => 'owner']);
+ $this->actingAs($this->member);
+ session(['currentTeam' => $ownTeam]);
+
+ expect(fn () => $this->team->update(['name' => 'Hacked']))
+ ->toThrow(Exception::class, 'You are not allowed to update this team.');
+});
+
+test('owner can update their team at model level while the session is on a team where they are a member', function () {
+ $otherTeam = Team::factory()->create();
+ $this->owner->teams()->attach($otherTeam, ['role' => 'member']);
+ $this->actingAs($this->owner);
+ session(['currentTeam' => $otherTeam]);
+
+ $this->team->update(['name' => 'Renamed']);
+
+ expect($this->team->fresh()->name)->toBe('Renamed');
+});
diff --git a/tests/Feature/Authorization/TerminalAccessAuthorizationTest.php b/tests/Feature/Authorization/TerminalAccessAuthorizationTest.php
new file mode 100644
index 0000000000..a1589ae9f4
--- /dev/null
+++ b/tests/Feature/Authorization/TerminalAccessAuthorizationTest.php
@@ -0,0 +1,50 @@
+ 0]);
+
+ $this->team = Team::factory()->create();
+ $this->otherTeam = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'admin']);
+ $this->otherTeam->members()->attach($this->user->id, ['role' => 'member']);
+
+ $this->server = Server::factory()->create(['team_id' => $this->team->id]);
+ $this->server->settings->update(['is_terminal_enabled' => false]);
+ $this->actingAs($this->user);
+ session(['currentTeam' => $this->team]);
+});
+
+it('lets an admin of the server team toggle terminal access after the session switches to a team where they are a member', function () {
+ $component = Livewire::test(TerminalAccess::class, ['server_uuid' => $this->server->uuid]);
+
+ session(['currentTeam' => $this->otherTeam]);
+ Cache::flush();
+
+ $component->call('toggleTerminal', 'password')->assertHasNoErrors();
+
+ expect($this->server->settings->fresh()->is_terminal_enabled)->toBeTrue();
+});
+
+it('does not let a user toggle terminal access after they become a member of the server team', function () {
+ $component = Livewire::test(TerminalAccess::class, ['server_uuid' => $this->server->uuid]);
+
+ $this->team->members()->updateExistingPivot($this->user->id, ['role' => 'member']);
+ $this->user->unsetRelation('teams');
+ Cache::flush();
+
+ $component->call('toggleTerminal', 'password');
+
+ expect($this->server->settings->fresh()->is_terminal_enabled)->toBeFalse();
+});