refactor: tighten team scoping on resource creation and admin nav (#9651)

This commit is contained in:
Andras Bacsai authored and GitHub committed 2026-04-19 12:01:11 +02:00
commit 33518b24a2
20 files changed
+653 -159

No files matched your search

+44 -3
View File
@@ -1,6 +1,7 @@
<?php
use App\Livewire\Admin\Index as AdminIndex;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -70,9 +71,9 @@ test('submitSearch requires admin authorization', function () {
test('switchUser requires root user id 0', function () {
config()->set('constants.coolify.self_hosted', false);
$rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]);
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
$rootUser = User::factory()->create(['id' => 0]);
$rootTeam->members()->attach($rootUser->id, ['role' => 'admin']);
$rootTeam = Team::find(0);
$targetUser = User::factory()->create();
$targetTeam = Team::factory()->create();
@@ -84,7 +85,47 @@ test('switchUser requires root user id 0', function () {
Livewire::test(AdminIndex::class)
->assertOk()
->call('switchUser', $targetUser->id)
->assertRedirect();
->assertRedirect(route('dashboard'));
});
test('back() redirects impersonator to admin index and clears session', function () {
config()->set('constants.coolify.self_hosted', false);
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
$rootUser = User::factory()->create(['id' => 0]);
$rootTeam = Team::find(0);
$this->actingAs($rootUser);
session([
'currentTeam' => ['id' => $rootTeam->id],
'impersonating' => true,
]);
Livewire::test(AdminIndex::class)
->call('back')
->assertRedirect(route('admin.index'));
expect(session('impersonating'))->toBeNull();
});
test('switchUser ignores Referer header and uses dashboard route', function () {
config()->set('constants.coolify.self_hosted', false);
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
$rootUser = User::factory()->create(['id' => 0]);
$rootTeam = Team::find(0);
$targetUser = User::factory()->create();
$targetTeam = Team::factory()->create();
$targetTeam->members()->attach($targetUser->id, ['role' => 'admin']);
$this->actingAs($rootUser);
session(['currentTeam' => ['id' => $rootTeam->id]]);
Livewire::withHeaders(['Referer' => 'https://example.com/elsewhere'])
->test(AdminIndex::class)
->call('switchUser', $targetUser->id)
->assertRedirect(route('dashboard'));
});
test('switchUser rejects non-root user', function () {
@@ -0,0 +1,106 @@
<?php
use App\Livewire\Storage\Resources as StorageResources;
use App\Models\InstanceSettings;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
use App\Models\Team;
use App\Models\User;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
Queue::fake();
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
$this->userA = User::factory()->create();
$this->teamA = Team::factory()->create();
$this->userA->teams()->attach($this->teamA, ['role' => 'owner']);
$this->userB = User::factory()->create();
$this->teamB = Team::factory()->create();
$this->userB->teams()->attach($this->teamB, ['role' => 'owner']);
$this->storageA = S3Storage::unguarded(fn () => S3Storage::create([
'uuid' => fake()->uuid(),
'name' => 'storage-a-'.fake()->unique()->word(),
'region' => 'us-east-1',
'key' => 'key-a',
'secret' => 'secret-a',
'bucket' => 'bucket-a',
'endpoint' => 'https://s3.example.com',
'team_id' => $this->teamA->id,
]));
$this->storageB = S3Storage::unguarded(fn () => S3Storage::create([
'uuid' => fake()->uuid(),
'name' => 'storage-b-'.fake()->unique()->word(),
'region' => 'us-east-1',
'key' => 'key-b',
'secret' => 'secret-b',
'bucket' => 'bucket-b',
'endpoint' => 'https://s3.example.com',
'team_id' => $this->teamB->id,
]));
$this->backupA = ScheduledDatabaseBackup::create([
'uuid' => fake()->uuid(),
'team_id' => $this->teamA->id,
'enabled' => true,
'save_s3' => true,
'frequency' => '0 0 * * *',
'database_type' => 'App\\Models\\StandalonePostgresql',
'database_id' => 1,
's3_storage_id' => $this->storageA->id,
]);
$this->backupB = ScheduledDatabaseBackup::create([
'uuid' => fake()->uuid(),
'team_id' => $this->teamB->id,
'enabled' => true,
'save_s3' => true,
'frequency' => '0 0 * * *',
'database_type' => 'App\\Models\\StandalonePostgresql',
'database_id' => 2,
's3_storage_id' => $this->storageB->id,
]);
$this->actingAs($this->userA);
session(['currentTeam' => $this->teamA]);
});
describe('Storage/Resources team-scoped backup access', function () {
test('disableS3 on other team backup throws and leaves row unchanged', function () {
expect(fn () => Livewire::test(StorageResources::class, ['storage' => $this->storageA])
->call('disableS3', $this->backupB->id))
->toThrow(ModelNotFoundException::class);
$this->backupB->refresh();
expect((bool) $this->backupB->save_s3)->toBeTrue();
expect($this->backupB->s3_storage_id)->toBe($this->storageB->id);
});
test('moveBackup on other team backup throws and leaves row unchanged', function () {
expect(fn () => Livewire::test(StorageResources::class, ['storage' => $this->storageA])
->set('selectedStorages', [$this->backupB->id => $this->storageA->id])
->call('moveBackup', $this->backupB->id))
->toThrow(ModelNotFoundException::class);
$this->backupB->refresh();
expect($this->backupB->s3_storage_id)->toBe($this->storageB->id);
});
test('disableS3 on own backup succeeds', function () {
Livewire::test(StorageResources::class, ['storage' => $this->storageA])
->call('disableS3', $this->backupA->id);
$this->backupA->refresh();
expect((bool) $this->backupA->save_s3)->toBeFalse();
expect($this->backupA->s3_storage_id)->toBeNull();
});
});
+297
View File
@@ -0,0 +1,297 @@
<?php
use App\Livewire\Destination\Show as DestinationShow;
use App\Livewire\Project\New\DockerCompose;
use App\Livewire\Project\New\DockerImage;
use App\Livewire\Project\New\GithubPrivateRepository;
use App\Livewire\Project\New\GithubPrivateRepositoryDeployKey;
use App\Livewire\Project\New\PublicGitRepository;
use App\Livewire\Project\New\SimpleDockerfile;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\SwarmDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
Queue::fake();
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
$this->userA = User::factory()->create();
$this->teamA = Team::factory()->create();
$this->userA->teams()->attach($this->teamA, ['role' => 'owner']);
$this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]);
$this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]);
$this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]);
$this->destinationA = StandaloneDocker::factory()->create([
'server_id' => $this->serverA->id,
'name' => 'dest-a-'.fake()->unique()->word(),
'network' => 'coolify-a-'.fake()->unique()->word(),
]);
$this->userB = User::factory()->create();
$this->teamB = Team::factory()->create();
$this->userB->teams()->attach($this->teamB, ['role' => 'owner']);
$this->serverB = Server::factory()->create(['team_id' => $this->teamB->id]);
$this->projectB = Project::factory()->create(['team_id' => $this->teamB->id]);
$this->environmentB = Environment::factory()->create(['project_id' => $this->projectB->id]);
$this->destinationB = StandaloneDocker::factory()->create([
'server_id' => $this->serverB->id,
'name' => 'dest-b-'.fake()->unique()->word(),
'network' => 'coolify-b-'.fake()->unique()->word(),
]);
$this->swarmDestinationB = SwarmDocker::create([
'uuid' => fake()->uuid(),
'name' => 'swarm-b-'.fake()->unique()->word(),
'network' => 'swarm-b-'.fake()->unique()->word(),
'server_id' => $this->serverB->id,
]);
$this->actingAs($this->userA);
session(['currentTeam' => $this->teamA]);
});
describe('find_destination_for_current_team helper', function () {
test('returns null for other team destination UUID', function () {
expect(find_destination_for_current_team($this->destinationB->uuid))->toBeNull();
});
test('returns null for other team swarm destination UUID', function () {
expect(find_destination_for_current_team($this->swarmDestinationB->uuid))->toBeNull();
});
test('returns own team destination', function () {
$found = find_destination_for_current_team($this->destinationA->uuid);
expect($found)->not->toBeNull();
expect($found->id)->toBe($this->destinationA->id);
});
test('returns null for blank uuid', function () {
expect(find_destination_for_current_team(null))->toBeNull();
expect(find_destination_for_current_team(''))->toBeNull();
});
});
describe('SimpleDockerfile destination team scope', function () {
test('submit with other team destination throws and creates no application', function () {
$routeParams = [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
];
request()->headers->set('referer', route('project.resource.create', $routeParams).'?destination='.$this->destinationB->uuid);
$before = Application::count();
expect(fn () => Livewire::withUrlParams(['destination' => $this->destinationB->uuid])
->test(SimpleDockerfile::class, $routeParams)
->set('dockerfile', "FROM nginx\nCMD [\"nginx\"]\n")
->call('submit'))
->toThrow(Exception::class, 'Destination not found.');
expect(Application::count())->toBe($before);
});
});
describe('DockerImage destination team scope', function () {
test('submit with other team destination throws and creates no application', function () {
$routeParams = [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
];
$before = Application::count();
expect(fn () => Livewire::withUrlParams(['destination' => $this->destinationB->uuid])
->test(DockerImage::class, $routeParams)
->set('imageName', 'nginx')
->set('imageTag', 'latest')
->call('submit'))
->toThrow(Exception::class, 'Destination not found.');
expect(Application::count())->toBe($before);
});
test('submit with other team swarm destination throws', function () {
$routeParams = [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
];
expect(fn () => Livewire::withUrlParams(['destination' => $this->swarmDestinationB->uuid])
->test(DockerImage::class, $routeParams)
->set('imageName', 'nginx')
->set('imageTag', 'latest')
->call('submit'))
->toThrow(Exception::class, 'Destination not found.');
});
});
describe('DockerCompose destination + server_id team scope', function () {
test('submit with other team destination throws and creates no service', function () {
$routeParams = [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
];
$before = Service::count();
Livewire::withUrlParams([
'destination' => $this->destinationB->uuid,
'server_id' => $this->serverB->id,
])
->test(DockerCompose::class, $routeParams)
->set('dockerComposeRaw', "services:\n app:\n image: nginx\n")
->call('submit');
expect(Service::count())->toBe($before);
});
});
describe('PublicGitRepository destination team scope', function () {
test('submit with other team destination creates no application', function () {
$routeParams = [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
];
$before = Application::count();
try {
Livewire::withUrlParams(['destination' => $this->destinationB->uuid])
->test(PublicGitRepository::class, $routeParams)
->set('repository_url', 'https://github.com/coollabsio/coolify')
->set('git_repository', 'coollabsio/coolify')
->set('git_branch', 'main')
->set('port', 3000)
->set('build_pack', 'nixpacks')
->set('git_source', 'other')
->call('submit');
} catch (Throwable $e) {
// submit wraps errors via handleError; count assertion below is source of truth
}
expect(Application::count())->toBe($before);
});
});
describe('GithubPrivateRepository destination team scope', function () {
test('submit with other team destination throws and creates no application', function () {
$routeParams = [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
];
$before = Application::count();
try {
Livewire::withUrlParams(['destination' => $this->destinationB->uuid])
->test(GithubPrivateRepository::class, $routeParams)
->call('submit');
} catch (Throwable $e) {
// expected
}
expect(Application::count())->toBe($before);
});
});
describe('GithubPrivateRepositoryDeployKey destination team scope', function () {
test('submit with other team destination throws and creates no application', function () {
$routeParams = [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
];
$before = Application::count();
try {
Livewire::withUrlParams(['destination' => $this->destinationB->uuid])
->test(GithubPrivateRepositoryDeployKey::class, $routeParams)
->call('submit');
} catch (Throwable $e) {
// expected
}
expect(Application::count())->toBe($before);
});
});
describe('Resource/Create database destination team scope', function () {
test('mount with other team destination does not create database', function () {
$before = StandalonePostgresql::count();
$url = route('project.resource.create', [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
]).'?type=postgresql&destination='.$this->destinationB->uuid.'&server_id='.$this->serverB->id.'&database_image=postgres:16-alpine';
$this->get($url);
expect(StandalonePostgresql::count())->toBe($before);
});
});
describe('StandaloneDocker/SwarmDocker ownedByCurrentTeam scope', function () {
test('StandaloneDocker::ownedByCurrentTeam excludes other team destinations', function () {
expect(StandaloneDocker::ownedByCurrentTeam()->where('uuid', $this->destinationB->uuid)->first())->toBeNull();
});
test('SwarmDocker::ownedByCurrentTeam excludes other team destinations', function () {
expect(SwarmDocker::ownedByCurrentTeam()->where('uuid', $this->swarmDestinationB->uuid)->first())->toBeNull();
});
test('StandaloneDocker::ownedByCurrentTeam returns own destination', function () {
$found = StandaloneDocker::ownedByCurrentTeam()->where('uuid', $this->destinationA->uuid)->first();
expect($found)->not->toBeNull();
expect($found->id)->toBe($this->destinationA->id);
});
test('StandaloneDocker::ownedByCurrentTeamAPI scopes by explicit team id', function () {
expect(StandaloneDocker::ownedByCurrentTeamAPI($this->teamA->id)->where('uuid', $this->destinationB->uuid)->first())->toBeNull();
expect(StandaloneDocker::ownedByCurrentTeamAPI($this->teamB->id)->where('uuid', $this->destinationB->uuid)->first()?->id)->toBe($this->destinationB->id);
});
test('SwarmDocker::ownedByCurrentTeamAPI scopes by explicit team id', function () {
expect(SwarmDocker::ownedByCurrentTeamAPI($this->teamA->id)->where('uuid', $this->swarmDestinationB->uuid)->first())->toBeNull();
expect(SwarmDocker::ownedByCurrentTeamAPI($this->teamB->id)->where('uuid', $this->swarmDestinationB->uuid)->first()?->id)->toBe($this->swarmDestinationB->id);
});
});
describe('Destination/Show team scope', function () {
test('mount with other team destination UUID redirects to index', function () {
$component = Livewire::test(DestinationShow::class, ['destination_uuid' => $this->destinationB->uuid]);
expect($component->get('destination'))->toBeNull();
$component->assertRedirect(route('destination.index'));
});
test('mount with own destination UUID loads it', function () {
$component = Livewire::test(DestinationShow::class, ['destination_uuid' => $this->destinationA->uuid]);
expect($component->get('destination'))->not->toBeNull();
expect($component->get('destination')->id)->toBe($this->destinationA->id);
});
test('mount with other team swarm destination UUID redirects to index', function () {
$component = Livewire::test(DestinationShow::class, ['destination_uuid' => $this->swarmDestinationB->uuid]);
expect($component->get('destination'))->toBeNull();
$component->assertRedirect(route('destination.index'));
});
});
@@ -0,0 +1,96 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\GithubApp;
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;
uses(RefreshDatabase::class);
beforeEach(function () {
// Team A (current actor)
$this->userA = User::factory()->create();
$this->teamA = Team::factory()->create();
$this->userA->teams()->attach($this->teamA, ['role' => 'owner']);
$this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]);
$this->destinationA = StandaloneDocker::factory()->create(['server_id' => $this->serverA->id, 'network' => 'net-a-'.fake()->uuid()]);
$this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]);
$this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]);
// Team B (other team)
$this->userB = User::factory()->create();
$this->teamB = Team::factory()->create();
$this->userB->teams()->attach($this->teamB, ['role' => 'owner']);
$this->serverB = Server::factory()->create(['team_id' => $this->teamB->id]);
$this->destinationB = StandaloneDocker::factory()->create(['server_id' => $this->serverB->id, 'network' => 'net-b-'.fake()->uuid()]);
$this->projectB = Project::factory()->create(['team_id' => $this->teamB->id]);
$this->environmentB = Environment::factory()->create(['project_id' => $this->projectB->id]);
// Authenticate as Team A
$this->actingAs($this->userA);
session(['currentTeam' => $this->teamA]);
});
test('unscoped Project lookup returns another teams project', function () {
$project = Project::where('uuid', $this->projectB->uuid)->first();
expect($project)->not->toBeNull()
->and($project->team_id)->toBe($this->teamB->id)
->and($project->team_id)->not->toBe($this->teamA->id);
});
test('unscoped StandaloneDocker lookup returns another teams destination', function () {
$dest = StandaloneDocker::where('uuid', $this->destinationB->uuid)->first();
expect($dest)->not->toBeNull()
->and($dest->server->team_id)->toBe($this->teamB->id);
});
test('ownedByCurrentTeam scope blocks other-team Project access', function () {
expect(Project::ownedByCurrentTeam()->where('uuid', $this->projectB->uuid)->first())->toBeNull();
});
test('ownedByCurrentTeam scope allows own Project access', function () {
expect(Project::ownedByCurrentTeam()->where('uuid', $this->projectA->uuid)->first())->not->toBeNull();
});
test('Team A can create Application in Team B environment via unscoped lookups', function () {
$destination = StandaloneDocker::where('uuid', $this->destinationB->uuid)->first();
$project = Project::where('uuid', $this->projectB->uuid)->first();
$environment = $project->load(['environments'])->environments->where('uuid', $this->environmentB->uuid)->first();
$application = Application::create([
'name' => 'team-scope-test-canary',
'repository_project_id' => 0,
'git_repository' => 'coollabsio/coolify',
'git_branch' => 'main',
'build_pack' => 'dockerfile',
'dockerfile' => "FROM alpine\nCMD echo hello",
'ports_exposes' => 80,
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
'health_check_enabled' => false,
'source_id' => 0,
'source_type' => GithubApp::class,
]);
expect($application->environment_id)->toBe($this->environmentB->id)
->and($application->destination_id)->toBe($this->destinationB->id)
->and($application->environment->project->team->id)->toBe($this->teamB->id)
->and($application->environment->project->team->id)->not->toBe($this->teamA->id);
});
test('resource creation page loads with another teams project UUID', function () {
$response = $this->get(route('project.resource.create', [
'project_uuid' => $this->projectB->uuid,
'environment_uuid' => $this->environmentB->uuid,
]));
expect($response->status())->not->toBe(403);
});