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:
Andras Bacsai
2026-11-09 21:21:14 +01:00
co-authored by Claude Opus 5.5
parent 312fcbfd89
commit f41047785e
13 changed files with 126 additions and 10 deletions
@@ -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))
+1 -1
View File
@@ -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) {
+1 -3
View File
@@ -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();
+5
View File
@@ -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');
+9
View File
@@ -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');
}
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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,
+3 -1
View File
@@ -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);
+7 -1
View File
@@ -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();
});
+3 -1
View File
@@ -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();