mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
fix(auth): enforce resource permissions for stored secrets
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.';
|
||||
|
||||
@@ -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
@@ -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.');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user