diff --git a/app/Http/Controllers/UploadController.php b/app/Http/Controllers/UploadController.php index 93847589a..895564caa 100644 --- a/app/Http/Controllers/UploadController.php +++ b/app/Http/Controllers/UploadController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Http\Request; use Illuminate\Http\UploadedFile; use Illuminate\Routing\Controller as BaseController; @@ -11,6 +12,8 @@ class UploadController extends BaseController { + use AuthorizesRequests; + public function upload(Request $request) { $databaseIdentifier = request()->route('databaseUuid'); @@ -18,6 +21,9 @@ public function upload(Request $request) if (is_null($resource)) { return response()->json(['error' => 'You do not have permission for this database'], 500); } + + $this->authorize('uploadBackup', $resource); + $receiver = new FileReceiver('file', $request, HandlerFactory::classFromRequest($request)); if ($receiver->isUploaded() === false) { diff --git a/app/Policies/ApplicationPolicy.php b/app/Policies/ApplicationPolicy.php index 7a992f2fd..1a1290b55 100644 --- a/app/Policies/ApplicationPolicy.php +++ b/app/Policies/ApplicationPolicy.php @@ -78,6 +78,24 @@ public function forceDelete(User $user, Application $application): bool return false; } + /** + * Determine whether the user can upload a backup archive for this application. + */ + public function uploadBackup(User $user, Application $application): Response + { + $teamId = $this->getTeamId($application); + + if ($teamId === null) { + return Response::deny('Application team not found.'); + } + + if ($user->isAdminOfTeam($teamId)) { + return Response::allow(); + } + + return Response::deny('You need at least admin or owner permissions to upload backups for this application.'); + } + /** * Determine whether the user can deploy the application. */ diff --git a/app/Policies/DatabasePolicy.php b/app/Policies/DatabasePolicy.php index f62ffdde2..4217432b5 100644 --- a/app/Policies/DatabasePolicy.php +++ b/app/Policies/DatabasePolicy.php @@ -87,6 +87,24 @@ public function manage(User $user, $database): bool return $teamId !== null && $user->isAdminOfTeam($teamId); } + /** + * Determine whether the user can upload a backup archive for this database. + */ + public function uploadBackup(User $user, $database): Response + { + $teamId = $this->getTeamId($database); + + if ($teamId === null) { + return Response::deny('Database team not found.'); + } + + if ($user->isAdminOfTeam($teamId)) { + return Response::allow(); + } + + return Response::deny('You need at least admin or owner permissions to upload backups for this database.'); + } + /** * Determine whether the user can manage database backups. */ diff --git a/app/Policies/ServiceDatabasePolicy.php b/app/Policies/ServiceDatabasePolicy.php index e5cbe91a0..e94658e29 100644 --- a/app/Policies/ServiceDatabasePolicy.php +++ b/app/Policies/ServiceDatabasePolicy.php @@ -63,4 +63,12 @@ public function manageBackups(User $user, ServiceDatabase $serviceDatabase): boo { return Gate::allows('update', $serviceDatabase->service); } + + /** + * Determine whether the user can upload a backup archive for this service database. + */ + public function uploadBackup(User $user, ServiceDatabase $serviceDatabase): bool + { + return Gate::allows('uploadBackup', $serviceDatabase->service); + } } diff --git a/app/Policies/ServicePolicy.php b/app/Policies/ServicePolicy.php index d48728cdf..6ca79b42a 100644 --- a/app/Policies/ServicePolicy.php +++ b/app/Policies/ServicePolicy.php @@ -89,6 +89,16 @@ public function manageEnvironment(User $user, Service $service): bool return $teamId !== null && $user->isAdminOfTeam($teamId); } + /** + * Determine whether the user can upload a backup archive for a database within this service. + */ + public function uploadBackup(User $user, Service $service): bool + { + $teamId = $this->getTeamId($service); + + return $teamId !== null && $user->isAdminOfTeam($teamId); + } + /** * Determine whether the user can deploy the service. */ diff --git a/tests/Feature/Authorization/UploadBackupAuthorizationTest.php b/tests/Feature/Authorization/UploadBackupAuthorizationTest.php new file mode 100644 index 000000000..126e6ef52 --- /dev/null +++ b/tests/Feature/Authorization/UploadBackupAuthorizationTest.php @@ -0,0 +1,186 @@ + 0]); + + $this->team = Team::factory()->create(); + + $this->admin = User::factory()->create(); + $this->admin->teams()->attach($this->team, ['role' => 'admin']); + + $this->member = User::factory()->create(); + $this->member->teams()->attach($this->team, ['role' => 'member']); + + $keyId = DB::table('private_keys')->insertGetId([ + 'uuid' => (string) Str::uuid(), + 'name' => 'Test Key', + 'private_key' => 'test-key', + 'team_id' => $this->team->id, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $keyId, + ]); + + StandaloneDocker::withoutEvents(function () { + $this->destination = StandaloneDocker::firstOrCreate( + ['server_id' => $this->server->id, 'network' => 'coolify'], + ['uuid' => (string) Str::uuid(), 'name' => 'test-docker'] + ); + }); + + $this->project = Project::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'Test Project', + 'team_id' => $this->team->id, + ]); + + $this->environment = $this->project->environments()->first(); + + $this->database = StandalonePostgresql::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'Test DB', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'testdb', + 'image' => 'postgres:15', + 'status' => 'running', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $this->application = Application::factory()->create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'Test App', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'status' => 'running', + ]); + + $this->service = Service::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'test-service', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'docker_compose_raw' => 'version: "3"', + ]); + + $this->serviceDatabase = ServiceDatabase::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'test-service-db', + 'service_id' => $this->service->id, + ]); +}); + +// --- DatabasePolicy::uploadBackup (covers Standalone* databases) --- + +test('admin can upload backup for standalone database', function () { + expect($this->admin->can('uploadBackup', $this->database))->toBeTrue(); +}); + +test('member cannot upload backup for standalone database', function () { + expect($this->member->can('uploadBackup', $this->database))->toBeFalse(); +}); + +// --- ApplicationPolicy::uploadBackup --- + +test('admin can upload backup for application', function () { + expect($this->admin->can('uploadBackup', $this->application))->toBeTrue(); +}); + +test('member cannot upload backup for application', function () { + expect($this->member->can('uploadBackup', $this->application))->toBeFalse(); +}); + +// --- ServicePolicy::uploadBackup --- + +test('admin can upload backup for service', function () { + expect($this->admin->can('uploadBackup', $this->service))->toBeTrue(); +}); + +test('member cannot upload backup for service', function () { + expect($this->member->can('uploadBackup', $this->service))->toBeFalse(); +}); + +// --- ServiceDatabasePolicy::uploadBackup (delegates to ServicePolicy) --- + +test('admin can upload backup for service database', function () { + expect($this->admin->can('uploadBackup', $this->serviceDatabase))->toBeTrue(); +}); + +test('member cannot upload backup for service database', function () { + expect($this->member->can('uploadBackup', $this->serviceDatabase))->toBeFalse(); +}); + +// --- Cross-team isolation --- + +test('user from different team cannot upload backup', function () { + $otherTeam = Team::factory()->create(); + $otherUser = User::factory()->create(); + $otherUser->teams()->attach($otherTeam, ['role' => 'admin']); + + expect($otherUser->can('uploadBackup', $this->database))->toBeFalse(); + expect($otherUser->can('uploadBackup', $this->application))->toBeFalse(); + expect($otherUser->can('uploadBackup', $this->service))->toBeFalse(); + expect($otherUser->can('uploadBackup', $this->serviceDatabase))->toBeFalse(); +}); + +// --- HTTP endpoint: POST /upload/backup/{uuid} --- + +test('member gets 403 from POST /upload/backup and no file lands on disk', function () { + $uploadDir = storage_path('app/upload/'.$this->database->uuid); + if (File::exists($uploadDir)) { + File::deleteDirectory($uploadDir); + } + + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + $response = $this->post(route('upload.backup', ['databaseUuid' => $this->database->uuid])); + + $response->assertForbidden(); + + expect(File::exists($uploadDir.'/restore'))->toBeFalse(); +}); + +test('user from different team hits null-resource branch with 500', function () { + $otherTeam = Team::factory()->create(); + $otherUser = User::factory()->create(); + $otherUser->teams()->attach($otherTeam, ['role' => 'admin']); + + $this->actingAs($otherUser); + session(['currentTeam' => $otherTeam]); + + $response = $this->post(route('upload.backup', ['databaseUuid' => $this->database->uuid])); + + $response->assertStatus(500); +}); + +test('unauthenticated request is redirected to login', function () { + $response = $this->post(route('upload.backup', ['databaseUuid' => $this->database->uuid])); + + $response->assertRedirect('/login'); +});