From 6fc14f3615bd133bd5637851ef48c919f79906ee Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 24 Oct 2026 18:26:55 +0200 Subject: [PATCH] fix(auth): enforce resource permissions for stored secrets --- .../Project/Database/Clickhouse/General.php | 2 +- .../Project/Database/Dragonfly/General.php | 2 +- .../Project/Database/Keydb/General.php | 2 +- .../Project/Database/Mariadb/General.php | 2 +- .../Project/Database/Mongodb/General.php | 2 +- .../Project/Database/Mysql/General.php | 2 +- .../Project/Database/Postgresql/General.php | 2 +- .../Project/Database/Redis/General.php | 2 +- app/Livewire/Project/Service/StackForm.php | 34 ++++--- .../Server/Security/TerminalAccess.php | 5 -- app/Livewire/Storage/Form.php | 2 +- app/Models/Team.php | 2 +- app/Traits/HasDatabaseStatusInfo.php | 1 - .../project/service/stack-form.blade.php | 4 +- .../server/security/terminal-access.blade.php | 4 +- .../DatabaseCredentialVisibilityTest.php | 12 +++ .../LivewireStoredSecretAuthorizationTest.php | 13 +++ .../StackFormPasswordVisibilityTest.php | 89 +++++++++++++++++++ .../Authorization/TeamAuthorizationTest.php | 21 +++++ .../TerminalAccessAuthorizationTest.php | 50 +++++++++++ 20 files changed, 224 insertions(+), 29 deletions(-) create mode 100644 tests/Feature/Authorization/StackFormPasswordVisibilityTest.php create mode 100644 tests/Feature/Authorization/TerminalAccessAuthorizationTest.php 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(); +});