diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 32334a16cf..28f82b083a 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -1248,9 +1248,6 @@ class ApplicationsController extends Controller $this->authorize('create', Application::class); - // removeUnnecessaryFieldsFromRequest() strips force_domain_override before the docker_compose_domains checks run. - $forceDomainOverride = $request->boolean('force_domain_override'); - if ($request->instant_deploy) { abort_unless($request->user()->tokenCan('deploy') || $request->user()->tokenCan('root'), 403, 'Missing required permissions: deploy'); } @@ -1328,6 +1325,7 @@ class ApplicationsController extends Controller } // A Docker Compose application uses per-service domains; a top-level domain is never routed. $autogenerateDomain = $request->boolean('autogenerate_domain', true) && $request->build_pack !== 'dockercompose'; + $forceDomainOverride = $request->boolean('force_domain_override'); $instantDeploy = $request->instant_deploy; $githubAppUuid = $request->github_app_uuid; $useBuildServer = $request->use_build_server; diff --git a/app/Http/Middleware/ApiAbility.php b/app/Http/Middleware/ApiAbility.php index 8951ad50de..7a18695836 100644 --- a/app/Http/Middleware/ApiAbility.php +++ b/app/Http/Middleware/ApiAbility.php @@ -25,8 +25,7 @@ class ApiAbility extends CheckForAnyAbility $teamId = data_get($token, 'team_id'); if ($teamId !== null && ! $request->user()->isAdminOfTeam((int) $teamId)) { - $tokenAbilities = $token->abilities ?? []; - $disallowed = array_intersect($tokenAbilities, self::MEMBER_DISALLOWED_ABILITIES); + $disallowed = array_filter(self::MEMBER_DISALLOWED_ABILITIES, fn (string $ability) => $token->can($ability)); if (! empty($disallowed)) { return response()->json([ diff --git a/tests/Feature/Api/ApplicationComposeDomainForceOverrideApiTest.php b/tests/Feature/Api/ApplicationComposeDomainForceOverrideApiTest.php new file mode 100644 index 0000000000..8e50e2e44f --- /dev/null +++ b/tests/Feature/Api/ApplicationComposeDomainForceOverrideApiTest.php @@ -0,0 +1,107 @@ + 'array']); + InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]); + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + session(['currentTeam' => $this->team]); + + $this->bearerToken = $this->user->createToken('test-token', ['*'])->plainTextToken; + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->server->settings()->update(['is_reachable' => true, 'is_usable' => true]); + + StandaloneDocker::withoutEvents(function () { + $this->destination = $this->server->standaloneDockers()->firstOrCreate( + ['network' => 'coolify'], + ['uuid' => (string) new Cuid2, 'name' => 'test-docker'] + ); + }); + + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = $this->project->environments()->first(); + + Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'fqdn' => 'https://shared.example.com', + ]); +}); + +function createComposeApplication(string $endpoint, array $composeDomains, bool $force) +{ + $payload = [ + 'project_uuid' => test()->project->uuid, + 'environment_uuid' => test()->environment->uuid, + 'server_uuid' => test()->server->uuid, + 'git_branch' => 'v4.x', + 'build_pack' => 'dockercompose', + 'docker_compose_domains' => $composeDomains, + 'force_domain_override' => $force, + ]; + + if ($endpoint === 'private-deploy-key') { + $payload['private_key_uuid'] = PrivateKey::factory()->create(['team_id' => test()->team->id])->uuid; + $payload['git_repository'] = 'git@gitlab.com:coollabsio/coolify-examples.git'; + } else { + // Not github.com: that path looks up the seeded public GithubApp (id 0). + $payload['git_repository'] = 'https://gitlab.com/coollabsio/coolify-examples'; + } + + return test()->withToken(test()->bearerToken)->postJson("/api/v1/applications/{$endpoint}", $payload); +} + +dataset('endpoints', ['public', 'private-deploy-key']); + +dataset('conflicts', [ + 'domain used by another application' => [ + [['name' => 'web', 'domain' => 'https://shared.example.com']], + 409, + ], + 'same domain twice in the request' => [ + [ + ['name' => 'web', 'domain' => 'https://dup.example.com'], + ['name' => 'worker', 'domain' => 'https://dup.example.com'], + ], + 422, + ], +]); + +it('refuses conflicting docker_compose_domains without force_domain_override', function (string $endpoint, array $composeDomains, int $status) { + createComposeApplication($endpoint, $composeDomains, false) + ->assertStatus($status) + ->assertSeeText('Use force_domain_override=true to proceed.'); +})->with('endpoints')->with('conflicts'); + +it('creates the application with conflicting docker_compose_domains when force_domain_override is true', function (string $endpoint, array $composeDomains) { + $response = createComposeApplication($endpoint, $composeDomains, true)->assertCreated(); + + $application = Application::where('uuid', $response->json('uuid'))->firstOrFail(); + $savedDomains = collect(json_decode($application->docker_compose_domains, true)) + ->map(fn ($entry) => $entry['domain']); + + expect($savedDomains->all())->toBe(collect($composeDomains)->pluck('domain', 'name')->all()); +})->with('endpoints')->with('conflicts'); diff --git a/tests/Feature/Security/ApiTokenCreationSecurityTest.php b/tests/Feature/Security/ApiTokenCreationSecurityTest.php index 60b23b0e56..fa7e4434b2 100644 --- a/tests/Feature/Security/ApiTokenCreationSecurityTest.php +++ b/tests/Feature/Security/ApiTokenCreationSecurityTest.php @@ -1,10 +1,12 @@ assertStatus(403); }); + test('ApiAbility evaluates member token abilities through the token', function () { + session(['currentTeam' => $this->team]); + $token = $this->member->createToken('token', ['*']); + $this->member->withAccessToken($token->accessToken); + + $request = Request::create('/api/v1/projects'); + $request->setUserResolver(fn () => $this->member); + + $response = (new ApiAbility)->handle($request, fn () => response('ok'), 'read'); + + expect($response->getStatusCode())->toBe(403); + }); + test('member read token passes on non-zero team', function () { session(['currentTeam' => $this->team]); $token = $this->member->createToken('read-token', ['read']);