diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 0a34c54239..7d103d7d52 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -1244,6 +1244,7 @@ class DatabasesController extends Controller if (! isset($backupData['enabled'])) { $backupData['enabled'] = true; } + $backupData['save_s3'] ??= false; if ($s3Storages?->isNotEmpty()) { $this->authorize('useTeamCredentials', $database); diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index f987003052..bfbd1ad164 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -192,6 +192,10 @@ class BackupEdit extends Component // The team of the schedule, because the instance database has no environment to read it from. if ($this->backup->newS3StorageIds($storageIds)->isNotEmpty() && ! app(AccessResolver::class)->allowsTeamWide(Auth::user(), AccessLevel::Write, (int) $this->backup->team_id)) { + $this->backup->refresh(); + $this->saveS3 = (bool) $this->backup->save_s3; + $this->s3StorageIds = $this->backup->selectedS3Storages()->pluck('id')->all(); + throw new AuthorizationException('You need write access on the whole team to add an S3 storage to this backup.'); } $changedFields = auditChangedFields($this->backup); diff --git a/app/Policies/ResourceCreatePolicy.php b/app/Policies/ResourceCreatePolicy.php index e8dd00cf9d..6ac1c10115 100644 --- a/app/Policies/ResourceCreatePolicy.php +++ b/app/Policies/ResourceCreatePolicy.php @@ -6,6 +6,7 @@ use App\Enums\AccessLevel; use App\Models\Environment; use App\Models\User; use App\Services\Access\AccessResolver; +use Illuminate\Auth\Access\Response; use Illuminate\Database\Eloquent\Model; class ResourceCreatePolicy @@ -30,10 +31,12 @@ class ResourceCreatePolicy * yet: private keys, git sources, integration tokens, and backup files of other resources. * Needs write access on the whole team of the resource, or of the current team without a resource. */ - public function useTeamCredentials(User $user, ?Model $resource = null): bool + public function useTeamCredentials(User $user, ?Model $resource = null): Response { $teamId = $resource !== null ? $resource->team()?->id : $user->currentTeam()?->id; - return $teamId !== null && app(AccessResolver::class)->allowsTeamWide($user, AccessLevel::Write, (int) $teamId); + return $teamId !== null && app(AccessResolver::class)->allowsTeamWide($user, AccessLevel::Write, (int) $teamId) + ? Response::allow() + : Response::deny('You need write access on the whole team to use team credentials, such as private keys, git sources, and S3 storages.'); } } diff --git a/tests/Feature/Access/ResourcePlacementTest.php b/tests/Feature/Access/ResourcePlacementTest.php index 5e207c72d4..d5b1557c64 100644 --- a/tests/Feature/Access/ResourcePlacementTest.php +++ b/tests/Feature/Access/ResourcePlacementTest.php @@ -236,7 +236,8 @@ describe('team credentials in the API', function () { ]; placementTestApi($this->developerToken, 'POST', '/api/v1/applications/private-deploy-key', [...$request, 'private_key_uuid' => $privateKey->uuid]) - ->assertForbidden(); + ->assertForbidden() + ->assertJsonPath('message', 'You need write access on the whole team to use team credentials, such as private keys, git sources, and S3 storages.'); placementTestApi($this->developerToken, 'POST', '/api/v1/applications/private-github-app', [...$request, 'github_app_uuid' => 'any-github-app']) ->assertForbidden(); @@ -314,6 +315,26 @@ describe('team S3 storages for backups', function () { ->and((bool) $backup->fresh()->save_s3)->toBeFalse(); }); + test('the backup page shows one error when a project-scoped member selects a team S3 storage', function () { + $backup = ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', + 'save_s3' => false, + 'database_type' => $this->database->getMorphClass(), + 'database_id' => $this->database->id, + 'team_id' => $this->team->id, + ]); + $this->actingAs($this->developer); + session(['currentTeam' => $this->team]); + + $component = Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => collect()]) + ->set('s3StorageIds', [$this->firstStorage->id]); + + $errors = collect($component->effects['dispatches'] ?? [])->where('name', 'error'); + expect($errors)->toHaveCount(1) + ->and($backup->fresh()->s3Storages()->count())->toBe(0); + $component->assertSet('s3StorageIds', [])->assertSet('saveS3', false); + }); + test('a member with team-wide write can send a backup schedule to a team S3 storage', function () { $operator = placementTestMember($this->team, [['scope' => 'team', 'uuid' => null, 'levels' => ['write', 'deploy', 'delete']]]); diff --git a/tests/Feature/Api/DatabaseBackupCreationApiTest.php b/tests/Feature/Api/DatabaseBackupCreationApiTest.php index 9a5cdc90c9..cb36558935 100644 --- a/tests/Feature/Api/DatabaseBackupCreationApiTest.php +++ b/tests/Feature/Api/DatabaseBackupCreationApiTest.php @@ -80,6 +80,16 @@ function backupHeaders(): array } describe('POST /api/v1/databases/{uuid}/backups', function () { + test('does not save to S3 when save_s3 is not sent', function () { + $this->withHeaders(backupHeaders()) + ->postJson("/api/v1/databases/{$this->database->uuid}/backups", ['frequency' => 'daily']) + ->assertCreated(); + + $backup = ScheduledDatabaseBackup::firstOrFail(); + expect((bool) $backup->save_s3)->toBeFalse() + ->and($backup->s3_storage_id)->toBeNull(); + }); + test('rejects backup configurations for unsupported database types', function () { $database = StandaloneRedis::create([ 'uuid' => (string) Str::uuid(),