From 25a4076bd0d422cfe5cda43fbd7524188eff8f09 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:00:29 +0200 Subject: [PATCH] fix(api): require the write ability for database imports Uploading and starting a database import overwrites data, so a deploy token is no longer enough. Import activity IDs must be numeric. Co-Authored-By: Claude Opus 5.5 --- routes/api.php | 12 +-- .../Api/DatabaseImportAbilityApiTest.php | 77 +++++++++++++++++++ tests/Feature/Api/DatabaseImportApiTest.php | 6 +- .../Feature/Api/DatabaseImportRoutesTest.php | 8 +- .../Api/ServiceDatabaseImportApiTest.php | 4 +- 5 files changed, 92 insertions(+), 15 deletions(-) create mode 100644 tests/Feature/Api/DatabaseImportAbilityApiTest.php diff --git a/routes/api.php b/routes/api.php index ad3389924b..1f4eff104c 100644 --- a/routes/api.php +++ b/routes/api.php @@ -317,9 +317,9 @@ Route::group([ Route::post('/databases/sqlite', [DatabasesController::class, 'create_database_sqlite'])->middleware(['api.ability:write']); Route::get('/databases/{uuid}', [DatabasesController::class, 'database_by_uuid'])->middleware(['api.ability:read']); - Route::post('/databases/{uuid}/imports/uploads', [DatabasesController::class, 'upload_import'])->middleware(['api.ability:deploy'])->name('api.databases.imports.upload'); - Route::post('/databases/{uuid}/imports', [DatabasesController::class, 'create_import'])->middleware(['api.ability:deploy'])->name('api.databases.imports.store'); - Route::get('/databases/{uuid}/imports/{activity_id}', [DatabasesController::class, 'show_import'])->middleware(['api.ability:read'])->name('api.databases.imports.show'); + Route::post('/databases/{uuid}/imports/uploads', [DatabasesController::class, 'upload_import'])->middleware(['api.ability:write'])->name('api.databases.imports.upload'); + Route::post('/databases/{uuid}/imports', [DatabasesController::class, 'create_import'])->middleware(['api.ability:write'])->name('api.databases.imports.store'); + Route::get('/databases/{uuid}/imports/{activity_id}', [DatabasesController::class, 'show_import'])->middleware(['api.ability:read'])->whereNumber('activity_id')->name('api.databases.imports.show'); Route::get('/databases/{uuid}/backups', [DatabasesController::class, 'database_backup_details_uuid'])->middleware(['api.ability:read']); Route::get('/databases/{uuid}/backups/{scheduled_backup_uuid}/executions', [DatabasesController::class, 'list_backup_executions'])->middleware(['api.ability:read']); Route::patch('/databases/{uuid}', [DatabasesController::class, 'update_by_uuid'])->middleware(['api.ability:write']); @@ -419,9 +419,9 @@ Route::group([ Route::get('/services/{uuid}/databases', [ServiceDatabasesController::class, 'index'])->middleware(['api.ability:read']); Route::get('/services/{uuid}/databases/{database_uuid}', [ServiceDatabasesController::class, 'show'])->middleware(['api.ability:read']); - Route::post('/services/{uuid}/databases/{database_uuid}/imports/uploads', [ServiceDatabasesController::class, 'upload_import'])->middleware(['api.ability:deploy'])->name('api.service-databases.imports.upload'); - Route::post('/services/{uuid}/databases/{database_uuid}/imports', [ServiceDatabasesController::class, 'create_import'])->middleware(['api.ability:deploy'])->name('api.service-databases.imports.store'); - Route::get('/services/{uuid}/databases/{database_uuid}/imports/{activity_id}', [ServiceDatabasesController::class, 'show_import'])->middleware(['api.ability:read'])->name('api.service-databases.imports.show'); + Route::post('/services/{uuid}/databases/{database_uuid}/imports/uploads', [ServiceDatabasesController::class, 'upload_import'])->middleware(['api.ability:write'])->name('api.service-databases.imports.upload'); + Route::post('/services/{uuid}/databases/{database_uuid}/imports', [ServiceDatabasesController::class, 'create_import'])->middleware(['api.ability:write'])->name('api.service-databases.imports.store'); + Route::get('/services/{uuid}/databases/{database_uuid}/imports/{activity_id}', [ServiceDatabasesController::class, 'show_import'])->middleware(['api.ability:read'])->whereNumber('activity_id')->name('api.service-databases.imports.show'); Route::patch('/services/{uuid}/databases/{database_uuid}', [ServiceDatabasesController::class, 'update'])->middleware(['api.ability:write']); Route::get('/services/{uuid}/databases/{database_uuid}/logs', [ServiceDatabasesController::class, 'logs'])->middleware(['api.ability:read:sensitive']); Route::post('/services/{uuid}/databases/{database_uuid}/start', [ServiceDatabasesController::class, 'start'])->middleware(['api.ability:deploy']); diff --git a/tests/Feature/Api/DatabaseImportAbilityApiTest.php b/tests/Feature/Api/DatabaseImportAbilityApiTest.php new file mode 100644 index 0000000000..a9e2646ec4 --- /dev/null +++ b/tests/Feature/Api/DatabaseImportAbilityApiTest.php @@ -0,0 +1,77 @@ + 0, 'is_api_enabled' => true]); + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user, ['role' => 'owner']); + session(['currentTeam' => $this->team]); + + $server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = StandaloneDocker::firstOrCreate(['server_id' => $server->id, 'network' => 'coolify'], ['uuid' => (string) Str::uuid(), 'name' => 'docker']); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + $database = StandalonePostgresql::create(['uuid' => (string) Str::uuid(), 'name' => 'db', 'postgres_user' => 'postgres', 'postgres_password' => 'password', 'postgres_db' => 'db', 'image' => 'postgres:17', 'status' => 'running', 'environment_id' => $environment->id, 'destination_id' => $destination->id, 'destination_type' => $destination->getMorphClass()]); + $service = Service::factory()->create(['environment_id' => $environment->id, 'server_id' => $server->id, 'destination_id' => $destination->id, 'destination_type' => $destination->getMorphClass(), 'docker_compose_raw' => "services:\n postgres:\n image: postgres:17\n"]); + $serviceDatabase = ServiceDatabase::create(['uuid' => (string) Str::uuid(), 'name' => 'postgres', 'service_id' => $service->id, 'image' => 'postgres:17']); + + $this->importPaths = [ + 'standalone' => "/api/v1/databases/{$database->uuid}/imports", + 'service' => "/api/v1/services/{$service->uuid}/databases/{$serviceDatabase->uuid}/imports", + ]; +}); + +function databaseImportAbilityToken(User $user, Team $team, array $abilities): string +{ + $token = $user->createToken('import-ability', $abilities); + $token->accessToken->forceFill(['team_id' => $team->id])->save(); + + return $token->plainTextToken; +} + +dataset('database import variants', ['standalone', 'service']); +dataset('database import mutations', ['upload' => ['/uploads'], 'create' => ['']]); + +test('a deploy token cannot upload or start a database import', function (string $variant, string $suffix) { + $token = databaseImportAbilityToken($this->user, $this->team, ['read', 'deploy']); + + $this->withToken($token) + ->postJson($this->importPaths[$variant].$suffix, ['source' => 'server', 'path' => '/tmp/a.sql', 'dump_all' => true]) + ->assertForbidden(); +})->with('database import variants')->with('database import mutations'); + +test('a write or root token passes the ability check for database imports', function (string $variant, string $suffix, array $abilities) { + $token = databaseImportAbilityToken($this->user, $this->team, $abilities); + + $this->withToken($token) + ->postJson($this->importPaths[$variant].$suffix, []) + ->assertUnprocessable(); +})->with('database import variants')->with('database import mutations')->with([ + 'write' => [['read', 'write']], + 'root' => [['root']], +]); + +test('a non-numeric import activity id returns not found', function (string $variant) { + $token = databaseImportAbilityToken($this->user, $this->team, ['read']); + + $this->withToken($token) + ->getJson($this->importPaths[$variant].'/not-a-number') + ->assertNotFound(); +})->with('database import variants'); diff --git a/tests/Feature/Api/DatabaseImportApiTest.php b/tests/Feature/Api/DatabaseImportApiTest.php index f0b8c4f99a..3effb8fabb 100644 --- a/tests/Feature/Api/DatabaseImportApiTest.php +++ b/tests/Feature/Api/DatabaseImportApiTest.php @@ -26,7 +26,7 @@ beforeEach(function () { $this->user = User::factory()->create(); $this->team->members()->attach($this->user, ['role' => 'owner']); session(['currentTeam' => $this->team]); - $this->token = $this->user->tokens()->create(['name' => 'imports', 'token' => hash('sha256', 'secret'), 'abilities' => ['deploy', 'read'], 'team_id' => $this->team->id]); + $this->token = $this->user->tokens()->create(['name' => 'imports', 'token' => hash('sha256', 'secret'), 'abilities' => ['write', 'read'], 'team_id' => $this->team->id]); $this->headers = ['Authorization' => 'Bearer '.$this->token->id.'|secret']; $this->server = Server::factory()->create(['team_id' => $this->team->id]); $this->destination = StandaloneDocker::firstOrCreate(['server_id' => $this->server->id, 'network' => 'coolify'], ['uuid' => (string) Str::uuid(), 'name' => 'docker']); @@ -48,7 +48,7 @@ test('validates standalone import source and hides foreign databases', function $this->withHeaders($this->headers)->postJson("/api/v1/databases/{$foreign->uuid}/imports", ['source' => 'server', 'path' => '/tmp/a.sql'])->assertNotFound(); }); -test('requires deploy ability to start standalone import', function () { +test('requires write ability to start standalone import', function () { $database = StandalonePostgresql::create(['uuid' => (string) Str::uuid(), 'name' => 'db', 'postgres_user' => 'postgres', 'postgres_password' => 'password', 'postgres_db' => 'db', 'image' => 'postgres:17', 'status' => 'running', 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass()]); $read = $this->user->createToken('read', ['read']); @@ -149,7 +149,7 @@ test('returns invalid token when the access token team is not a member team', fu $token = $this->user->tokens()->create([ 'name' => 'imports-foreign-team', 'token' => hash('sha256', $plainTextToken), - 'abilities' => ['deploy', 'read'], + 'abilities' => ['write', 'read'], 'team_id' => $foreignTeam->id, ]); diff --git a/tests/Feature/Api/DatabaseImportRoutesTest.php b/tests/Feature/Api/DatabaseImportRoutesTest.php index a91e160356..3d817f1b1e 100644 --- a/tests/Feature/Api/DatabaseImportRoutesTest.php +++ b/tests/Feature/Api/DatabaseImportRoutesTest.php @@ -6,11 +6,11 @@ test('registers standalone and service database import routes with abilities', f $routes = collect(Route::getRoutes()->getRoutesByName()); $expected = [ - 'api.databases.imports.upload' => 'api.ability:deploy', - 'api.databases.imports.store' => 'api.ability:deploy', + 'api.databases.imports.upload' => 'api.ability:write', + 'api.databases.imports.store' => 'api.ability:write', 'api.databases.imports.show' => 'api.ability:read', - 'api.service-databases.imports.upload' => 'api.ability:deploy', - 'api.service-databases.imports.store' => 'api.ability:deploy', + 'api.service-databases.imports.upload' => 'api.ability:write', + 'api.service-databases.imports.store' => 'api.ability:write', 'api.service-databases.imports.show' => 'api.ability:read', ]; diff --git a/tests/Feature/Api/ServiceDatabaseImportApiTest.php b/tests/Feature/Api/ServiceDatabaseImportApiTest.php index df591e97cb..6930df7dd4 100644 --- a/tests/Feature/Api/ServiceDatabaseImportApiTest.php +++ b/tests/Feature/Api/ServiceDatabaseImportApiTest.php @@ -22,7 +22,7 @@ beforeEach(function () { $this->user = User::factory()->create(); $this->team->members()->attach($this->user, ['role' => 'owner']); session(['currentTeam' => $this->team]); - $this->token = $this->user->tokens()->create(['name' => 'imports', 'token' => hash('sha256', 'secret'), 'abilities' => ['deploy', 'read'], 'team_id' => $this->team->id]); + $this->token = $this->user->tokens()->create(['name' => 'imports', 'token' => hash('sha256', 'secret'), 'abilities' => ['write', 'read'], 'team_id' => $this->team->id]); $this->headers = ['Authorization' => 'Bearer '.$this->token->id.'|secret']; $this->server = Server::factory()->create(['team_id' => $this->team->id]); $this->destination = StandaloneDocker::firstOrCreate(['server_id' => $this->server->id, 'network' => 'coolify'], ['uuid' => (string) Str::uuid(), 'name' => 'docker']); @@ -55,7 +55,7 @@ test('returns invalid token when the access token team is not a member team', fu $token = $this->user->tokens()->create([ 'name' => 'imports-foreign-team', 'token' => hash('sha256', $plainTextToken), - 'abilities' => ['deploy', 'read'], + 'abilities' => ['write', 'read'], 'team_id' => $foreignTeam->id, ]);