diff --git a/.changes/broken-links-in-backup-notifications-of.md b/.changes/broken-links-in-backup-notifications-of.md new file mode 100644 index 0000000000..914369294d --- /dev/null +++ b/.changes/broken-links-in-backup-notifications-of.md @@ -0,0 +1,6 @@ +--- +type: fixed +--- +Broken links in backup notifications of the instance database and of service databases + +- They link to `Settings` > `Backup` and to the service page diff --git a/.changes/denied-root-team-owners-and-admins.md b/.changes/denied-root-team-owners-and-admins.md new file mode 100644 index 0000000000..08cd5ec53a --- /dev/null +++ b/.changes/denied-root-team-owners-and-admins.md @@ -0,0 +1,4 @@ +--- +type: fixed +--- +Denied root team owners and admins to change and download the instance backup on `Settings` > `Backup` ([#12503](https://github.com/coollabsio/coolify/issues/12503), [#12505](https://github.com/coollabsio/coolify/issues/12505)) diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index 946655c4de..35fa0af064 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -1159,7 +1159,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue failureEvent: 'backup_failure', title: "Database backup for {$this->database->name} works again.", webhookEvent: 'backup_recovered', - url: base_url().'/project/'.data_get($this->database, 'environment.project.uuid').'/environment/'.data_get($this->database, 'environment.uuid').'/database/'.$this->database->uuid, + url: $this->database->link() ?? base_url(), webhookData: ['database_name' => $this->database->name, 'database_uuid' => $this->database->uuid, 'backup_uuid' => $this->backup->uuid], )); } catch (Throwable $e) { diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index bfbd1ad164..bb208d3579 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -8,7 +8,6 @@ use App\Livewire\Project\Database\Backup\Execution; use App\Models\S3Storage; use App\Models\ScheduledDatabaseBackup; use App\Models\ServiceDatabase; -use App\Models\StandalonePostgresql; use App\Services\Access\AccessResolver; use App\Traits\ListensToTeamChannel; use Exception; @@ -421,8 +420,7 @@ class BackupEdit extends Component private function availableS3StorageIds(): Collection { $database = $this->backup->database; - // The instance database (id 0) has no project and belongs to the root team. - $teamId = $database instanceof StandalonePostgresql && $database->id === 0 ? 0 : $database?->team()?->id; + $teamId = $database?->team()?->id; if ($teamId === null) { return collect(); diff --git a/app/Models/ServiceDatabase.php b/app/Models/ServiceDatabase.php index 4220ac13d2..2a69c3293c 100644 --- a/app/Models/ServiceDatabase.php +++ b/app/Models/ServiceDatabase.php @@ -171,6 +171,11 @@ class ServiceDatabase extends BaseModel return $this->belongsTo(Service::class); } + public function link(): ?string + { + return $this->service?->link(); + } + public function persistentStorages() { return $this->morphMany(LocalPersistentVolume::class, 'resource'); diff --git a/app/Models/StandalonePostgresql.php b/app/Models/StandalonePostgresql.php index de30f5627d..a53779e8e5 100644 --- a/app/Models/StandalonePostgresql.php +++ b/app/Models/StandalonePostgresql.php @@ -283,6 +283,10 @@ class StandalonePostgresql extends BaseModel public function link() { + if ($this->isInstanceDatabase()) { + return route('settings.backup'); + } + if (data_get($this, 'environment.project.uuid')) { return route('project.database.configuration', [ 'project_uuid' => data_get($this, 'environment.project.uuid'), @@ -318,6 +322,11 @@ class StandalonePostgresql extends BaseModel public function team() { + // The instance database (coolify-db) has no environment and belongs to the root team. + if ($this->isInstanceDatabase()) { + return Team::find(0); + } + return data_get($this, 'environment.project.team'); } diff --git a/app/Notifications/Database/BackupFailed.php b/app/Notifications/Database/BackupFailed.php index 29d4f51333..6d54b9d1c0 100644 --- a/app/Notifications/Database/BackupFailed.php +++ b/app/Notifications/Database/BackupFailed.php @@ -108,7 +108,7 @@ class BackupFailed extends CustomEmailNotification implements ThrottledNotificat public function toWebhook(): array { - $url = base_url().'/project/'.data_get($this->database, 'environment.project.uuid').'/environment/'.data_get($this->database, 'environment.uuid').'/database/'.$this->database->uuid; + $url = $this->database->link() ?? base_url(); return [ 'success' => false, diff --git a/app/Notifications/Database/BackupSuccess.php b/app/Notifications/Database/BackupSuccess.php index 3d2d8ece3b..44e898d048 100644 --- a/app/Notifications/Database/BackupSuccess.php +++ b/app/Notifications/Database/BackupSuccess.php @@ -88,7 +88,7 @@ class BackupSuccess extends CustomEmailNotification public function toWebhook(): array { - $url = base_url().'/project/'.data_get($this->database, 'environment.project.uuid').'/environment/'.data_get($this->database, 'environment.uuid').'/database/'.$this->database->uuid; + $url = $this->database->link() ?? base_url(); return [ 'success' => true, diff --git a/app/Notifications/Database/BackupSuccessWithS3Warning.php b/app/Notifications/Database/BackupSuccessWithS3Warning.php index 564e90882e..c5659f1641 100644 --- a/app/Notifications/Database/BackupSuccessWithS3Warning.php +++ b/app/Notifications/Database/BackupSuccessWithS3Warning.php @@ -132,7 +132,7 @@ class BackupSuccessWithS3Warning extends CustomEmailNotification implements Thro public function toWebhook(): array { - $url = base_url().'/project/'.data_get($this->database, 'environment.project.uuid').'/environment/'.data_get($this->database, 'environment.uuid').'/database/'.$this->database->uuid; + $url = $this->database->link() ?? base_url(); $data = [ 'success' => true, diff --git a/app/Policies/DatabasePolicy.php b/app/Policies/DatabasePolicy.php index e35295cba5..1f1f4c4e49 100644 --- a/app/Policies/DatabasePolicy.php +++ b/app/Policies/DatabasePolicy.php @@ -5,6 +5,7 @@ namespace App\Policies; use App\Enums\AccessLevel; use App\Models\Environment; use App\Models\User; +use App\Services\Access\AccessResolver; use Illuminate\Auth\Access\Response; class DatabasePolicy @@ -120,11 +121,12 @@ class DatabasePolicy /** * The instance database (coolify-db) belongs to the root team; only its owners and admins can change it. + * It has no environment, so the check uses the root team grants and the API token limits. */ private function allows(User $user, AccessLevel $level, $database): bool { if ($this->isInstanceDatabase($database)) { - return $user->isAdminOfTeam(0) && $user->canAccess($level, $database); + return $user->isAdminOfTeam(0) && app(AccessResolver::class)->allowsTeamWide($user, $level, 0); } return $user->canAccess($level, $database); diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index ee5a023dd0..3953b7c137 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -1831,11 +1831,17 @@ function getResourceByUuid(string $uuid, ?int $teamId = null) return null; } +/** + * Finds a standalone database of the team for the API and MCP. The instance database (coolify-db) is never returned. + */ function queryDatabaseByUuidWithinTeam(string $uuid, string $teamId) { foreach (STANDALONE_DATABASE_MODELS as $modelClass) { $database = $modelClass::whereUuid($uuid)->first(); - if ($database && $database->team()->id == $teamId) { + if ($database instanceof StandalonePostgresql && $database->isInstanceDatabase()) { + return null; + } + if ($database && $database->team()?->id == $teamId) { return $database->unsetRelation('environment'); } } diff --git a/tests/Feature/InstanceBackupMultipleS3DestinationsTest.php b/tests/Feature/InstanceBackupMultipleS3DestinationsTest.php index 017ddb95ed..cd11e247a3 100644 --- a/tests/Feature/InstanceBackupMultipleS3DestinationsTest.php +++ b/tests/Feature/InstanceBackupMultipleS3DestinationsTest.php @@ -14,9 +14,11 @@ use App\Models\StandaloneDocker; use App\Models\StandalonePostgresql; use App\Models\Team; use App\Models\User; +use App\Notifications\Database\BackupFailed; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Process; use Illuminate\Support\Once; +use Illuminate\Support\Str; use Livewire\Livewire; uses(RefreshDatabase::class); @@ -135,3 +137,85 @@ it('links the instance backup from the storage resources page', function () { ->assertSee('coolify-db') ->assertSeeHtml(route('settings.backup')); }); + +it('lets a root team owner enable S3 for the instance backup', function () { + $owner = User::factory()->create(); + $this->rootTeam->members()->attach($owner->id, ['role' => 'owner']); + $this->actingAs($owner); + + Livewire::test(BackupEdit::class, ['backup' => $this->backup->fresh(), 'availableS3Storages' => collect()]) + ->set('saveS3', true) + ->set('s3StorageIds', [$this->firstStorage->id]) + ->call('submit') + ->assertHasNoErrors() + ->assertDispatched('success'); + + expect($this->backup->fresh()->save_s3)->toBeTruthy(); +}); + +it('denies a root team member to enable S3 for the instance backup', function () { + $member = User::factory()->create(); + $this->rootTeam->members()->attach($member->id, ['role' => 'member']); + $this->actingAs($member); + + Livewire::test(BackupEdit::class, ['backup' => $this->backup->fresh(), 'availableS3Storages' => collect()]) + ->set('saveS3', true) + ->set('s3StorageIds', [$this->firstStorage->id]) + ->call('submit'); + + expect($this->backup->fresh()->save_s3)->toBeFalsy(); +}); + +it('finds the root team when a root team admin downloads an instance backup', function () { + $execution = ScheduledDatabaseBackupExecution::create([ + 'scheduled_database_backup_id' => $this->backup->id, + 'status' => 'success', + 'filename' => '/data/coolify/backups/coolify/coolify-db-0/pg-dump-coolify-1.dmp', + 'local_storage_deleted' => true, + ]); + + $this->get(route('download.backup', $execution->id)) + ->assertNotFound() + ->assertJson(['message' => 'Backup not found locally on the server.']); +}); + +it('denies a root team member to download an instance backup', function () { + $member = User::factory()->create(); + $this->rootTeam->members()->attach($member->id, ['role' => 'member']); + $this->actingAs($member); + $execution = ScheduledDatabaseBackupExecution::create([ + 'scheduled_database_backup_id' => $this->backup->id, + 'status' => 'success', + 'filename' => '/data/coolify/backups/coolify/coolify-db-0/pg-dump-coolify-1.dmp', + 'local_storage_deleted' => true, + ]); + + $this->get(route('download.backup', $execution->id))->assertForbidden(); +}); + +it('links instance backup notifications to the instance backup page', function () { + $notification = new BackupFailed($this->backup, $this->database->fresh(), 'error', 'coolify'); + + expect($notification->toWebhook()['url'])->toBe(route('settings.backup')); +}); + +it('hides the instance database from the API of the root team', function () { + InstanceSettings::find(0)->update(['is_api_enabled' => true]); + $this->app['auth']->forgetGuards(); + $owner = User::factory()->create(); + $this->rootTeam->members()->attach($owner->id, ['role' => 'owner']); + $plainTextToken = Str::random(40); + $token = $owner->tokens()->create([ + 'name' => 'root', + 'token' => hash('sha256', $plainTextToken), + 'abilities' => ['root'], + 'team_id' => 0, + ]); + $this->withToken($token->getKey().'|'.$plainTextToken); + $uuid = $this->database->fresh()->uuid; + + $this->getJson("/api/v1/databases/{$uuid}")->assertNotFound(); + $this->deleteJson("/api/v1/databases/{$uuid}")->assertNotFound(); + + expect(StandalonePostgresql::find(0))->not->toBeNull(); +}); diff --git a/tests/Unit/Policies/DatabasePolicyTest.php b/tests/Unit/Policies/DatabasePolicyTest.php index 8267a5d66d..2b31d6ebb9 100644 --- a/tests/Unit/Policies/DatabasePolicyTest.php +++ b/tests/Unit/Policies/DatabasePolicyTest.php @@ -226,7 +226,9 @@ it('denies a root team admin to update the instance database when the token does $database->id = 0; $user->shouldReceive('isAdminOfTeam')->with(0)->andReturn(true); - $user->shouldReceive('canAccess')->with(AccessLevel::Write, $database)->andReturn(false); + $resolver = Mockery::mock(AccessResolver::class); + $resolver->shouldReceive('allowsTeamWide')->with($user, AccessLevel::Write, 0)->andReturn(false); + Container::getInstance()->instance(AccessResolver::class, $resolver); $policy = new DatabasePolicy; expect($policy->update($user, $database)->allowed())->toBeFalse();