diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index ddef74226..9471307f3 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -54,6 +54,10 @@ private function removeSensitiveData($application) ]); } + if ($application->is_shown_once ?? false) { + $application->makeHidden(['value', 'real_value']); + } + return serializeApiResponse($application); } diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index ee4d84f10..791972eb5 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -35,6 +35,10 @@ private function removeSensitiveData($service) ]); } + if ($service->is_shown_once ?? false) { + $service->makeHidden(['value', 'real_value']); + } + return serializeApiResponse($service); } diff --git a/app/Http/Middleware/ApiAbility.php b/app/Http/Middleware/ApiAbility.php index 324eeebaa..d42e09136 100644 --- a/app/Http/Middleware/ApiAbility.php +++ b/app/Http/Middleware/ApiAbility.php @@ -6,9 +6,34 @@ class ApiAbility extends CheckForAnyAbility { + /** + * Permissions that only admins/owners may use. + */ + private const MEMBER_DISALLOWED_ABILITIES = [ + 'root', + 'write', + 'write:sensitive', + 'deploy', + 'read:sensitive', + ]; + public function handle($request, $next, ...$abilities) { try { + $token = $request->user()->currentAccessToken(); + $teamId = (int) data_get($token, 'team_id'); + + if ($teamId && ! $request->user()->isAdminOfTeam($teamId)) { + $tokenAbilities = $token->abilities ?? []; + $disallowed = array_intersect($tokenAbilities, self::MEMBER_DISALLOWED_ABILITIES); + + 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); + } + } + if ($request->user()->tokenCan('root')) { return $next($request); } diff --git a/app/Http/Middleware/ApiSensitiveData.php b/app/Http/Middleware/ApiSensitiveData.php index 49584ddb3..8d7c51d11 100644 --- a/app/Http/Middleware/ApiSensitiveData.php +++ b/app/Http/Middleware/ApiSensitiveData.php @@ -10,10 +10,13 @@ class ApiSensitiveData public function handle(Request $request, Closure $next) { $token = $request->user()->currentAccessToken(); + $hasTokenPermission = $token->can('root') || $token->can('read:sensitive'); + $teamId = (int) data_get($token, 'team_id'); + $isAdmin = $teamId ? $request->user()->isAdminOfTeam($teamId) : false; - // Allow access to sensitive data if token has root or read:sensitive permission + // Allow access to sensitive data only if token has permission AND user is admin/owner $request->attributes->add([ - 'can_read_sensitive' => $token->can('root') || $token->can('read:sensitive'), + 'can_read_sensitive' => $hasTokenPermission && $isAdmin, ]); return $next($request); diff --git a/app/Livewire/Boarding/Index.php b/app/Livewire/Boarding/Index.php index 0f6f45d83..289f8c181 100644 --- a/app/Livewire/Boarding/Index.php +++ b/app/Livewire/Boarding/Index.php @@ -8,12 +8,15 @@ use App\Models\Server; use App\Models\Team; use App\Services\ConfigurationRepository; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Livewire\Component; use Visus\Cuid2\Cuid2; class Index extends Component { + use AuthorizesRequests; + protected $listeners = [ 'refreshBoardingIndex' => 'validateServer', 'prerequisitesInstalled' => 'handlePrerequisitesInstalled', @@ -172,6 +175,9 @@ public function restartBoarding() public function skipBoarding() { + if (auth()->user()?->isMember()) { + return redirect()->route('dashboard'); + } Team::find(currentTeam()->id)->update([ 'show_boarding' => false, ]); @@ -257,6 +263,7 @@ public function savePrivateKey() ]); try { + $this->authorize('create', PrivateKey::class); $privateKey = PrivateKey::createAndStore([ 'name' => $this->privateKeyName, 'description' => $this->privateKeyDescription, @@ -280,6 +287,12 @@ public function saveServer() 'remoteServerUser' => 'required|string', ]); + try { + $this->authorize('create', Server::class); + } catch (\Throwable $e) { + return handleError($e, $this); + } + $this->privateKey = formatPrivateKey($this->privateKey); $foundServer = Server::whereIp($this->remoteServerHost)->first(); if ($foundServer) { diff --git a/app/Livewire/Project/AddEmpty.php b/app/Livewire/Project/AddEmpty.php index 974f0608a..3430c69bb 100644 --- a/app/Livewire/Project/AddEmpty.php +++ b/app/Livewire/Project/AddEmpty.php @@ -4,11 +4,14 @@ use App\Models\Project; use App\Support\ValidationPatterns; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; use Visus\Cuid2\Cuid2; class AddEmpty extends Component { + use AuthorizesRequests; + public string $name; public string $description = ''; @@ -29,6 +32,7 @@ protected function messages(): array public function submit() { try { + $this->authorize('create', Project::class); $this->validate(); $project = Project::create([ 'name' => $this->name, diff --git a/app/Livewire/Project/Application/Deployment/Show.php b/app/Livewire/Project/Application/Deployment/Show.php index 954670582..189dcca9b 100644 --- a/app/Livewire/Project/Application/Deployment/Show.php +++ b/app/Livewire/Project/Application/Deployment/Show.php @@ -123,6 +123,8 @@ public function copyLogs(): string public function downloadAllLogs(): string { + $this->authorize('update', $this->application); + $logs = decode_remote_command_output($this->application_deployment_queue, includeAll: true) ->map(function ($line) { $prefix = ''; diff --git a/app/Livewire/Project/Application/DeploymentNavbar.php b/app/Livewire/Project/Application/DeploymentNavbar.php index ebdc014ae..f71b7f753 100644 --- a/app/Livewire/Project/Application/DeploymentNavbar.php +++ b/app/Livewire/Project/Application/DeploymentNavbar.php @@ -6,11 +6,14 @@ use App\Models\Application; use App\Models\ApplicationDeploymentQueue; use App\Models\Server; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Carbon; use Livewire\Component; class DeploymentNavbar extends Component { + use AuthorizesRequests; + public ApplicationDeploymentQueue $application_deployment_queue; public Application $application; @@ -35,10 +38,15 @@ public function deploymentFinished() public function show_debug() { - $this->application->settings->is_debug_enabled = ! $this->application->settings->is_debug_enabled; - $this->application->settings->save(); - $this->is_debug_enabled = $this->application->settings->is_debug_enabled; - $this->dispatch('refreshQueue'); + try { + $this->authorize('update', $this->application); + $this->application->settings->is_debug_enabled = ! $this->application->settings->is_debug_enabled; + $this->application->settings->save(); + $this->is_debug_enabled = $this->application->settings->is_debug_enabled; + $this->dispatch('refreshQueue'); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function force_start() diff --git a/app/Livewire/Project/CloneMe.php b/app/Livewire/Project/CloneMe.php index 3b3e42619..58a181f2f 100644 --- a/app/Livewire/Project/CloneMe.php +++ b/app/Livewire/Project/CloneMe.php @@ -11,11 +11,14 @@ use App\Models\Project; use App\Models\Server; use App\Support\ValidationPatterns; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; use Visus\Cuid2\Cuid2; class CloneMe extends Component { + use AuthorizesRequests; + public string $project_uuid; public string $environment_uuid; @@ -91,6 +94,7 @@ public function selectServer($server_id, $destination_id) public function clone(string $type) { try { + $this->authorize('create', Project::class); $this->validate([ 'selectedDestination' => 'required', 'newName' => ValidationPatterns::nameRules(), diff --git a/app/Livewire/Project/Database/Heading.php b/app/Livewire/Project/Database/Heading.php index ef2163f14..94773feb1 100644 --- a/app/Livewire/Project/Database/Heading.php +++ b/app/Livewire/Project/Database/Heading.php @@ -90,6 +90,7 @@ public function restart() $this->authorize('manage', $this->database); $activity = RestartDatabase::run($this->database); + $this->js("window.dispatchEvent(new CustomEvent('startdatabase'))"); $this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class); } catch (\Throwable $e) { return handleError($e, $this); @@ -102,6 +103,7 @@ public function start() $this->authorize('manage', $this->database); $activity = StartDatabase::run($this->database); + $this->js("window.dispatchEvent(new CustomEvent('startdatabase'))"); $this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class); } catch (\Throwable $e) { return handleError($e, $this); diff --git a/app/Livewire/Project/Edit.php b/app/Livewire/Project/Edit.php index a2d73eb5f..1314c9e4b 100644 --- a/app/Livewire/Project/Edit.php +++ b/app/Livewire/Project/Edit.php @@ -4,10 +4,13 @@ use App\Models\Project; use App\Support\ValidationPatterns; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class Edit extends Component { + use AuthorizesRequests; + public Project $project; public string $name; @@ -54,6 +57,7 @@ public function syncData(bool $toModel = false) public function submit() { try { + $this->authorize('update', $this->project); $this->syncData(true); $this->dispatch('success', 'Project updated.'); } catch (\Throwable $e) { diff --git a/app/Livewire/Project/EnvironmentEdit.php b/app/Livewire/Project/EnvironmentEdit.php index 529b9d7b1..9b9a3670d 100644 --- a/app/Livewire/Project/EnvironmentEdit.php +++ b/app/Livewire/Project/EnvironmentEdit.php @@ -5,11 +5,14 @@ use App\Models\Application; use App\Models\Project; use App\Support\ValidationPatterns; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Locked; use Livewire\Component; class EnvironmentEdit extends Component { + use AuthorizesRequests; + public Project $project; public Application $application; @@ -62,6 +65,7 @@ public function syncData(bool $toModel = false) public function submit() { try { + $this->authorize('update', $this->environment); $this->syncData(true); redirectRoute($this, 'project.environment.edit', [ 'environment_uuid' => $this->environment->uuid, diff --git a/app/Livewire/Project/Service/Heading.php b/app/Livewire/Project/Service/Heading.php index adc2b151b..20502393b 100644 --- a/app/Livewire/Project/Service/Heading.php +++ b/app/Livewire/Project/Service/Heading.php @@ -105,6 +105,7 @@ public function start() try { $this->authorize('deploy', $this->service); $activity = StartService::run($this->service, pullLatestImages: true); + $this->js("window.dispatchEvent(new CustomEvent('startservice'))"); $this->dispatch('activityMonitor', $activity->id); } catch (\Throwable $e) { return handleError($e, $this); @@ -125,6 +126,7 @@ public function forceDeploy() $activity->save(); } $activity = StartService::run($this->service, pullLatestImages: true, stopBeforeStart: true); + $this->js("window.dispatchEvent(new CustomEvent('startservice'))"); $this->dispatch('activityMonitor', $activity->id); } catch (\Throwable $e) { return handleError($e, $this); @@ -152,6 +154,7 @@ public function restart() return; } $activity = StartService::run($this->service, stopBeforeStart: true); + $this->js("window.dispatchEvent(new CustomEvent('startservice'))"); $this->dispatch('activityMonitor', $activity->id); } catch (\Throwable $e) { return handleError($e, $this); @@ -169,6 +172,7 @@ public function pullAndRestartEvent() return; } $activity = StartService::run($this->service, pullLatestImages: true, stopBeforeStart: true); + $this->js("window.dispatchEvent(new CustomEvent('startservice'))"); $this->dispatch('activityMonitor', $activity->id); } catch (\Throwable $e) { return handleError($e, $this); diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php index 55e388b78..766e2a9b9 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php @@ -72,7 +72,7 @@ public function getEnvironmentVariablesProperty() $query->orderBy('order'); } - return $query->get(); + return $this->nullLockedValues($query->get()); } public function getEnvironmentVariablesPreviewProperty() @@ -86,7 +86,21 @@ public function getEnvironmentVariablesPreviewProperty() $query->orderBy('order'); } - return $query->get(); + return $this->nullLockedValues($query->get()); + } + + private function nullLockedValues($envs) + { + $isMember = auth()->user()?->isMember(); + + $envs->each(function ($env) use ($isMember) { + if ($env->is_shown_once || $isMember) { + $env->value = null; + $env->real_value = null; + } + }); + + return $envs; } public function getDevView() @@ -99,7 +113,12 @@ public function getDevView() private function formatEnvironmentVariables($variables) { - return $variables->map(function ($item) { + $isMember = auth()->user()?->isMember(); + + return $variables->map(function ($item) use ($isMember) { + if ($isMember) { + return "$item->key=(Hidden, only admins can view)"; + } if ($item->is_shown_once) { return "$item->key=(Locked Secret, delete and add again to change)"; } diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php index 2030f631e..1dc01cc48 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php @@ -52,6 +52,8 @@ class Show extends Component public bool $is_redis_credential = false; + public bool $isValueHidden = false; + public array $problematicVariables = []; protected $listeners = [ @@ -134,6 +136,13 @@ public function syncData(bool $toModel = false) $this->is_really_required = $this->env->is_really_required ?? false; $this->is_shared = $this->env->is_shared ?? false; $this->real_value = $this->env->real_value; + + if ($this->env->is_shown_once || auth()->user()?->isMember()) { + $this->value = null; + $this->real_value = null; + } + + $this->isValueHidden = auth()->user()?->isMember() ?? false; } } diff --git a/app/Livewire/Security/ApiTokens.php b/app/Livewire/Security/ApiTokens.php index d22d5d9fc..af2ae189a 100644 --- a/app/Livewire/Security/ApiTokens.php +++ b/app/Livewire/Security/ApiTokens.php @@ -4,6 +4,7 @@ use App\Models\InstanceSettings; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\Str; use Laravel\Sanctum\PersonalAccessToken; use Livewire\Component; @@ -25,6 +26,8 @@ class ApiTokens extends Component public bool $canUseDeployPermissions = false; + public bool $canUseSensitivePermissions = false; + public function render() { return view('livewire.security.api-tokens'); @@ -36,6 +39,7 @@ public function mount() $this->canUseRootPermissions = auth()->user()->can('useRootPermissions', PersonalAccessToken::class); $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->getTokens(); } @@ -70,6 +74,13 @@ public function updatedPermissions($permissionToUpdate) return; } + if ($permissionToUpdate == 'read:sensitive' && ! $this->canUseSensitivePermissions) { + $this->dispatch('error', 'You do not have permission to use read:sensitive permissions.'); + $this->permissions = array_diff($this->permissions, ['read:sensitive']); + + return; + } + if ($permissionToUpdate == 'root') { $this->permissions = ['root']; } elseif ($permissionToUpdate == 'read:sensitive' && ! in_array('read', $this->permissions)) { @@ -102,12 +113,16 @@ public function addNewToken() throw new \Exception('You do not have permission to create tokens with deploy permissions.'); } + if (in_array('read:sensitive', $this->permissions) && ! $this->canUseSensitivePermissions) { + throw new \Exception('You do not have permission to create tokens with read:sensitive permissions.'); + } + $this->validate([ 'description' => 'required|min:3|max:255', ]); $token = auth()->user()->createToken($this->description, array_values($this->permissions)); $this->getTokens(); - session()->flash('token', $token->plainTextToken); + session()->flash('token', Str::after($token->plainTextToken, '|')); } catch (\Exception $e) { return handleError($e, $this); } diff --git a/app/Livewire/Server/CloudflareTunnel.php b/app/Livewire/Server/CloudflareTunnel.php index 24f8e022e..2ab829854 100644 --- a/app/Livewire/Server/CloudflareTunnel.php +++ b/app/Livewire/Server/CloudflareTunnel.php @@ -72,12 +72,16 @@ public function toggleCloudflareTunnels() public function manualCloudflareConfig() { - $this->authorize('update', $this->server); - $this->isCloudflareTunnelsEnabled = true; - $this->server->settings->is_cloudflare_tunnel = true; - $this->server->settings->save(); - $this->server->refresh(); - $this->dispatch('success', 'Cloudflare Tunnel enabled.'); + try { + $this->authorize('update', $this->server); + $this->isCloudflareTunnelsEnabled = true; + $this->server->settings->is_cloudflare_tunnel = true; + $this->server->settings->save(); + $this->server->refresh(); + $this->dispatch('success', 'Cloudflare Tunnel enabled.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function automatedCloudflareConfig() diff --git a/app/Livewire/Server/Destinations.php b/app/Livewire/Server/Destinations.php index 117b43ad6..622bc1d1e 100644 --- a/app/Livewire/Server/Destinations.php +++ b/app/Livewire/Server/Destinations.php @@ -69,6 +69,11 @@ public function add($name) public function scan() { + try { + $this->authorize('update', $this->server); + } catch (\Throwable $e) { + return handleError($e, $this); + } if ($this->server->isSwarm()) { $alreadyAddedNetworks = $this->server->swarmDockers; } else { diff --git a/app/Livewire/Server/Proxy/DynamicConfigurations.php b/app/Livewire/Server/Proxy/DynamicConfigurations.php index 6ea9e7c3d..f824645aa 100644 --- a/app/Livewire/Server/Proxy/DynamicConfigurations.php +++ b/app/Livewire/Server/Proxy/DynamicConfigurations.php @@ -3,11 +3,14 @@ namespace App\Livewire\Server\Proxy; use App\Models\Server; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Livewire\Component; class DynamicConfigurations extends Component { + use AuthorizesRequests; + public ?Server $server = null; public $parameters = []; @@ -35,6 +38,11 @@ public function initLoadDynamicConfigurations() public function loadDynamicConfigurations() { + try { + $this->authorize('view', $this->server); + } catch (\Throwable $e) { + return handleError($e, $this); + } $proxy_path = $this->server->proxyPath(); $files = instant_remote_process(["mkdir -p $proxy_path/dynamic && ls -1 {$proxy_path}/dynamic"], $this->server); $files = collect(explode("\n", $files))->filter(fn ($file) => ! empty($file)); diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index 38053386e..462c5de84 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -404,6 +404,7 @@ public function instantSave() public function checkHetznerServerStatus(bool $manual = false) { try { + $this->authorize('view', $this->server); if (! $this->server->hetzner_server_id || ! $this->server->cloudProviderToken) { $this->dispatch('error', 'This server is not associated with a Hetzner Cloud server or token.'); @@ -468,6 +469,7 @@ public function handleServerValidated($event = null) public function startHetznerServer() { try { + $this->authorize('update', $this->server); if (! $this->server->hetzner_server_id || ! $this->server->cloudProviderToken) { $this->dispatch('error', 'This server is not associated with a Hetzner Cloud server or token.'); diff --git a/app/Livewire/Settings/Advanced.php b/app/Livewire/Settings/Advanced.php index 16361ce79..c48af76a8 100644 --- a/app/Livewire/Settings/Advanced.php +++ b/app/Livewire/Settings/Advanced.php @@ -4,11 +4,14 @@ use App\Models\InstanceSettings; use App\Rules\ValidIpOrCidr; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Validate; use Livewire\Component; class Advanced extends Component { + use AuthorizesRequests; + public InstanceSettings $settings; #[Validate('boolean')] @@ -72,6 +75,7 @@ public function mount() public function submit() { try { + $this->authorize('update', $this->settings); $this->validate(); $this->custom_dns_servers = str($this->custom_dns_servers)->replaceEnd(',', '')->trim(); @@ -137,6 +141,7 @@ public function submit() public function instantSave() { try { + $this->authorize('update', $this->settings); $this->settings->is_registration_enabled = $this->is_registration_enabled; $this->settings->do_not_track = $this->do_not_track; $this->settings->is_dns_validation_enabled = $this->is_dns_validation_enabled; @@ -155,6 +160,7 @@ public function instantSave() public function toggleTwoStepConfirmation($password): bool { + $this->authorize('update', $this->settings); if (! verifyPasswordConfirmation($password, $this)) { return false; } diff --git a/app/Livewire/Settings/Index.php b/app/Livewire/Settings/Index.php index 9a51d107d..5a7c74172 100644 --- a/app/Livewire/Settings/Index.php +++ b/app/Livewire/Settings/Index.php @@ -4,12 +4,15 @@ use App\Models\InstanceSettings; use App\Models\Server; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Computed; use Livewire\Attributes\Validate; use Livewire\Component; class Index extends Component { + use AuthorizesRequests; + public InstanceSettings $settings; public ?Server $server = null; @@ -86,6 +89,7 @@ public function timezones(): array public function instantSave($isSave = true) { + $this->authorize('update', $this->settings); $this->validate(); $this->settings->fqdn = $this->fqdn ? trim($this->fqdn) : $this->fqdn; $this->settings->public_port_min = $this->public_port_min; @@ -103,6 +107,7 @@ public function instantSave($isSave = true) public function confirmDomainUsage() { + $this->authorize('update', $this->settings); $this->forceSaveDomains = true; $this->showDomainConflictModal = false; $this->submit(); @@ -111,6 +116,7 @@ public function confirmDomainUsage() public function submit() { try { + $this->authorize('update', $this->settings); $error_show = false; $this->resetErrorBag(); @@ -172,6 +178,7 @@ public function submit() public function buildHelperImage() { try { + $this->authorize('update', $this->settings); if (! isDev()) { $this->dispatch('error', 'Building helper image is only available in development mode.'); diff --git a/app/Livewire/Settings/Updates.php b/app/Livewire/Settings/Updates.php index 01a67c38c..88a8945d0 100644 --- a/app/Livewire/Settings/Updates.php +++ b/app/Livewire/Settings/Updates.php @@ -5,11 +5,14 @@ use App\Jobs\CheckForUpdatesJob; use App\Models\InstanceSettings; use App\Models\Server; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Validate; use Livewire\Component; class Updates extends Component { + use AuthorizesRequests; + public InstanceSettings $settings; public ?Server $server = null; @@ -25,6 +28,9 @@ class Updates extends Component public function mount() { + if (! isInstanceAdmin()) { + return redirect()->route('dashboard'); + } if (! isCloud()) { $this->server = Server::findOrFail(0); } @@ -38,6 +44,7 @@ public function mount() public function instantSave() { try { + $this->authorize('update', $this->settings); if ($this->settings->is_auto_update_enabled === true) { $this->validate([ 'auto_update_frequency' => ['required', 'string'], @@ -56,6 +63,7 @@ public function instantSave() public function submit() { try { + $this->authorize('update', $this->settings); $this->resetErrorBag(); $this->validate(); @@ -88,6 +96,7 @@ public function submit() public function checkManually() { + $this->authorize('update', $this->settings); CheckForUpdatesJob::dispatchSync(); $this->dispatch('updateAvailable'); $settings = instanceSettings(); diff --git a/app/Livewire/SettingsBackup.php b/app/Livewire/SettingsBackup.php index 84f5c6081..cfd188b40 100644 --- a/app/Livewire/SettingsBackup.php +++ b/app/Livewire/SettingsBackup.php @@ -7,12 +7,15 @@ use App\Models\ScheduledDatabaseBackup; use App\Models\Server; use App\Models\StandalonePostgresql; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Locked; use Livewire\Attributes\Validate; use Livewire\Component; class SettingsBackup extends Component { + use AuthorizesRequests; + public InstanceSettings $settings; public Server $server; @@ -76,6 +79,7 @@ public function mount() public function addCoolifyDatabase() { try { + $this->authorize('update', $this->settings); $server = Server::findOrFail(0); $out = instant_remote_process(['docker inspect coolify-db'], $server); $envs = format_docker_envs_to_json($out); @@ -120,14 +124,19 @@ public function addCoolifyDatabase() public function submit() { - $this->validate(); + try { + $this->authorize('update', $this->settings); + $this->validate(); - $this->database->update([ - 'name' => $this->name, - 'description' => $this->description, - 'postgres_user' => $this->postgres_user, - 'postgres_password' => $this->postgres_password, - ]); - $this->dispatch('success', 'Backup updated.'); + $this->database->update([ + 'name' => $this->name, + 'description' => $this->description, + 'postgres_user' => $this->postgres_user, + 'postgres_password' => $this->postgres_password, + ]); + $this->dispatch('success', 'Backup updated.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } } } diff --git a/app/Livewire/SettingsEmail.php b/app/Livewire/SettingsEmail.php index ca48e9b16..5ed012b37 100644 --- a/app/Livewire/SettingsEmail.php +++ b/app/Livewire/SettingsEmail.php @@ -5,6 +5,7 @@ use App\Models\InstanceSettings; use App\Models\Team; use App\Notifications\TransactionalEmails\Test; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\RateLimiter; use Livewire\Attributes\Locked; use Livewire\Attributes\Validate; @@ -12,6 +13,8 @@ class SettingsEmail extends Component { + use AuthorizesRequests; + public InstanceSettings $settings; #[Locked] @@ -103,6 +106,7 @@ public function syncData(bool $toModel = false) public function submit() { try { + $this->authorize('update', $this->settings); $this->resetErrorBag(); $this->syncData(true); $this->dispatch('success', 'Transactional email settings updated.'); @@ -114,6 +118,7 @@ public function submit() public function instantSave(string $type) { try { + $this->authorize('update', $this->settings); $currentSmtpEnabled = $this->settings->smtp_enabled; $currentResendEnabled = $this->settings->resend_enabled; $this->resetErrorBag(); @@ -141,6 +146,7 @@ public function instantSave(string $type) public function submitSmtp() { try { + $this->authorize('update', $this->settings); $this->validate([ 'smtpEnabled' => 'boolean', 'smtpFromAddress' => 'required|email', @@ -184,6 +190,7 @@ public function submitSmtp() public function submitResend() { try { + $this->authorize('update', $this->settings); $this->validate([ 'resendEnabled' => 'boolean', 'resendApiKey' => 'required|string', @@ -214,6 +221,7 @@ public function submitResend() public function sendTestEmail() { try { + $this->authorize('update', $this->settings); $this->validate([ 'testEmailAddress' => 'required|email', ], [ diff --git a/app/Livewire/SettingsOauth.php b/app/Livewire/SettingsOauth.php index 6f949b716..44ea4f611 100644 --- a/app/Livewire/SettingsOauth.php +++ b/app/Livewire/SettingsOauth.php @@ -3,10 +3,13 @@ namespace App\Livewire; use App\Models\OauthSetting; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class SettingsOauth extends Component { + use AuthorizesRequests; + public $oauth_settings_map; protected function rules() @@ -131,6 +134,7 @@ private function updateOauthSettings(?string $provider = null) public function instantSave(string $provider) { try { + $this->authorize('update', instanceSettings()); $this->updateOauthSettings($provider); } catch (\Exception $e) { return handleError($e, $this); @@ -139,7 +143,12 @@ public function instantSave(string $provider) public function submit() { - $this->updateOauthSettings(); - $this->dispatch('success', 'Instance settings updated successfully!'); + try { + $this->authorize('update', instanceSettings()); + $this->updateOauthSettings(); + $this->dispatch('success', 'Instance settings updated successfully!'); + } catch (\Throwable $e) { + return handleError($e, $this); + } } } diff --git a/app/Livewire/SharedVariables/Environment/Show.php b/app/Livewire/SharedVariables/Environment/Show.php index 0bdc1503f..9a40e9cad 100644 --- a/app/Livewire/SharedVariables/Environment/Show.php +++ b/app/Livewire/SharedVariables/Environment/Show.php @@ -72,7 +72,12 @@ public function getDevView() private function formatEnvironmentVariables($variables) { - return $variables->map(function ($item) { + $isMember = auth()->user()?->isMember(); + + return $variables->map(function ($item) use ($isMember) { + if ($isMember) { + return "$item->key=(Hidden, only admins can view)"; + } if ($item->is_shown_once) { return "$item->key=(Locked Secret, delete and add again to change)"; } diff --git a/app/Livewire/SharedVariables/Project/Show.php b/app/Livewire/SharedVariables/Project/Show.php index 008f4af5a..b41f3262d 100644 --- a/app/Livewire/SharedVariables/Project/Show.php +++ b/app/Livewire/SharedVariables/Project/Show.php @@ -73,7 +73,12 @@ public function getDevView() private function formatEnvironmentVariables($variables) { - return $variables->map(function ($item) { + $isMember = auth()->user()?->isMember(); + + return $variables->map(function ($item) use ($isMember) { + if ($isMember) { + return "$item->key=(Hidden, only admins can view)"; + } if ($item->is_shown_once) { return "$item->key=(Locked Secret, delete and add again to change)"; } diff --git a/app/Livewire/SharedVariables/Team/Index.php b/app/Livewire/SharedVariables/Team/Index.php index 93e12f376..35da5ae62 100644 --- a/app/Livewire/SharedVariables/Team/Index.php +++ b/app/Livewire/SharedVariables/Team/Index.php @@ -67,7 +67,12 @@ public function getDevView() private function formatEnvironmentVariables($variables) { - return $variables->map(function ($item) { + $isMember = auth()->user()?->isMember(); + + return $variables->map(function ($item) use ($isMember) { + if ($isMember) { + return "$item->key=(Hidden, only admins can view)"; + } if ($item->is_shown_once) { return "$item->key=(Locked Secret, delete and add again to change)"; } diff --git a/app/Livewire/Tags/Show.php b/app/Livewire/Tags/Show.php index fc5b13374..28d6440a9 100644 --- a/app/Livewire/Tags/Show.php +++ b/app/Livewire/Tags/Show.php @@ -5,6 +5,7 @@ use App\Http\Controllers\Api\DeployController; use App\Models\ApplicationDeploymentQueue; use App\Models\Tag; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Livewire\Attributes\Locked; use Livewire\Attributes\Title; @@ -13,6 +14,8 @@ #[Title('Tags | Coolify')] class Show extends Component { + use AuthorizesRequests; + #[Locked] public ?string $tagName = null; @@ -73,6 +76,12 @@ public function getDeployments() public function redeployAll() { try { + $this->applications->each(function ($resource) { + $this->authorize('deploy', $resource); + }); + $this->services->each(function ($resource) { + $this->authorize('deploy', $resource); + }); $message = collect([]); $this->applications->each(function ($resource) use ($message) { $deploy = new DeployController; diff --git a/app/Livewire/Team/AdminView.php b/app/Livewire/Team/AdminView.php index c8d44d42b..cbc8f67b6 100644 --- a/app/Livewire/Team/AdminView.php +++ b/app/Livewire/Team/AdminView.php @@ -25,6 +25,9 @@ public function mount() public function submitSearch() { + if (! isInstanceAdmin()) { + return; + } if ($this->search !== '') { $this->users = User::where(function ($query) { $query->where('name', 'like', "%{$this->search}%") @@ -39,6 +42,9 @@ public function submitSearch() public function getUsers() { + if (! isInstanceAdmin()) { + return; + } $users = User::where('id', '!=', auth()->id())->get(); if ($users->count() > $this->number_of_users_to_show) { $this->lots_of_users = true; diff --git a/app/Policies/ApiTokenPolicy.php b/app/Policies/ApiTokenPolicy.php index 5eb1a05eb..ba9bade01 100644 --- a/app/Policies/ApiTokenPolicy.php +++ b/app/Policies/ApiTokenPolicy.php @@ -78,4 +78,12 @@ public function useDeployPermissions(User $user): bool { return $user->isAdmin() || $user->isOwner(); } + + /** + * Determine whether the user can use read:sensitive permissions for API tokens. + */ + public function useSensitivePermissions(User $user): bool + { + return $user->isAdmin() || $user->isOwner(); + } } diff --git a/app/Policies/S3StoragePolicy.php b/app/Policies/S3StoragePolicy.php index 982c7c523..81cbd164f 100644 --- a/app/Policies/S3StoragePolicy.php +++ b/app/Policies/S3StoragePolicy.php @@ -36,8 +36,7 @@ public function create(User $user): bool */ public function update(User $user, S3Storage $storage): bool { - // return $user->teams->contains('id', $storage->team_id) && $user->isAdmin(); - return $user->teams->contains('id', $storage->team_id); + return $user->teams->contains('id', $storage->team_id) && $user->isAdminOfTeam($storage->team_id); } /** @@ -45,8 +44,7 @@ public function update(User $user, S3Storage $storage): bool */ public function delete(User $user, S3Storage $storage): bool { - // return $user->teams->contains('id', $storage->team_id) && $user->isAdmin(); - return $user->teams->contains('id', $storage->team_id); + return $user->teams->contains('id', $storage->team_id) && $user->isAdminOfTeam($storage->team_id); } /** @@ -70,6 +68,6 @@ public function forceDelete(User $user, S3Storage $storage): bool */ public function validateConnection(User $user, S3Storage $storage): bool { - return $user->teams->contains('id', $storage->team_id); + return $user->teams->contains('id', $storage->team_id) && $user->isAdminOfTeam($storage->team_id); } } diff --git a/app/View/Components/Forms/Button.php b/app/View/Components/Forms/Button.php index b54444261..8511c87db 100644 --- a/app/View/Components/Forms/Button.php +++ b/app/View/Components/Forms/Button.php @@ -14,6 +14,7 @@ class Button extends Component */ public function __construct( public bool $disabled = false, + public bool $authDisabled = false, public bool $noStyle = false, public ?string $modalId = null, public string $defaultClass = 'button', @@ -28,6 +29,7 @@ public function __construct( if (! $hasPermission) { $this->disabled = true; + $this->authDisabled = true; } } diff --git a/resources/css/utilities.css b/resources/css/utilities.css index 02be0c0c4..349a12ed0 100644 --- a/resources/css/utilities.css +++ b/resources/css/utilities.css @@ -122,7 +122,11 @@ @utility select { } @utility button { - @apply flex gap-2 justify-center items-center px-2 h-8 text-sm text-black normal-case rounded-sm border-2 outline-0 cursor-pointer font-medium bg-white border-neutral-200 hover:bg-neutral-100 dark:bg-coolgray-100 dark:text-white dark:hover:text-white dark:hover:bg-coolgray-200 dark:border-coolgray-300 hover:text-black disabled:cursor-not-allowed min-w-fit dark:disabled:text-neutral-600 disabled:border-transparent disabled:hover:bg-transparent disabled:bg-transparent disabled:text-neutral-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base; + @apply flex gap-2 justify-center items-center px-2 h-8 text-sm text-black normal-case rounded-sm border-2 outline-0 cursor-pointer font-medium bg-white border-neutral-200 hover:bg-neutral-100 dark:bg-coolgray-100 dark:text-white dark:hover:text-white dark:hover:bg-coolgray-200 dark:border-coolgray-300 hover:text-black disabled:cursor-not-allowed min-w-fit dark:disabled:text-neutral-600 disabled:border-neutral-200 dark:disabled:border-coolgray-300 disabled:hover:bg-transparent disabled:bg-transparent disabled:text-neutral-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base; +} + +@utility auth-tooltip { + @apply fixed z-[99] px-2.5 py-1.5 text-xs rounded-sm pointer-events-none whitespace-nowrap text-neutral-700 bg-neutral-200 dark:text-neutral-300 dark:bg-coolgray-400; } @utility alert-success { diff --git a/resources/views/components/forms/button.blade.php b/resources/views/components/forms/button.blade.php index 96cdb4420..f1efd8c6c 100644 --- a/resources/views/components/forms/button.blade.php +++ b/resources/views/components/forms/button.blade.php @@ -1,3 +1,24 @@ +@if ($authDisabled) + +@endif +@if ($authDisabled) +
+ You do not have permission to perform this action. +
+
+@endif diff --git a/resources/views/components/modal-confirmation.blade.php b/resources/views/components/modal-confirmation.blade.php index 73939092e..7ad46b6a1 100644 --- a/resources/views/components/modal-confirmation.blade.php +++ b/resources/views/components/modal-confirmation.blade.php @@ -6,6 +6,7 @@ 'buttonFullWidth' => false, 'customButton' => null, 'disabled' => false, + 'authDisabled' => false, 'dispatchAction' => false, 'submitAction' => 'delete', 'content' => null, @@ -145,11 +146,11 @@ class="relative w-auto h-auto"> @else @if ($disabled) @if ($buttonFullWidth) - + {{ $buttonTitle }} @else - + {{ $buttonTitle }} @endif diff --git a/resources/views/livewire/project/application/deployment/show.blade.php b/resources/views/livewire/project/application/deployment/show.blade.php index 28872f4bc..5861cef30 100644 --- a/resources/views/livewire/project/application/deployment/show.blade.php +++ b/resources/views/livewire/project/application/deployment/show.blade.php @@ -246,6 +246,7 @@ class="absolute right-0 z-50 mt-2 w-max origin-top-right rounded-md bg-white dar class="block w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-coolgray-300"> Download displayed logs + @can('update', $application) + @endcan diff --git a/resources/views/livewire/project/application/heading.blade.php b/resources/views/livewire/project/application/heading.blade.php index 5db6d4c5c..c73a824bb 100644 --- a/resources/views/livewire/project/application/heading.blade.php +++ b/resources/views/livewire/project/application/heading.blade.php @@ -85,7 +85,7 @@ @endif @endif - destination->server->isFunctional())
@if (!str($database->status)->startsWith('exited')) - - @else - + @endif @script diff --git a/resources/views/livewire/project/service/heading.blade.php b/resources/views/livewire/project/service/heading.blade.php index af057813c..db21f3317 100644 --- a/resources/views/livewire/project/service/heading.blade.php +++ b/resources/views/livewire/project/service/heading.blade.php @@ -40,7 +40,7 @@ Restart - @@ -68,7 +68,7 @@ Restart - @@ -86,7 +86,7 @@ @elseif (str($service->status)->contains('exited')) - + @else - @@ -113,7 +113,7 @@ Stop - + @endif
@else @@ -149,11 +149,9 @@ ); return; } - window.dispatchEvent(new CustomEvent('startservice')); $wire.$call('start'); }); $wire.$on('forceDeployEvent', () => { - window.dispatchEvent(new CustomEvent('startservice')); $wire.$call('forceDeploy'); }); $wire.$on('restartEvent', async () => { @@ -166,12 +164,10 @@ } $wire.$dispatch('info', 'Gracefully stopping service.

It could take a while depending on the service.'); - window.dispatchEvent(new CustomEvent('startservice')); $wire.$call('restart'); }); $wire.$on('pullAndRestartEvent', () => { $wire.$dispatch('info', 'Pulling new images and restarting service.'); - window.dispatchEvent(new CustomEvent('startservice')); $wire.$call('pullAndRestartEvent'); }); $wire.on('imagePulled', () => { diff --git a/resources/views/livewire/project/shared/environment-variable/show.blade.php b/resources/views/livewire/project/shared/environment-variable/show.blade.php index 68e1d7e7d..0bcb127ba 100644 --- a/resources/views/livewire/project/shared/environment-variable/show.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/show.blade.php @@ -139,15 +139,22 @@ @else
- - @if ($is_shared) - + @if ($isValueHidden) +
+ +
+ @else + + @if ($is_shared) + + @endif @endif
@endcan diff --git a/resources/views/livewire/project/shared/health-checks.blade.php b/resources/views/livewire/project/shared/health-checks.blade.php index 47837b230..01f42dace 100644 --- a/resources/views/livewire/project/shared/health-checks.blade.php +++ b/resources/views/livewire/project/shared/health-checks.blade.php @@ -3,7 +3,7 @@

Healthchecks

Save @if (!$healthCheckEnabled) -

Scheduled Task

- + Save @if ($resource->isRunning()) - - Execute Now - + @can('update', $resource) + + Execute Now + + @endcan @endif - - + @can('update', $resource) + + @endcan
- + @can('update', $resource) + + @else + + @endcan
- - - - + + + @if ($type === 'application') - @elseif ($type === 'service') - @endif diff --git a/resources/views/livewire/security/api-tokens.blade.php b/resources/views/livewire/security/api-tokens.blade.php index 1a7b6eb79..bf04aad2a 100644 --- a/resources/views/livewire/security/api-tokens.blade.php +++ b/resources/views/livewire/security/api-tokens.blade.php @@ -18,21 +18,23 @@ Create
-
- Permissions - : -
- @if ($permissions) +
+ Permissions + + @if ($permissions) +
@foreach ($permissions as $permission) -
{{ $permission }}
+ + {{ $permission }} + @endforeach - @endif -
+
+ @endif

Token Permissions

-
+
@if ($canUseRootPermissions) @@ -59,9 +61,14 @@ class="pr-1">: @endif - + @if ($canUseSensitivePermissions) + + @else + + @endif @endif
@if (in_array('root', $permissions)) @@ -70,44 +77,74 @@ class="pr-1">: @endcan @if (session()->has('token')) -
Please copy this token now. For your security, it won't be shown - again. +
+
Please copy this token now. For your security, it won't + be shown again.
+
+ + +
-
{{ session('token') }}
@endif -

Issued Tokens

-
- @forelse ($tokens as $token) -
-
Description: {{ $token->name }}
-
Last used: {{ $token->last_used_at ? $token->last_used_at->diffForHumans() : 'Never' }}
-
- @if ($token->abilities) - Permissions: - @foreach ($token->abilities as $ability) -
{{ $ability }}
- @endforeach +
+
+

Issued Tokens

+ @if ($tokens->count() > 1) + + @endif +
+
+ @forelse ($tokens as $token) +
+
+
+ {{ $token->name }} + @if ($token->abilities) + @foreach ($token->abilities as $ability) + + {{ $ability }} + + @endforeach + @endif +
+
+ Last used: {{ $token->last_used_at ? $token->last_used_at->diffForHumans() : 'Never' }} +
+
+ @if (auth()->id() === $token->tokenable_id) + @endif
- - @if (auth()->id() === $token->tokenable_id) - - @endif -
- @empty -
-
No API tokens found.
-
- @endforelse + @empty +
No API tokens found.
+ @endforelse +
@endif
diff --git a/tests/Feature/Authorization/EnvironmentVariableValueHidingTest.php b/tests/Feature/Authorization/EnvironmentVariableValueHidingTest.php new file mode 100644 index 000000000..e30e2ad62 --- /dev/null +++ b/tests/Feature/Authorization/EnvironmentVariableValueHidingTest.php @@ -0,0 +1,274 @@ + 0], ['is_api_enabled' => true]); + + $this->team = Team::factory()->create(); + + $this->admin = User::factory()->create(); + $this->admin->teams()->attach($this->team, ['role' => 'admin']); + + $this->member = User::factory()->create(); + $this->member->teams()->attach($this->team, ['role' => 'member']); + + $keyId = DB::table('private_keys')->insertGetId([ + 'uuid' => (string) Str::uuid(), + 'name' => 'Test Key', + 'private_key' => 'test-key', + 'team_id' => $this->team->id, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $keyId, + ]); + + StandaloneDocker::withoutEvents(function () { + $this->destination = StandaloneDocker::firstOrCreate( + ['server_id' => $this->server->id, 'network' => 'coolify'], + ['uuid' => (string) Str::uuid(), 'name' => 'test-docker'] + ); + }); + + $this->project = Project::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'Test Project', + 'team_id' => $this->team->id, + ]); + + $this->environment = $this->project->environments()->first(); + + $this->application = Application::factory()->create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'Test App', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $this->unlockedEnv = EnvironmentVariable::create([ + 'key' => 'UNLOCKED_VAR', + 'value' => 'secret-unlocked-value', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + 'is_preview' => false, + 'is_shown_once' => false, + 'is_multiline' => false, + 'is_literal' => false, + 'is_runtime' => true, + 'is_buildtime' => true, + ]); + + $this->lockedEnv = EnvironmentVariable::create([ + 'key' => 'LOCKED_VAR', + 'value' => 'secret-locked-value', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + 'is_preview' => false, + 'is_shown_once' => true, + 'is_multiline' => false, + 'is_literal' => false, + 'is_runtime' => true, + 'is_buildtime' => true, + ]); +}); + +// --- Livewire Show component: locked env values --- + +test('admin sees unlocked env value in Show component', function () { + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + $component = Livewire::test(EnvironmentVariableShow::class, [ + 'env' => $this->unlockedEnv, + 'type' => 'application', + ]); + + expect($component->get('value'))->toBe('secret-unlocked-value'); +}); + +test('admin cannot see locked env value in Show component', function () { + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + $component = Livewire::test(EnvironmentVariableShow::class, [ + 'env' => $this->lockedEnv, + 'type' => 'application', + ]); + + expect($component->get('value'))->toBeNull(); + expect($component->get('real_value'))->toBeNull(); +}); + +test('member cannot see any env value in Show component', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + $component = Livewire::test(EnvironmentVariableShow::class, [ + 'env' => $this->unlockedEnv, + 'type' => 'application', + ]); + + expect($component->get('value'))->toBeNull(); + expect($component->get('real_value'))->toBeNull(); +}); + +test('member has isValueHidden flag set to true', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + $component = Livewire::test(EnvironmentVariableShow::class, [ + 'env' => $this->unlockedEnv, + 'type' => 'application', + ]); + + expect($component->get('isValueHidden'))->toBeTrue(); +}); + +test('admin has isValueHidden flag set to false', function () { + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + $component = Livewire::test(EnvironmentVariableShow::class, [ + 'env' => $this->unlockedEnv, + 'type' => 'application', + ]); + + expect($component->get('isValueHidden'))->toBeFalse(); +}); + +// --- Livewire All component: dev view --- + +test('admin dev view shows unlocked env value', function () { + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + $component = Livewire::test(EnvironmentVariableAll::class, [ + 'resource' => $this->application, + ]); + + expect($component->get('variables'))->toContain('UNLOCKED_VAR=secret-unlocked-value'); +}); + +test('admin dev view hides locked env value', function () { + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + $component = Livewire::test(EnvironmentVariableAll::class, [ + 'resource' => $this->application, + ]); + + expect($component->get('variables'))->toContain('LOCKED_VAR=(Locked Secret, delete and add again to change)'); + expect($component->get('variables'))->not->toContain('secret-locked-value'); +}); + +test('member dev view hides all env values', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + $component = Livewire::test(EnvironmentVariableAll::class, [ + 'resource' => $this->application, + ]); + + expect($component->get('variables'))->not->toContain('secret-unlocked-value'); + expect($component->get('variables'))->not->toContain('secret-locked-value'); + expect($component->get('variables'))->toContain('UNLOCKED_VAR=(Hidden'); +}); + +// --- API: locked env values hidden --- + +test('API hides locked env value even with read:sensitive token', function () { + session(['currentTeam' => $this->team]); + $token = $this->admin->createToken('admin-sensitive', ['read', 'read:sensitive']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->getJson("/api/v1/applications/{$this->application->uuid}/envs"); + + $response->assertOk(); + + $envs = collect($response->json()); + $locked = $envs->firstWhere('key', 'LOCKED_VAR'); + $unlocked = $envs->firstWhere('key', 'UNLOCKED_VAR'); + + expect($locked)->not->toBeNull(); + expect($locked)->not->toHaveKey('value'); + expect($locked)->not->toHaveKey('real_value'); + + expect($unlocked)->not->toBeNull(); + expect($unlocked)->toHaveKey('value'); +}); + +test('API hides locked env value with root token', function () { + session(['currentTeam' => $this->team]); + $token = $this->admin->createToken('admin-root', ['root']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->getJson("/api/v1/applications/{$this->application->uuid}/envs"); + + $response->assertOk(); + + $envs = collect($response->json()); + $locked = $envs->firstWhere('key', 'LOCKED_VAR'); + + expect($locked)->not->toBeNull(); + expect($locked)->not->toHaveKey('value'); + expect($locked)->not->toHaveKey('real_value'); +}); + +// --- API: member role hides env values --- + +test('API hides env values for member even with read:sensitive token', function () { + session(['currentTeam' => $this->team]); + $token = $this->member->createToken('member-sensitive', ['read', 'read:sensitive']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->getJson("/api/v1/applications/{$this->application->uuid}/envs"); + + $response->assertOk(); + + $envs = collect($response->json()); + $unlocked = $envs->firstWhere('key', 'UNLOCKED_VAR'); + + expect($unlocked)->not->toBeNull(); + expect($unlocked)->not->toHaveKey('value'); + expect($unlocked)->not->toHaveKey('real_value'); +}); + +test('API shows env values for admin with read:sensitive token', function () { + session(['currentTeam' => $this->team]); + $token = $this->admin->createToken('admin-sensitive-2', ['read', 'read:sensitive']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->getJson("/api/v1/applications/{$this->application->uuid}/envs"); + + $response->assertOk(); + + $envs = collect($response->json()); + $unlocked = $envs->firstWhere('key', 'UNLOCKED_VAR'); + + expect($unlocked)->not->toBeNull(); + expect($unlocked)->toHaveKey('value'); +}); diff --git a/tests/Feature/Authorization/LegacyMemberTokenTest.php b/tests/Feature/Authorization/LegacyMemberTokenTest.php new file mode 100644 index 000000000..273327985 --- /dev/null +++ b/tests/Feature/Authorization/LegacyMemberTokenTest.php @@ -0,0 +1,127 @@ + 0, 'is_api_enabled' => true]); + + $this->team = Team::factory()->create(); + $this->member = User::factory()->create(); + $this->admin = User::factory()->create(); + + $this->team->members()->attach($this->member->id, ['role' => 'member']); + $this->team->members()->attach($this->admin->id, ['role' => 'admin']); + + session(['currentTeam' => $this->team]); +}); + +function apiRequest($test, string $token, string $method = 'get', string $url = '/api/v1/version') +{ + return $test->withHeaders([ + 'Authorization' => 'Bearer '.$token, + 'Content-Type' => 'application/json', + ])->{$method.'Json'}($url); +} + +describe('member with legacy elevated token is rejected', function () { + test('member with legacy write token gets 403 with descriptive message', function () { + $token = $this->member->createToken('legacy-write', ['read', 'write']); + + $response = apiRequest($this, $token->plainTextToken); + + $response->assertStatus(403); + $response->assertJsonFragment([ + 'message' => 'This API token has permissions (write) 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.', + ]); + }); + + test('member with legacy deploy token gets 403', function () { + $token = $this->member->createToken('legacy-deploy', ['read', 'deploy']); + + $response = apiRequest($this, $token->plainTextToken); + + $response->assertStatus(403); + $response->assertSee('deploy'); + $response->assertSee('revoke this token'); + }); + + test('member with legacy root token gets 403', function () { + $token = $this->member->createToken('legacy-root', ['root']); + + $response = apiRequest($this, $token->plainTextToken); + + $response->assertStatus(403); + $response->assertSee('root'); + }); + + test('member with legacy read:sensitive token gets 403', function () { + $token = $this->member->createToken('legacy-sensitive', ['read', 'read:sensitive']); + + $response = apiRequest($this, $token->plainTextToken); + + $response->assertStatus(403); + $response->assertSee('read:sensitive'); + }); + + test('member with legacy write:sensitive token gets 403', function () { + $token = $this->member->createToken('legacy-ws', ['read', 'write:sensitive']); + + $response = apiRequest($this, $token->plainTextToken); + + $response->assertStatus(403); + $response->assertSee('write:sensitive'); + }); + + test('member with multiple disallowed abilities lists them all', function () { + $token = $this->member->createToken('legacy-multi', ['read', 'write', 'deploy', 'read:sensitive']); + + $response = apiRequest($this, $token->plainTextToken); + + $response->assertStatus(403); + $json = $response->json(); + expect($json['message'])->toContain('write'); + expect($json['message'])->toContain('deploy'); + expect($json['message'])->toContain('read:sensitive'); + }); +}); + +describe('member with read-only token passes through', function () { + test('member with read token can access read endpoints', function () { + $token = $this->member->createToken('read-only', ['read']); + + $response = apiRequest($this, $token->plainTextToken); + + $response->assertStatus(200); + }); +}); + +describe('admin with elevated token passes through', function () { + test('admin with write token is not blocked', function () { + $token = $this->admin->createToken('admin-write', ['read', 'write']); + + $response = apiRequest($this, $token->plainTextToken); + + $response->assertStatus(200); + }); + + test('admin with root token is not blocked', function () { + $token = $this->admin->createToken('admin-root', ['root']); + + $response = apiRequest($this, $token->plainTextToken); + + $response->assertStatus(200); + }); + + test('admin with deploy token is not blocked', function () { + $token = $this->admin->createToken('admin-deploy', ['read', 'deploy']); + + $response = apiRequest($this, $token->plainTextToken); + + $response->assertStatus(200); + }); +}); diff --git a/tests/Unit/Policies/ApiTokenPolicyTest.php b/tests/Unit/Policies/ApiTokenPolicyTest.php index 98b59319a..98c60aae8 100644 --- a/tests/Unit/Policies/ApiTokenPolicyTest.php +++ b/tests/Unit/Policies/ApiTokenPolicyTest.php @@ -165,3 +165,29 @@ $policy = new ApiTokenPolicy; expect($policy->useDeployPermissions($user))->toBeFalse(); }); + +it('allows admin to use sensitive permissions', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('isAdmin')->andReturn(true); + + $policy = new ApiTokenPolicy; + expect($policy->useSensitivePermissions($user))->toBeTrue(); +}); + +it('allows owner to use sensitive permissions', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('isAdmin')->andReturn(false); + $user->shouldReceive('isOwner')->andReturn(true); + + $policy = new ApiTokenPolicy; + expect($policy->useSensitivePermissions($user))->toBeTrue(); +}); + +it('denies member from using sensitive permissions', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('isAdmin')->andReturn(false); + $user->shouldReceive('isOwner')->andReturn(false); + + $policy = new ApiTokenPolicy; + expect($policy->useSensitivePermissions($user))->toBeFalse(); +}); diff --git a/tests/Unit/Policies/S3StoragePolicyTest.php b/tests/Unit/Policies/S3StoragePolicyTest.php index 4ea580d0f..70ffdf718 100644 --- a/tests/Unit/Policies/S3StoragePolicyTest.php +++ b/tests/Unit/Policies/S3StoragePolicyTest.php @@ -52,7 +52,23 @@ expect($policy->update($user, $storage))->toBeTrue(); }); -it('denies team member to update S3 storage from another team', function () { +it('denies team member to update S3 storage from their team', function () { + $teams = collect([ + (object) ['id' => 1, 'pivot' => (object) ['role' => 'member']], + ]); + + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); + + $storage = Mockery::mock(S3Storage::class)->makePartial(); + $storage->shouldReceive('getAttribute')->with('team_id')->andReturn(1); + $storage->team_id = 1; + + $policy = new S3StoragePolicy; + expect($policy->update($user, $storage))->toBeFalse(); +}); + +it('denies team admin to update S3 storage from another team', function () { $teams = collect([ (object) ['id' => 1, 'pivot' => (object) ['role' => 'admin']], ]); @@ -68,9 +84,9 @@ expect($policy->update($user, $storage))->toBeFalse(); }); -it('allows team member to delete S3 storage from their team', function () { +it('allows team admin to delete S3 storage from their team', function () { $teams = collect([ - (object) ['id' => 1, 'pivot' => (object) ['role' => 'member']], + (object) ['id' => 1, 'pivot' => (object) ['role' => 'admin']], ]); $user = Mockery::mock(User::class)->makePartial(); @@ -84,7 +100,23 @@ expect($policy->delete($user, $storage))->toBeTrue(); }); -it('denies team member to delete S3 storage from another team', function () { +it('denies team member to delete S3 storage from their team', function () { + $teams = collect([ + (object) ['id' => 1, 'pivot' => (object) ['role' => 'member']], + ]); + + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); + + $storage = Mockery::mock(S3Storage::class)->makePartial(); + $storage->shouldReceive('getAttribute')->with('team_id')->andReturn(1); + $storage->team_id = 1; + + $policy = new S3StoragePolicy; + expect($policy->delete($user, $storage))->toBeFalse(); +}); + +it('denies team admin to delete S3 storage from another team', function () { $teams = collect([ (object) ['id' => 1, 'pivot' => (object) ['role' => 'owner']], ]); @@ -116,9 +148,9 @@ expect($policy->create($user))->toBeFalse(); }); -it('allows team member to validate connection of S3 storage from their team', function () { +it('allows team admin to validate connection of S3 storage from their team', function () { $teams = collect([ - (object) ['id' => 1, 'pivot' => (object) ['role' => 'member']], + (object) ['id' => 1, 'pivot' => (object) ['role' => 'admin']], ]); $user = Mockery::mock(User::class)->makePartial(); @@ -132,7 +164,23 @@ expect($policy->validateConnection($user, $storage))->toBeTrue(); }); -it('denies team member to validate connection of S3 storage from another team', function () { +it('denies team member to validate connection of S3 storage from their team', function () { + $teams = collect([ + (object) ['id' => 1, 'pivot' => (object) ['role' => 'member']], + ]); + + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); + + $storage = Mockery::mock(S3Storage::class)->makePartial(); + $storage->shouldReceive('getAttribute')->with('team_id')->andReturn(1); + $storage->team_id = 1; + + $policy = new S3StoragePolicy; + expect($policy->validateConnection($user, $storage))->toBeFalse(); +}); + +it('denies team admin to validate connection of S3 storage from another team', function () { $teams = collect([ (object) ['id' => 1, 'pivot' => (object) ['role' => 'admin']], ]);