fix(auth): enforce resource permissions for stored secrets

This commit is contained in:
Andras Bacsai
2026-10-24 18:26:55 +02:00
parent bf579df036
commit 6fc14f3615
20 changed files with 224 additions and 29 deletions
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
+24 -10
View File
@@ -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
@@ -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.';
+1 -1
View File
@@ -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()
+1 -1
View File
@@ -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.');
}
});
-1
View File
@@ -75,7 +75,6 @@ trait HasDatabaseStatusInfo
public function mount(): void
{
$this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false;
$this->refresh();
}
@@ -77,7 +77,9 @@
<x-helper helper="Variable name: {{ $serviceName }}" />
@endif
</div>
@if ($isPasswordHiddenForMember && data_get($field, 'isPassword'))
@if (data_get($field, 'isLocked'))
<x-forms.input disabled value="Locked (shown once)" />
@elseif ($isPasswordHiddenForMember && data_get($field, 'isPassword'))
<x-forms.input disabled value="Hidden (only admins can view)" />
@else
<x-forms.input canGate="update" :canResource="$service"
@@ -29,7 +29,7 @@
</div>
</div>
@if (auth()->user()->isAdmin())
@can('update', $server)
<div wire:key="terminal-access-change-{{ $isTerminalEnabled }}">
<x-modal-confirmation title="Confirm Terminal Access Change?"
temporaryDisableTwoStepConfirmation
@@ -44,7 +44,7 @@
step3ButtonText="{{ $isTerminalEnabled ? 'Disable Terminal' : 'Enable Terminal' }}"
:isHighlightedButton="!$isTerminalEnabled" />
</div>
@endif
@endcan
</div>
</x-application.settings-section>
</div>
@@ -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');
@@ -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', '');
});
@@ -0,0 +1,89 @@
<?php
use App\Livewire\Project\Service\StackForm;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::create(['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->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');
});
@@ -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');
});
@@ -0,0 +1,50 @@
<?php
use App\Livewire\Server\Security\TerminalAccess;
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 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();
});