fix: restrict proxy content access and scope backup storage to owners

- Hide proxy configuration contents from users without update permission.
- Select and validate backup S3 storage against the resource's team.
- Keep scheduled branch syncs green while a conflict PR remains open.
- Document external mount permissions and Sentinel traffic limits.
- Add regression coverage for authorization and backup storage selection.
This commit is contained in:
Andras Bacsai
2026-10-05 13:20:19 +02:00
parent 4227ec6ebf
commit 439ee6faea
10 changed files with 220 additions and 33 deletions
@@ -0,0 +1,81 @@
<?php
use App\Enums\ProxyTypes;
use App\Livewire\Server\Proxy\DynamicConfigurations;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
Server::flushIdentityMap();
Storage::fake('ssh-keys');
Storage::fake('ssh-mux');
InstanceSettings::forceCreate(['id' => 0]);
$this->team = Team::factory()->create();
$privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $privateKey->id,
'ip' => '203.0.113.20',
'proxy' => ['type' => ProxyTypes::TRAEFIK->value, 'status' => 'running'],
]);
$this->server->settings->forceFill(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false])->save();
Process::fake(function ($process) {
if (str_contains($process->command, 'ls -1')) {
return Process::result(output: "zzfix-auth.yaml\n");
}
if (str_contains($process->command, 'zzfix-auth.yaml')) {
return Process::result(output: "http:\n middlewares:\n auth:\n basicAuth:\n users:\n - 'zzfix:\$apr1\$zzfixsecrethash'\n");
}
return Process::result();
});
});
function actingAsDynamicConfigurationsUser(Team $team, string $role): User
{
$user = User::factory()->create();
$team->members()->attach($user->id, ['role' => $role]);
test()->actingAs($user);
session(['currentTeam' => $team]);
return $user;
}
it('does not load dynamic configuration contents for a team member', function () {
actingAsDynamicConfigurationsUser($this->team, 'member');
$component = Livewire::withQueryParams(['server_uuid' => $this->server->uuid])
->test(DynamicConfigurations::class)
->call('initLoadDynamicConfigurations')
->assertOk()
->assertNotDispatched('error');
expect($component->get('contents')->filter()->all())->toBe([])
->and(json_encode($component->snapshot))->not->toContain('zzfixsecrethash')
->and($component->html())->not->toContain('zzfixsecrethash')
->and($component->html())->toContain('zzfix-auth.yaml');
Process::assertNotRan(fn ($process) => str_contains($process->command, 'head -c'));
});
it('loads dynamic configuration contents for a team admin', function () {
actingAsDynamicConfigurationsUser($this->team, 'admin');
$component = Livewire::withQueryParams(['server_uuid' => $this->server->uuid])
->test(DynamicConfigurations::class)
->call('initLoadDynamicConfigurations')
->assertNotDispatched('error');
expect($component->get('contents')->get('zzfix-auth|yaml'))->toContain('zzfixsecrethash')
->and($component->html())->toContain('zzfixsecrethash');
});
+37
View File
@@ -1220,6 +1220,43 @@ it('creates a local scheduled backup for a persistent volume', function () {
->and($backup->save_s3)->toBeFalse();
});
it('lists and accepts only S3 storages of the resource team when the session team differs', function (bool $rootTeam) {
$team = $rootTeam ? Team::factory()->create(['id' => 0]) : Team::factory()->create();
$user = signInForVolumeBackups($this, $team);
[$application, $volume] = createVolumeBackupApplication($team);
$otherTeam = Team::factory()->create();
$user->teams()->attach($otherTeam, ['role' => 'owner']);
$s3Attributes = ['region' => 'us-east-1', 'key' => 'key', 'secret' => 'secret', 'bucket' => 'bucket', 'endpoint' => 'https://s3.example.com', 'is_usable' => true];
$resourceTeamStorage = S3Storage::create([...$s3Attributes, 'name' => 'Resource team S3', 'team_id' => $team->id]);
$otherTeamStorage = S3Storage::create([...$s3Attributes, 'name' => 'Other team S3', 'team_id' => $otherTeam->id]);
session(['currentTeam' => $otherTeam]);
$component = Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application]);
expect($component->get('availableS3Storages')->pluck('id')->all())->toBe([$resourceTeamStorage->id])
->and($component->get('s3StorageId'))->toBe($resourceTeamStorage->id);
$component->set('frequency', 'daily')
->set('saveToS3', true)
->set('s3StorageId', $otherTeamStorage->id)
->assertHasErrors('s3StorageId')
->call('save')
->assertHasErrors('s3StorageId');
expect(ScheduledVolumeBackup::query()->count())->toBe(0);
$component->set('s3StorageId', $resourceTeamStorage->id)
->call('save')
->assertHasNoErrors();
expect(ScheduledVolumeBackup::query()->sole())
->s3_storage_id->toBe($resourceTeamStorage->id)
->save_s3->toBeTrue();
})->with([
'team' => [false],
'root team' => [true],
]);
it('only accepts a usable S3 storage owned by the current team', function () {
$team = Team::factory()->create();
signInForVolumeBackups($this, $team);
@@ -32,6 +32,7 @@ function remoteOutputTestServer(): Server
it('bounds dynamic proxy configuration files and their combined Livewire payload', function () {
$server = Mockery::mock(remoteOutputTestServer())->makePartial();
$server->shouldReceive('proxyPath')->andReturn('/data/proxy');
$this->actingAs(User::query()->latest('id')->firstOrFail());
$files = collect(range(1, 101))->map(fn (int $number) => sprintf('file%03d.yml', $number))->implode("\n");
Process::fake(function ($process) use ($files) {