Merge remote-tracking branch 'origin/main'

This commit is contained in:
Andras Bacsai
2026-10-18 23:24:00 +02:00
4 changed files with 124 additions and 5 deletions
@@ -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;
+1 -2
View File
@@ -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([
@@ -0,0 +1,107 @@
<?php
use App\Models\Application;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Visus\Cuid2\Cuid2;
uses(RefreshDatabase::class);
// create_application() strips force_domain_override from the request before the
// docker_compose_domains checks run, so the flag must be read up front.
beforeEach(function () {
// The create dispatches LoadComposeFile, which would clone the repository.
Queue::fake();
config(['app.maintenance.store' => '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');
@@ -1,10 +1,12 @@
<?php
use App\Http\Middleware\ApiAbility;
use App\Livewire\Security\ApiTokens;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
use Livewire\Livewire;
@@ -201,6 +203,19 @@ describe('ApiAbility middleware — member with elevated token blocked', functio
->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']);