diff --git a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php index 98c5395bfd..6946d9a687 100644 --- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php +++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php @@ -125,7 +125,10 @@ class GithubPrivateRepositoryDeployKey extends Component public function setPrivateKey($private_key_id) { - $this->private_key_id = $private_key_id; + $this->authorize('create', Application::class); + + $privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($private_key_id); + $this->private_key_id = $privateKey->id; $this->current_step = 'repository'; } @@ -133,6 +136,8 @@ class GithubPrivateRepositoryDeployKey extends Component { $this->authorize('create', Application::class); + $privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id); + $this->validate(); try { $destination_uuid = $this->query['destination'] ?? null; @@ -160,7 +165,7 @@ class GithubPrivateRepositoryDeployKey extends Component 'environment_id' => $environment->id, 'destination_id' => $destination->id, 'destination_type' => $destination_class, - 'private_key_id' => $this->private_key_id, + 'private_key_id' => $privateKey->id, ]; } else { $application_init = [ @@ -173,7 +178,7 @@ class GithubPrivateRepositoryDeployKey extends Component 'environment_id' => $environment->id, 'destination_id' => $destination->id, 'destination_type' => $destination_class, - 'private_key_id' => $this->private_key_id, + 'private_key_id' => $privateKey->id, 'source_id' => $this->git_source->id, 'source_type' => $this->git_source->getMorphClass(), ]; diff --git a/tests/Feature/Authorization/DeployKeyApplicationCreationAuthorizationTest.php b/tests/Feature/Authorization/DeployKeyApplicationCreationAuthorizationTest.php new file mode 100644 index 0000000000..62cf02ef3b --- /dev/null +++ b/tests/Feature/Authorization/DeployKeyApplicationCreationAuthorizationTest.php @@ -0,0 +1,156 @@ +forceCreate(['id' => 0]); + + $this->rootTeam = Team::query()->forceCreate(['id' => 0, 'name' => 'Root Team']); + $this->team = Team::factory()->create(); + $this->otherTeam = Team::factory()->create(); + $this->admin = User::factory()->create(); + $this->member = User::factory()->create(); + $this->team->members()->attach($this->admin, ['role' => 'admin']); + $this->team->members()->attach($this->member, ['role' => 'member']); + + $this->privateKey = PrivateKey::withoutEvents(fn () => PrivateKey::factory()->create(['uuid' => (string) Str::uuid(), 'team_id' => $this->team->id])); + $this->otherPrivateKey = PrivateKey::withoutEvents(fn () => PrivateKey::factory()->create(['uuid' => (string) Str::uuid(), 'team_id' => $this->otherTeam->id])); + $this->rootPrivateKey = PrivateKey::withoutEvents(fn () => PrivateKey::factory()->create(['uuid' => (string) Str::uuid(), 'team_id' => 0])); + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); +}); + +function actAsDeployKeyUser(User $user, Team $team): void +{ + test()->actingAs($user); + session(['currentTeam' => $team]); +} + +function deployKeyComponent(): Testable +{ + return Livewire::test(GithubPrivateRepositoryDeployKey::class, ['type' => 'private-deploy-key']) + ->set('parameters', [ + 'project_uuid' => test()->project->uuid, + 'environment_uuid' => test()->environment->uuid, + ]) + ->set('query', ['destination' => test()->destination->uuid]); +} + +function deployKeyApiPayload(PrivateKey $privateKey): array +{ + return [ + 'project_uuid' => test()->project->uuid, + 'environment_uuid' => test()->environment->uuid, + 'server_uuid' => test()->server->uuid, + 'private_key_uuid' => $privateKey->uuid, + 'git_repository' => 'git@attacker.example:owner/repository.git', + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', + 'autogenerate_domain' => false, + ]; +} + +test('admin can select and create an application with a key owned by the current team', function () { + actAsDeployKeyUser($this->admin, $this->team); + + deployKeyComponent() + ->call('setPrivateKey', $this->privateKey->id) + ->assertSet('private_key_id', $this->privateKey->id) + ->set('repository_url', 'git@example.com:owner/repository.git') + ->set('branch', 'main') + ->call('submit'); + + expect(Application::query()->sole()->private_key_id)->toBe($this->privateKey->id); +}); + +test('member cannot invoke the deploy key selection action directly', function () { + actAsDeployKeyUser($this->member, $this->team); + $this->withoutExceptionHandling(); + + expect(fn () => deployKeyComponent()->call('setPrivateKey', $this->privateKey->id)) + ->toThrow(AuthorizationException::class); + + expect(Application::query()->count())->toBe(0); +}); + +test('deploy key selection rejects foreign ids uuids missing ids and root team keys', function (mixed $key): void { + actAsDeployKeyUser($this->admin, $this->team); + + expect(fn () => deployKeyComponent()->call('setPrivateKey', value($key))) + ->toThrow(ModelNotFoundException::class); + + expect(Application::query()->count())->toBe(0); +})->with([ + 'another team id' => fn () => test()->otherPrivateKey->id, + 'another team uuid' => fn () => test()->otherPrivateKey->uuid, + 'missing id' => 999999, + 'missing uuid' => '00000000-0000-0000-0000-000000000000', + 'root team key' => fn () => test()->rootPrivateKey->id, +]); + +test('submit rejects a foreign key injected through hydrated component state without side effects', function () { + actAsDeployKeyUser($this->admin, $this->team); + + expect(fn () => deployKeyComponent() + ->set('private_key_id', $this->otherPrivateKey->id) + ->set('repository_url', 'git@attacker.example:owner/repository.git') + ->set('branch', 'main') + ->call('submit')) + ->toThrow(ModelNotFoundException::class); + + expect(Application::query()->count())->toBe(0) + ->and(Storage::disk('ssh-keys')->allFiles())->toBeEmpty(); +}); + +test('private deploy key api accepts a current team key and rejects foreign and root team keys', function () { + actAsDeployKeyUser($this->admin, $this->team); + $token = $this->admin->createToken('deploy-key-test', ['*'])->plainTextToken; + auth()->logout(); + + $this->withToken($token) + ->postJson('/api/v1/applications/private-deploy-key', deployKeyApiPayload($this->privateKey)) + ->assertCreated(); + + foreach ([$this->otherPrivateKey, $this->rootPrivateKey] as $unownedKey) { + $this->withToken($token) + ->postJson('/api/v1/applications/private-deploy-key', deployKeyApiPayload($unownedKey)) + ->assertNotFound(); + } + + expect(Application::query()->count())->toBe(1) + ->and(Application::query()->sole()->private_key_id)->toBe($this->privateKey->id); +}); + +test('member cannot create a private deploy key application through the api', function () { + actAsDeployKeyUser($this->member, $this->team); + $token = $this->member->createToken('deploy-key-member-test', ['*'])->plainTextToken; + auth()->logout(); + + $this->withToken($token) + ->postJson('/api/v1/applications/private-deploy-key', deployKeyApiPayload($this->privateKey)) + ->assertForbidden(); + + expect(Application::query()->count())->toBe(0); +});