mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
Merge remote-tracking branch 'origin/next' into automation/sync-main-to-next
This commit is contained in:
@@ -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:<version>-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=<name> 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()`.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum AccessLevel: string
|
||||
{
|
||||
case Read = 'read';
|
||||
case ReadSensitive = 'read:sensitive';
|
||||
case Deploy = 'deploy';
|
||||
case Write = 'write';
|
||||
case Delete = 'delete';
|
||||
case Root = 'root';
|
||||
|
||||
/**
|
||||
* The levels that a grant of this level also gives.
|
||||
*
|
||||
* Write includes ReadSensitive because a user who can change a resource
|
||||
* can always read its secrets back through the compose file or a deploy.
|
||||
*
|
||||
* @return list<self>
|
||||
*/
|
||||
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<string> $values
|
||||
* @return list<self>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum AccessScope: string
|
||||
{
|
||||
case Team = 'team';
|
||||
case Project = 'project';
|
||||
case Environment = 'environment';
|
||||
case Resource = 'resource';
|
||||
}
|
||||
@@ -253,6 +253,25 @@ class SshMultiplexingHelper
|
||||
.$delimiter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an SSH command that runs the command batch given to the process as input (see sshBatchInput()).
|
||||
* The batch is not part of any command line on the Coolify host, where `ps` would show its secrets
|
||||
* (database passwords, registry passwords, tokens), and it is not limited to 128 KiB.
|
||||
* Like the quoted heredoc of generateSshCommand(), the batch gets no local shell expansion.
|
||||
*/
|
||||
public static function generateSshBatchCommand(Server $server, bool $disableMultiplexing = false, ?int $commandTimeout = null): string
|
||||
{
|
||||
return self::generateSshStdinCommand($server, self::remoteShellCommand(), $disableMultiplexing, $commandTimeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* The process input for generateSshBatchCommand(). A heredoc ends its content with a newline, so does this.
|
||||
*/
|
||||
public static function sshBatchInput(string $command): string
|
||||
{
|
||||
return $command.PHP_EOL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an SSH command that runs one short remote command and leaves stdin free for data.
|
||||
* Large content must go through stdin: Linux limits one exec argument to 128 KiB, and
|
||||
|
||||
@@ -375,6 +375,8 @@ class ApplicationsController extends Controller
|
||||
'environment_uuid' => ['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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Services\Access\AccessResolver;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Blocks team-level pages (servers, keys, sources, notifications, team settings, ...) for members
|
||||
* whose grants cover only some projects, environments, or resources of the session team.
|
||||
*/
|
||||
class EnsureTeamWideAccess
|
||||
{
|
||||
/**
|
||||
* @param Closure(Request): (Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->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);
|
||||
}
|
||||
}
|
||||
@@ -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)]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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', [
|
||||
|
||||
@@ -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',
|
||||
|
||||
+405
-379
@@ -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<array<string, mixed>>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:<uuid>", "environment:<uuid>", or "resource:<uuid>".
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
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<array{value: string, label: string, header?: bool}>
|
||||
*/
|
||||
#[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<Grant> 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 {
|
||||
|
||||
@@ -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.'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Team\Member;
|
||||
|
||||
use App\Enums\AccessLevel;
|
||||
use App\Enums\AccessScope;
|
||||
use App\Enums\Role;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Scopes\VisibleThroughAccessGrants;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Services\Access\AccessResolver;
|
||||
use App\Services\Access\Grant;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* Edits the access grants (`team_user.access`) of one member with the `member` role.
|
||||
*
|
||||
* The members page renders one editor; a member row opens it with the `open-member-access` event.
|
||||
* A row target is `team`, or `<scope>:<uuid>` 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<array{key: string, target: string, levels: array<string, bool>}> */
|
||||
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<array{value: string, label: string, header?: bool}>
|
||||
*/
|
||||
#[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<AccessLevel> $levels
|
||||
* @return array{key: string, target: string, levels: array<string, bool>}
|
||||
*/
|
||||
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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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<int, string>
|
||||
*/
|
||||
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<Server>
|
||||
*/
|
||||
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);
|
||||
|
||||
@@ -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) => [
|
||||
|
||||
@@ -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<int, class-string<Tool>>
|
||||
*/
|
||||
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<int, class-string<Tool>>
|
||||
*/
|
||||
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,
|
||||
|
||||
@@ -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 <provider>_<catalog>, 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)'],
|
||||
|
||||
@@ -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() => [
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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)'],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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)'],
|
||||
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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)'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<ApplicationFactory> */
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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 ?? []);
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Scopes;
|
||||
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Services\Access\AccessResolver;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Scope;
|
||||
|
||||
/**
|
||||
* Hides projects, environments, and resources that the authenticated member or API token has no grant for.
|
||||
*
|
||||
* It filters only when the actor has no team-wide grant, so owners, admins, team-wide members and tokens,
|
||||
* queue workers, and console commands see the same rows as before.
|
||||
*/
|
||||
class VisibleThroughAccessGrants implements Scope
|
||||
{
|
||||
public function apply(Builder $builder, Model $model): void
|
||||
{
|
||||
$resolver = app(AccessResolver::class);
|
||||
if ($resolver->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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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'];
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'];
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
+1
-1
@@ -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()
|
||||
|
||||
+12
-1
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user