mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-12 15:53:46 -04:00
fix(backups): let root team admins manage and download the instance backup
- Check the root team grants for the instance database, which has no environment. - Return the root team as the team of the instance database. - Hide the instance database from the API and MCP. - Fix the links in backup notifications of the instance and service databases. Closes #12503 Closes #12505 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
312fcbfd89
commit
f41047785e
@@ -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
|
||||
@@ -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))
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user