Merge remote-tracking branch 'origin/next' into automation/sync-main-to-next

This commit is contained in:
Andras Bacsai
2026-10-28 12:23:07 +01:00
170 changed files with 5213 additions and 1427 deletions
+2
View File
@@ -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()`.
+10 -3
View File
@@ -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)
+3 -3
View File
@@ -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,
];
}
+64
View File
@@ -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;
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace App\Enums;
enum AccessScope: string
{
case Team = 'team';
case Project = 'project';
case Environment = 'environment';
case Resource = 'resource';
}
+19
View File
@@ -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;
+12 -1
View File
@@ -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,
+2
View File
@@ -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,
+26 -16
View File
@@ -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);
}
+16 -1
View File
@@ -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)]);
}
}
+4 -1
View File
@@ -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 -5
View File
@@ -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));
}
}
+13 -3
View File
@@ -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([
+2 -1
View File
@@ -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', [
+1
View File
@@ -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
View File
@@ -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;
}
+43 -1
View File
@@ -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 {
+1 -1
View File
@@ -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,
+1 -2
View File
@@ -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);
+1 -2
View File
@@ -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) {
+1 -2
View File
@@ -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()
+4 -3
View File
@@ -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");
+4 -5
View File
@@ -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,
+125 -20
View File
@@ -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 {
+5
View File
@@ -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.'),
};
}
+4 -1
View File
@@ -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);
+243
View File
@@ -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(),
];
}
}
+1 -1
View File
@@ -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',
+27 -31
View File
@@ -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) => [
+59 -1
View File
@@ -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,
+1 -1
View File
@@ -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)'],
+1 -1
View File
@@ -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() => [
+2 -3
View File
@@ -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();
+3 -2
View File
@@ -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]);
}
+1 -1
View File
@@ -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);
+3 -3
View File
@@ -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'],
];
}
+2 -2
View File
@@ -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)'],
];
}
+2 -2
View File
@@ -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'],
+1 -1
View File
@@ -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'],
];
}
+1 -1
View File
@@ -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'],
];
}
+1 -1
View File
@@ -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'],
];
}
+2 -2
View File
@@ -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'],
];
}
+1 -1
View File
@@ -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'],
];
}
+2 -2
View File
@@ -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'],
];
}
+1 -1
View File
@@ -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'],
];
}
+1 -1
View File
@@ -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'],
];
}
+1 -1
View File
@@ -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',
+2 -2
View File
@@ -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)'],
];
}
+2 -2
View File
@@ -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)'],
+1 -1
View File
@@ -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'],
];
}
+2 -2
View File
@@ -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)'],
];
}
+2 -2
View File
@@ -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'],
];
}
+1 -2
View File
@@ -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)
+65 -5
View File
@@ -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);
}
}
+15
View File
@@ -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.
*
+4 -2
View File
@@ -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
+2 -1
View File
@@ -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 = [
+5
View File
@@ -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 ?? []);
+2
View File
@@ -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',
];
}
}
+2 -1
View File
@@ -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()));
}
}
}
}
+2 -1
View File
@@ -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';
+2 -1
View File
@@ -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'];
+2 -1
View File
@@ -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',
+2 -1
View File
@@ -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',
+2 -1
View File
@@ -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',
+2 -1
View File
@@ -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',
+2 -1
View File
@@ -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',
+2 -1
View File
@@ -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',
+2 -1
View File
@@ -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'];
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
*/
+36 -4
View File
@@ -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
+19 -34
View File
@@ -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);
}
}
+9 -18
View File
@@ -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);
}
}
+8 -11
View File
@@ -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);
}
}
+35 -39
View File
@@ -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;
}
}
+11 -17
View File
@@ -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;
}
}
+18 -17
View File
@@ -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);
}
}
+9 -5
View File
@@ -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);
}
/**
+13 -3
View File
@@ -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);
}
}
+30 -3
View File
@@ -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;
}
}
+19 -28
View File
@@ -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);
}
}
+2
View File
@@ -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