refactor(auth): enforce team member authorization across app

Restrict sensitive operations to admins/owners and hide sensitive data
from team members:
- Add authorization checks to Livewire components and API endpoints
- Restrict team members from accessing sensitive permissions and data
- Hide environment variable values from non-admin team members
- Update policies to enforce team-level admin status requirement
- Add useSensitivePermissions policy for read:sensitive tokens
- Improve disabled button UX with auth-specific tooltips
- Add authorization checks in middleware for API tokens

Closes authorization gaps in project management, server management,
and settings components.
This commit is contained in:
Andras Bacsai 2026-02-27 11:41:01 +01:00
parent e82942b387
commit b878dc8102
49 changed files with 919 additions and 136 deletions

View file

@ -54,6 +54,10 @@ private function removeSensitiveData($application)
]); ]);
} }
if ($application->is_shown_once ?? false) {
$application->makeHidden(['value', 'real_value']);
}
return serializeApiResponse($application); return serializeApiResponse($application);
} }

View file

@ -35,6 +35,10 @@ private function removeSensitiveData($service)
]); ]);
} }
if ($service->is_shown_once ?? false) {
$service->makeHidden(['value', 'real_value']);
}
return serializeApiResponse($service); return serializeApiResponse($service);
} }

View file

@ -6,9 +6,34 @@
class ApiAbility extends CheckForAnyAbility 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) public function handle($request, $next, ...$abilities)
{ {
try { 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')) { if ($request->user()->tokenCan('root')) {
return $next($request); return $next($request);
} }

View file

@ -10,10 +10,13 @@ class ApiSensitiveData
public function handle(Request $request, Closure $next) public function handle(Request $request, Closure $next)
{ {
$token = $request->user()->currentAccessToken(); $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([ $request->attributes->add([
'can_read_sensitive' => $token->can('root') || $token->can('read:sensitive'), 'can_read_sensitive' => $hasTokenPermission && $isAdmin,
]); ]);
return $next($request); return $next($request);

View file

@ -8,12 +8,15 @@
use App\Models\Server; use App\Models\Server;
use App\Models\Team; use App\Models\Team;
use App\Services\ConfigurationRepository; use App\Services\ConfigurationRepository;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Livewire\Component; use Livewire\Component;
use Visus\Cuid2\Cuid2; use Visus\Cuid2\Cuid2;
class Index extends Component class Index extends Component
{ {
use AuthorizesRequests;
protected $listeners = [ protected $listeners = [
'refreshBoardingIndex' => 'validateServer', 'refreshBoardingIndex' => 'validateServer',
'prerequisitesInstalled' => 'handlePrerequisitesInstalled', 'prerequisitesInstalled' => 'handlePrerequisitesInstalled',
@ -172,6 +175,9 @@ public function restartBoarding()
public function skipBoarding() public function skipBoarding()
{ {
if (auth()->user()?->isMember()) {
return redirect()->route('dashboard');
}
Team::find(currentTeam()->id)->update([ Team::find(currentTeam()->id)->update([
'show_boarding' => false, 'show_boarding' => false,
]); ]);
@ -257,6 +263,7 @@ public function savePrivateKey()
]); ]);
try { try {
$this->authorize('create', PrivateKey::class);
$privateKey = PrivateKey::createAndStore([ $privateKey = PrivateKey::createAndStore([
'name' => $this->privateKeyName, 'name' => $this->privateKeyName,
'description' => $this->privateKeyDescription, 'description' => $this->privateKeyDescription,
@ -280,6 +287,12 @@ public function saveServer()
'remoteServerUser' => 'required|string', 'remoteServerUser' => 'required|string',
]); ]);
try {
$this->authorize('create', Server::class);
} catch (\Throwable $e) {
return handleError($e, $this);
}
$this->privateKey = formatPrivateKey($this->privateKey); $this->privateKey = formatPrivateKey($this->privateKey);
$foundServer = Server::whereIp($this->remoteServerHost)->first(); $foundServer = Server::whereIp($this->remoteServerHost)->first();
if ($foundServer) { if ($foundServer) {

View file

@ -4,11 +4,14 @@
use App\Models\Project; use App\Models\Project;
use App\Support\ValidationPatterns; use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component; use Livewire\Component;
use Visus\Cuid2\Cuid2; use Visus\Cuid2\Cuid2;
class AddEmpty extends Component class AddEmpty extends Component
{ {
use AuthorizesRequests;
public string $name; public string $name;
public string $description = ''; public string $description = '';
@ -29,6 +32,7 @@ protected function messages(): array
public function submit() public function submit()
{ {
try { try {
$this->authorize('create', Project::class);
$this->validate(); $this->validate();
$project = Project::create([ $project = Project::create([
'name' => $this->name, 'name' => $this->name,

View file

@ -123,6 +123,8 @@ public function copyLogs(): string
public function downloadAllLogs(): string public function downloadAllLogs(): string
{ {
$this->authorize('update', $this->application);
$logs = decode_remote_command_output($this->application_deployment_queue, includeAll: true) $logs = decode_remote_command_output($this->application_deployment_queue, includeAll: true)
->map(function ($line) { ->map(function ($line) {
$prefix = ''; $prefix = '';

View file

@ -6,11 +6,14 @@
use App\Models\Application; use App\Models\Application;
use App\Models\ApplicationDeploymentQueue; use App\Models\ApplicationDeploymentQueue;
use App\Models\Server; use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Livewire\Component; use Livewire\Component;
class DeploymentNavbar extends Component class DeploymentNavbar extends Component
{ {
use AuthorizesRequests;
public ApplicationDeploymentQueue $application_deployment_queue; public ApplicationDeploymentQueue $application_deployment_queue;
public Application $application; public Application $application;
@ -35,10 +38,15 @@ public function deploymentFinished()
public function show_debug() public function show_debug()
{ {
$this->application->settings->is_debug_enabled = ! $this->application->settings->is_debug_enabled; try {
$this->application->settings->save(); $this->authorize('update', $this->application);
$this->is_debug_enabled = $this->application->settings->is_debug_enabled; $this->application->settings->is_debug_enabled = ! $this->application->settings->is_debug_enabled;
$this->dispatch('refreshQueue'); $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() public function force_start()

View file

@ -11,11 +11,14 @@
use App\Models\Project; use App\Models\Project;
use App\Models\Server; use App\Models\Server;
use App\Support\ValidationPatterns; use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component; use Livewire\Component;
use Visus\Cuid2\Cuid2; use Visus\Cuid2\Cuid2;
class CloneMe extends Component class CloneMe extends Component
{ {
use AuthorizesRequests;
public string $project_uuid; public string $project_uuid;
public string $environment_uuid; public string $environment_uuid;
@ -91,6 +94,7 @@ public function selectServer($server_id, $destination_id)
public function clone(string $type) public function clone(string $type)
{ {
try { try {
$this->authorize('create', Project::class);
$this->validate([ $this->validate([
'selectedDestination' => 'required', 'selectedDestination' => 'required',
'newName' => ValidationPatterns::nameRules(), 'newName' => ValidationPatterns::nameRules(),

View file

@ -90,6 +90,7 @@ public function restart()
$this->authorize('manage', $this->database); $this->authorize('manage', $this->database);
$activity = RestartDatabase::run($this->database); $activity = RestartDatabase::run($this->database);
$this->js("window.dispatchEvent(new CustomEvent('startdatabase'))");
$this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class); $this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class);
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
@ -102,6 +103,7 @@ public function start()
$this->authorize('manage', $this->database); $this->authorize('manage', $this->database);
$activity = StartDatabase::run($this->database); $activity = StartDatabase::run($this->database);
$this->js("window.dispatchEvent(new CustomEvent('startdatabase'))");
$this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class); $this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class);
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);

View file

@ -4,10 +4,13 @@
use App\Models\Project; use App\Models\Project;
use App\Support\ValidationPatterns; use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component; use Livewire\Component;
class Edit extends Component class Edit extends Component
{ {
use AuthorizesRequests;
public Project $project; public Project $project;
public string $name; public string $name;
@ -54,6 +57,7 @@ public function syncData(bool $toModel = false)
public function submit() public function submit()
{ {
try { try {
$this->authorize('update', $this->project);
$this->syncData(true); $this->syncData(true);
$this->dispatch('success', 'Project updated.'); $this->dispatch('success', 'Project updated.');
} catch (\Throwable $e) { } catch (\Throwable $e) {

View file

@ -5,11 +5,14 @@
use App\Models\Application; use App\Models\Application;
use App\Models\Project; use App\Models\Project;
use App\Support\ValidationPatterns; use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked; use Livewire\Attributes\Locked;
use Livewire\Component; use Livewire\Component;
class EnvironmentEdit extends Component class EnvironmentEdit extends Component
{ {
use AuthorizesRequests;
public Project $project; public Project $project;
public Application $application; public Application $application;
@ -62,6 +65,7 @@ public function syncData(bool $toModel = false)
public function submit() public function submit()
{ {
try { try {
$this->authorize('update', $this->environment);
$this->syncData(true); $this->syncData(true);
redirectRoute($this, 'project.environment.edit', [ redirectRoute($this, 'project.environment.edit', [
'environment_uuid' => $this->environment->uuid, 'environment_uuid' => $this->environment->uuid,

View file

@ -105,6 +105,7 @@ public function start()
try { try {
$this->authorize('deploy', $this->service); $this->authorize('deploy', $this->service);
$activity = StartService::run($this->service, pullLatestImages: true); $activity = StartService::run($this->service, pullLatestImages: true);
$this->js("window.dispatchEvent(new CustomEvent('startservice'))");
$this->dispatch('activityMonitor', $activity->id); $this->dispatch('activityMonitor', $activity->id);
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
@ -125,6 +126,7 @@ public function forceDeploy()
$activity->save(); $activity->save();
} }
$activity = StartService::run($this->service, pullLatestImages: true, stopBeforeStart: true); $activity = StartService::run($this->service, pullLatestImages: true, stopBeforeStart: true);
$this->js("window.dispatchEvent(new CustomEvent('startservice'))");
$this->dispatch('activityMonitor', $activity->id); $this->dispatch('activityMonitor', $activity->id);
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
@ -152,6 +154,7 @@ public function restart()
return; return;
} }
$activity = StartService::run($this->service, stopBeforeStart: true); $activity = StartService::run($this->service, stopBeforeStart: true);
$this->js("window.dispatchEvent(new CustomEvent('startservice'))");
$this->dispatch('activityMonitor', $activity->id); $this->dispatch('activityMonitor', $activity->id);
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
@ -169,6 +172,7 @@ public function pullAndRestartEvent()
return; return;
} }
$activity = StartService::run($this->service, pullLatestImages: true, stopBeforeStart: true); $activity = StartService::run($this->service, pullLatestImages: true, stopBeforeStart: true);
$this->js("window.dispatchEvent(new CustomEvent('startservice'))");
$this->dispatch('activityMonitor', $activity->id); $this->dispatch('activityMonitor', $activity->id);
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);

View file

@ -72,7 +72,7 @@ public function getEnvironmentVariablesProperty()
$query->orderBy('order'); $query->orderBy('order');
} }
return $query->get(); return $this->nullLockedValues($query->get());
} }
public function getEnvironmentVariablesPreviewProperty() public function getEnvironmentVariablesPreviewProperty()
@ -86,7 +86,21 @@ public function getEnvironmentVariablesPreviewProperty()
$query->orderBy('order'); $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() public function getDevView()
@ -99,7 +113,12 @@ public function getDevView()
private function formatEnvironmentVariables($variables) 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) { if ($item->is_shown_once) {
return "$item->key=(Locked Secret, delete and add again to change)"; return "$item->key=(Locked Secret, delete and add again to change)";
} }

View file

@ -52,6 +52,8 @@ class Show extends Component
public bool $is_redis_credential = false; public bool $is_redis_credential = false;
public bool $isValueHidden = false;
public array $problematicVariables = []; public array $problematicVariables = [];
protected $listeners = [ protected $listeners = [
@ -134,6 +136,13 @@ public function syncData(bool $toModel = false)
$this->is_really_required = $this->env->is_really_required ?? false; $this->is_really_required = $this->env->is_really_required ?? false;
$this->is_shared = $this->env->is_shared ?? false; $this->is_shared = $this->env->is_shared ?? false;
$this->real_value = $this->env->real_value; $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;
} }
} }

View file

@ -4,6 +4,7 @@
use App\Models\InstanceSettings; use App\Models\InstanceSettings;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Str;
use Laravel\Sanctum\PersonalAccessToken; use Laravel\Sanctum\PersonalAccessToken;
use Livewire\Component; use Livewire\Component;
@ -25,6 +26,8 @@ class ApiTokens extends Component
public bool $canUseDeployPermissions = false; public bool $canUseDeployPermissions = false;
public bool $canUseSensitivePermissions = false;
public function render() public function render()
{ {
return view('livewire.security.api-tokens'); return view('livewire.security.api-tokens');
@ -36,6 +39,7 @@ public function mount()
$this->canUseRootPermissions = auth()->user()->can('useRootPermissions', PersonalAccessToken::class); $this->canUseRootPermissions = auth()->user()->can('useRootPermissions', PersonalAccessToken::class);
$this->canUseWritePermissions = auth()->user()->can('useWritePermissions', PersonalAccessToken::class); $this->canUseWritePermissions = auth()->user()->can('useWritePermissions', PersonalAccessToken::class);
$this->canUseDeployPermissions = auth()->user()->can('useDeployPermissions', PersonalAccessToken::class); $this->canUseDeployPermissions = auth()->user()->can('useDeployPermissions', PersonalAccessToken::class);
$this->canUseSensitivePermissions = auth()->user()->can('useSensitivePermissions', PersonalAccessToken::class);
$this->getTokens(); $this->getTokens();
} }
@ -70,6 +74,13 @@ public function updatedPermissions($permissionToUpdate)
return; 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') { if ($permissionToUpdate == 'root') {
$this->permissions = ['root']; $this->permissions = ['root'];
} elseif ($permissionToUpdate == 'read:sensitive' && ! in_array('read', $this->permissions)) { } 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.'); 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([ $this->validate([
'description' => 'required|min:3|max:255', 'description' => 'required|min:3|max:255',
]); ]);
$token = auth()->user()->createToken($this->description, array_values($this->permissions)); $token = auth()->user()->createToken($this->description, array_values($this->permissions));
$this->getTokens(); $this->getTokens();
session()->flash('token', $token->plainTextToken); session()->flash('token', Str::after($token->plainTextToken, '|'));
} catch (\Exception $e) { } catch (\Exception $e) {
return handleError($e, $this); return handleError($e, $this);
} }

View file

@ -72,12 +72,16 @@ public function toggleCloudflareTunnels()
public function manualCloudflareConfig() public function manualCloudflareConfig()
{ {
$this->authorize('update', $this->server); try {
$this->isCloudflareTunnelsEnabled = true; $this->authorize('update', $this->server);
$this->server->settings->is_cloudflare_tunnel = true; $this->isCloudflareTunnelsEnabled = true;
$this->server->settings->save(); $this->server->settings->is_cloudflare_tunnel = true;
$this->server->refresh(); $this->server->settings->save();
$this->dispatch('success', 'Cloudflare Tunnel enabled.'); $this->server->refresh();
$this->dispatch('success', 'Cloudflare Tunnel enabled.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
} }
public function automatedCloudflareConfig() public function automatedCloudflareConfig()

View file

@ -69,6 +69,11 @@ public function add($name)
public function scan() public function scan()
{ {
try {
$this->authorize('update', $this->server);
} catch (\Throwable $e) {
return handleError($e, $this);
}
if ($this->server->isSwarm()) { if ($this->server->isSwarm()) {
$alreadyAddedNetworks = $this->server->swarmDockers; $alreadyAddedNetworks = $this->server->swarmDockers;
} else { } else {

View file

@ -3,11 +3,14 @@
namespace App\Livewire\Server\Proxy; namespace App\Livewire\Server\Proxy;
use App\Models\Server; use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Livewire\Component; use Livewire\Component;
class DynamicConfigurations extends Component class DynamicConfigurations extends Component
{ {
use AuthorizesRequests;
public ?Server $server = null; public ?Server $server = null;
public $parameters = []; public $parameters = [];
@ -35,6 +38,11 @@ public function initLoadDynamicConfigurations()
public function loadDynamicConfigurations() public function loadDynamicConfigurations()
{ {
try {
$this->authorize('view', $this->server);
} catch (\Throwable $e) {
return handleError($e, $this);
}
$proxy_path = $this->server->proxyPath(); $proxy_path = $this->server->proxyPath();
$files = instant_remote_process(["mkdir -p $proxy_path/dynamic && ls -1 {$proxy_path}/dynamic"], $this->server); $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)); $files = collect(explode("\n", $files))->filter(fn ($file) => ! empty($file));

View file

@ -404,6 +404,7 @@ public function instantSave()
public function checkHetznerServerStatus(bool $manual = false) public function checkHetznerServerStatus(bool $manual = false)
{ {
try { try {
$this->authorize('view', $this->server);
if (! $this->server->hetzner_server_id || ! $this->server->cloudProviderToken) { if (! $this->server->hetzner_server_id || ! $this->server->cloudProviderToken) {
$this->dispatch('error', 'This server is not associated with a Hetzner Cloud server or token.'); $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() public function startHetznerServer()
{ {
try { try {
$this->authorize('update', $this->server);
if (! $this->server->hetzner_server_id || ! $this->server->cloudProviderToken) { if (! $this->server->hetzner_server_id || ! $this->server->cloudProviderToken) {
$this->dispatch('error', 'This server is not associated with a Hetzner Cloud server or token.'); $this->dispatch('error', 'This server is not associated with a Hetzner Cloud server or token.');

View file

@ -4,11 +4,14 @@
use App\Models\InstanceSettings; use App\Models\InstanceSettings;
use App\Rules\ValidIpOrCidr; use App\Rules\ValidIpOrCidr;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Validate; use Livewire\Attributes\Validate;
use Livewire\Component; use Livewire\Component;
class Advanced extends Component class Advanced extends Component
{ {
use AuthorizesRequests;
public InstanceSettings $settings; public InstanceSettings $settings;
#[Validate('boolean')] #[Validate('boolean')]
@ -72,6 +75,7 @@ public function mount()
public function submit() public function submit()
{ {
try { try {
$this->authorize('update', $this->settings);
$this->validate(); $this->validate();
$this->custom_dns_servers = str($this->custom_dns_servers)->replaceEnd(',', '')->trim(); $this->custom_dns_servers = str($this->custom_dns_servers)->replaceEnd(',', '')->trim();
@ -137,6 +141,7 @@ public function submit()
public function instantSave() public function instantSave()
{ {
try { try {
$this->authorize('update', $this->settings);
$this->settings->is_registration_enabled = $this->is_registration_enabled; $this->settings->is_registration_enabled = $this->is_registration_enabled;
$this->settings->do_not_track = $this->do_not_track; $this->settings->do_not_track = $this->do_not_track;
$this->settings->is_dns_validation_enabled = $this->is_dns_validation_enabled; $this->settings->is_dns_validation_enabled = $this->is_dns_validation_enabled;
@ -155,6 +160,7 @@ public function instantSave()
public function toggleTwoStepConfirmation($password): bool public function toggleTwoStepConfirmation($password): bool
{ {
$this->authorize('update', $this->settings);
if (! verifyPasswordConfirmation($password, $this)) { if (! verifyPasswordConfirmation($password, $this)) {
return false; return false;
} }

View file

@ -4,12 +4,15 @@
use App\Models\InstanceSettings; use App\Models\InstanceSettings;
use App\Models\Server; use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Computed; use Livewire\Attributes\Computed;
use Livewire\Attributes\Validate; use Livewire\Attributes\Validate;
use Livewire\Component; use Livewire\Component;
class Index extends Component class Index extends Component
{ {
use AuthorizesRequests;
public InstanceSettings $settings; public InstanceSettings $settings;
public ?Server $server = null; public ?Server $server = null;
@ -86,6 +89,7 @@ public function timezones(): array
public function instantSave($isSave = true) public function instantSave($isSave = true)
{ {
$this->authorize('update', $this->settings);
$this->validate(); $this->validate();
$this->settings->fqdn = $this->fqdn ? trim($this->fqdn) : $this->fqdn; $this->settings->fqdn = $this->fqdn ? trim($this->fqdn) : $this->fqdn;
$this->settings->public_port_min = $this->public_port_min; $this->settings->public_port_min = $this->public_port_min;
@ -103,6 +107,7 @@ public function instantSave($isSave = true)
public function confirmDomainUsage() public function confirmDomainUsage()
{ {
$this->authorize('update', $this->settings);
$this->forceSaveDomains = true; $this->forceSaveDomains = true;
$this->showDomainConflictModal = false; $this->showDomainConflictModal = false;
$this->submit(); $this->submit();
@ -111,6 +116,7 @@ public function confirmDomainUsage()
public function submit() public function submit()
{ {
try { try {
$this->authorize('update', $this->settings);
$error_show = false; $error_show = false;
$this->resetErrorBag(); $this->resetErrorBag();
@ -172,6 +178,7 @@ public function submit()
public function buildHelperImage() public function buildHelperImage()
{ {
try { try {
$this->authorize('update', $this->settings);
if (! isDev()) { if (! isDev()) {
$this->dispatch('error', 'Building helper image is only available in development mode.'); $this->dispatch('error', 'Building helper image is only available in development mode.');

View file

@ -5,11 +5,14 @@
use App\Jobs\CheckForUpdatesJob; use App\Jobs\CheckForUpdatesJob;
use App\Models\InstanceSettings; use App\Models\InstanceSettings;
use App\Models\Server; use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Validate; use Livewire\Attributes\Validate;
use Livewire\Component; use Livewire\Component;
class Updates extends Component class Updates extends Component
{ {
use AuthorizesRequests;
public InstanceSettings $settings; public InstanceSettings $settings;
public ?Server $server = null; public ?Server $server = null;
@ -25,6 +28,9 @@ class Updates extends Component
public function mount() public function mount()
{ {
if (! isInstanceAdmin()) {
return redirect()->route('dashboard');
}
if (! isCloud()) { if (! isCloud()) {
$this->server = Server::findOrFail(0); $this->server = Server::findOrFail(0);
} }
@ -38,6 +44,7 @@ public function mount()
public function instantSave() public function instantSave()
{ {
try { try {
$this->authorize('update', $this->settings);
if ($this->settings->is_auto_update_enabled === true) { if ($this->settings->is_auto_update_enabled === true) {
$this->validate([ $this->validate([
'auto_update_frequency' => ['required', 'string'], 'auto_update_frequency' => ['required', 'string'],
@ -56,6 +63,7 @@ public function instantSave()
public function submit() public function submit()
{ {
try { try {
$this->authorize('update', $this->settings);
$this->resetErrorBag(); $this->resetErrorBag();
$this->validate(); $this->validate();
@ -88,6 +96,7 @@ public function submit()
public function checkManually() public function checkManually()
{ {
$this->authorize('update', $this->settings);
CheckForUpdatesJob::dispatchSync(); CheckForUpdatesJob::dispatchSync();
$this->dispatch('updateAvailable'); $this->dispatch('updateAvailable');
$settings = instanceSettings(); $settings = instanceSettings();

View file

@ -7,12 +7,15 @@
use App\Models\ScheduledDatabaseBackup; use App\Models\ScheduledDatabaseBackup;
use App\Models\Server; use App\Models\Server;
use App\Models\StandalonePostgresql; use App\Models\StandalonePostgresql;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked; use Livewire\Attributes\Locked;
use Livewire\Attributes\Validate; use Livewire\Attributes\Validate;
use Livewire\Component; use Livewire\Component;
class SettingsBackup extends Component class SettingsBackup extends Component
{ {
use AuthorizesRequests;
public InstanceSettings $settings; public InstanceSettings $settings;
public Server $server; public Server $server;
@ -76,6 +79,7 @@ public function mount()
public function addCoolifyDatabase() public function addCoolifyDatabase()
{ {
try { try {
$this->authorize('update', $this->settings);
$server = Server::findOrFail(0); $server = Server::findOrFail(0);
$out = instant_remote_process(['docker inspect coolify-db'], $server); $out = instant_remote_process(['docker inspect coolify-db'], $server);
$envs = format_docker_envs_to_json($out); $envs = format_docker_envs_to_json($out);
@ -120,14 +124,19 @@ public function addCoolifyDatabase()
public function submit() public function submit()
{ {
$this->validate(); try {
$this->authorize('update', $this->settings);
$this->validate();
$this->database->update([ $this->database->update([
'name' => $this->name, 'name' => $this->name,
'description' => $this->description, 'description' => $this->description,
'postgres_user' => $this->postgres_user, 'postgres_user' => $this->postgres_user,
'postgres_password' => $this->postgres_password, 'postgres_password' => $this->postgres_password,
]); ]);
$this->dispatch('success', 'Backup updated.'); $this->dispatch('success', 'Backup updated.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
} }
} }

View file

@ -5,6 +5,7 @@
use App\Models\InstanceSettings; use App\Models\InstanceSettings;
use App\Models\Team; use App\Models\Team;
use App\Notifications\TransactionalEmails\Test; use App\Notifications\TransactionalEmails\Test;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\RateLimiter;
use Livewire\Attributes\Locked; use Livewire\Attributes\Locked;
use Livewire\Attributes\Validate; use Livewire\Attributes\Validate;
@ -12,6 +13,8 @@
class SettingsEmail extends Component class SettingsEmail extends Component
{ {
use AuthorizesRequests;
public InstanceSettings $settings; public InstanceSettings $settings;
#[Locked] #[Locked]
@ -103,6 +106,7 @@ public function syncData(bool $toModel = false)
public function submit() public function submit()
{ {
try { try {
$this->authorize('update', $this->settings);
$this->resetErrorBag(); $this->resetErrorBag();
$this->syncData(true); $this->syncData(true);
$this->dispatch('success', 'Transactional email settings updated.'); $this->dispatch('success', 'Transactional email settings updated.');
@ -114,6 +118,7 @@ public function submit()
public function instantSave(string $type) public function instantSave(string $type)
{ {
try { try {
$this->authorize('update', $this->settings);
$currentSmtpEnabled = $this->settings->smtp_enabled; $currentSmtpEnabled = $this->settings->smtp_enabled;
$currentResendEnabled = $this->settings->resend_enabled; $currentResendEnabled = $this->settings->resend_enabled;
$this->resetErrorBag(); $this->resetErrorBag();
@ -141,6 +146,7 @@ public function instantSave(string $type)
public function submitSmtp() public function submitSmtp()
{ {
try { try {
$this->authorize('update', $this->settings);
$this->validate([ $this->validate([
'smtpEnabled' => 'boolean', 'smtpEnabled' => 'boolean',
'smtpFromAddress' => 'required|email', 'smtpFromAddress' => 'required|email',
@ -184,6 +190,7 @@ public function submitSmtp()
public function submitResend() public function submitResend()
{ {
try { try {
$this->authorize('update', $this->settings);
$this->validate([ $this->validate([
'resendEnabled' => 'boolean', 'resendEnabled' => 'boolean',
'resendApiKey' => 'required|string', 'resendApiKey' => 'required|string',
@ -214,6 +221,7 @@ public function submitResend()
public function sendTestEmail() public function sendTestEmail()
{ {
try { try {
$this->authorize('update', $this->settings);
$this->validate([ $this->validate([
'testEmailAddress' => 'required|email', 'testEmailAddress' => 'required|email',
], [ ], [

View file

@ -3,10 +3,13 @@
namespace App\Livewire; namespace App\Livewire;
use App\Models\OauthSetting; use App\Models\OauthSetting;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component; use Livewire\Component;
class SettingsOauth extends Component class SettingsOauth extends Component
{ {
use AuthorizesRequests;
public $oauth_settings_map; public $oauth_settings_map;
protected function rules() protected function rules()
@ -131,6 +134,7 @@ private function updateOauthSettings(?string $provider = null)
public function instantSave(string $provider) public function instantSave(string $provider)
{ {
try { try {
$this->authorize('update', instanceSettings());
$this->updateOauthSettings($provider); $this->updateOauthSettings($provider);
} catch (\Exception $e) { } catch (\Exception $e) {
return handleError($e, $this); return handleError($e, $this);
@ -139,7 +143,12 @@ public function instantSave(string $provider)
public function submit() public function submit()
{ {
$this->updateOauthSettings(); try {
$this->dispatch('success', 'Instance settings updated successfully!'); $this->authorize('update', instanceSettings());
$this->updateOauthSettings();
$this->dispatch('success', 'Instance settings updated successfully!');
} catch (\Throwable $e) {
return handleError($e, $this);
}
} }
} }

View file

@ -72,7 +72,12 @@ public function getDevView()
private function formatEnvironmentVariables($variables) 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) { if ($item->is_shown_once) {
return "$item->key=(Locked Secret, delete and add again to change)"; return "$item->key=(Locked Secret, delete and add again to change)";
} }

View file

@ -73,7 +73,12 @@ public function getDevView()
private function formatEnvironmentVariables($variables) 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) { if ($item->is_shown_once) {
return "$item->key=(Locked Secret, delete and add again to change)"; return "$item->key=(Locked Secret, delete and add again to change)";
} }

View file

@ -67,7 +67,12 @@ public function getDevView()
private function formatEnvironmentVariables($variables) 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) { if ($item->is_shown_once) {
return "$item->key=(Locked Secret, delete and add again to change)"; return "$item->key=(Locked Secret, delete and add again to change)";
} }

View file

@ -5,6 +5,7 @@
use App\Http\Controllers\Api\DeployController; use App\Http\Controllers\Api\DeployController;
use App\Models\ApplicationDeploymentQueue; use App\Models\ApplicationDeploymentQueue;
use App\Models\Tag; use App\Models\Tag;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Livewire\Attributes\Locked; use Livewire\Attributes\Locked;
use Livewire\Attributes\Title; use Livewire\Attributes\Title;
@ -13,6 +14,8 @@
#[Title('Tags | Coolify')] #[Title('Tags | Coolify')]
class Show extends Component class Show extends Component
{ {
use AuthorizesRequests;
#[Locked] #[Locked]
public ?string $tagName = null; public ?string $tagName = null;
@ -73,6 +76,12 @@ public function getDeployments()
public function redeployAll() public function redeployAll()
{ {
try { try {
$this->applications->each(function ($resource) {
$this->authorize('deploy', $resource);
});
$this->services->each(function ($resource) {
$this->authorize('deploy', $resource);
});
$message = collect([]); $message = collect([]);
$this->applications->each(function ($resource) use ($message) { $this->applications->each(function ($resource) use ($message) {
$deploy = new DeployController; $deploy = new DeployController;

View file

@ -25,6 +25,9 @@ public function mount()
public function submitSearch() public function submitSearch()
{ {
if (! isInstanceAdmin()) {
return;
}
if ($this->search !== '') { if ($this->search !== '') {
$this->users = User::where(function ($query) { $this->users = User::where(function ($query) {
$query->where('name', 'like', "%{$this->search}%") $query->where('name', 'like', "%{$this->search}%")
@ -39,6 +42,9 @@ public function submitSearch()
public function getUsers() public function getUsers()
{ {
if (! isInstanceAdmin()) {
return;
}
$users = User::where('id', '!=', auth()->id())->get(); $users = User::where('id', '!=', auth()->id())->get();
if ($users->count() > $this->number_of_users_to_show) { if ($users->count() > $this->number_of_users_to_show) {
$this->lots_of_users = true; $this->lots_of_users = true;

View file

@ -78,4 +78,12 @@ public function useDeployPermissions(User $user): bool
{ {
return $user->isAdmin() || $user->isOwner(); 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();
}
} }

View file

@ -36,8 +36,7 @@ public function create(User $user): bool
*/ */
public function update(User $user, S3Storage $storage): 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) && $user->isAdminOfTeam($storage->team_id);
return $user->teams->contains('id', $storage->team_id);
} }
/** /**
@ -45,8 +44,7 @@ public function update(User $user, S3Storage $storage): bool
*/ */
public function delete(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) && $user->isAdminOfTeam($storage->team_id);
return $user->teams->contains('id', $storage->team_id);
} }
/** /**
@ -70,6 +68,6 @@ public function forceDelete(User $user, S3Storage $storage): bool
*/ */
public function validateConnection(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);
} }
} }

View file

@ -14,6 +14,7 @@ class Button extends Component
*/ */
public function __construct( public function __construct(
public bool $disabled = false, public bool $disabled = false,
public bool $authDisabled = false,
public bool $noStyle = false, public bool $noStyle = false,
public ?string $modalId = null, public ?string $modalId = null,
public string $defaultClass = 'button', public string $defaultClass = 'button',
@ -28,6 +29,7 @@ public function __construct(
if (! $hasPermission) { if (! $hasPermission) {
$this->disabled = true; $this->disabled = true;
$this->authDisabled = true;
} }
} }

View file

@ -122,7 +122,11 @@ @utility select {
} }
@utility button { @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 { @utility alert-success {

View file

@ -1,3 +1,24 @@
@if ($authDisabled)
<span class="relative inline-flex"
x-data="{ visible: false, _t: null }"
@mouseenter="_t = setTimeout(() => {
visible = true;
$nextTick(() => requestAnimationFrame(() => {
const tip = $refs.tip;
if (!tip) return;
const r = $el.getBoundingClientRect();
const t = tip.getBoundingClientRect();
let top = r.top - t.height - 6;
let left = r.left;
if (top < 4) top = r.bottom + 6;
if (left + t.width > innerWidth - 8) left = innerWidth - 8 - t.width;
if (left < 4) left = 4;
tip.style.top = top + 'px';
tip.style.left = left + 'px';
}));
}, 300)"
@mouseleave="clearTimeout(_t); visible = false">
@endif
<button @disabled($disabled) {{ $attributes->merge(['class' => $defaultClass]) }} <button @disabled($disabled) {{ $attributes->merge(['class' => $defaultClass]) }}
{{ $attributes->merge(['type' => 'button']) }} {{ $attributes->merge(['type' => 'button']) }}
@isset($confirm) @isset($confirm)
@ -18,3 +39,9 @@
@endif @endif
@endif @endif
</button> </button>
@if ($authDisabled)
<div x-ref="tip" x-show="visible" x-cloak class="auth-tooltip">
You do not have permission to perform this action.
</div>
</span>
@endif

View file

@ -6,6 +6,7 @@
'buttonFullWidth' => false, 'buttonFullWidth' => false,
'customButton' => null, 'customButton' => null,
'disabled' => false, 'disabled' => false,
'authDisabled' => false,
'dispatchAction' => false, 'dispatchAction' => false,
'submitAction' => 'delete', 'submitAction' => 'delete',
'content' => null, 'content' => null,
@ -145,11 +146,11 @@ class="relative w-auto h-auto">
@else @else
@if ($disabled) @if ($disabled)
@if ($buttonFullWidth) @if ($buttonFullWidth)
<x-forms.button class="w-full" isError disabled wire:target> <x-forms.button class="w-full" isError disabled :authDisabled="$authDisabled" wire:target>
{{ $buttonTitle }} {{ $buttonTitle }}
</x-forms.button> </x-forms.button>
@else @else
<x-forms.button isError disabled wire:target> <x-forms.button isError disabled :authDisabled="$authDisabled" wire:target>
{{ $buttonTitle }} {{ $buttonTitle }}
</x-forms.button> </x-forms.button>
@endif @endif

View file

@ -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"> 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 Download displayed logs
</button> </button>
@can('update', $application)
<button x-on:click=" <button x-on:click="
downloadingAllLogs = true; downloadingAllLogs = true;
$wire.downloadAllLogs().then(logs => { $wire.downloadAllLogs().then(logs => {
@ -276,6 +277,7 @@ class="block w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-200
Downloading... Downloading...
</span> </span>
</button> </button>
@endcan
</div> </div>
</div> </div>
</div> </div>

View file

@ -85,7 +85,7 @@
</x-forms.button> </x-forms.button>
@endif @endif
@endif @endif
<x-modal-confirmation :disabled="!auth()->user()->can('deploy', $application)" title="Confirm Application Stopping?" buttonTitle="Stop" <x-modal-confirmation :disabled="!auth()->user()->can('deploy', $application)" :authDisabled="!auth()->user()->can('deploy', $application)" title="Confirm Application Stopping?" buttonTitle="Stop"
submitAction="stop" :checkboxes="$checkboxes" :actions="[ submitAction="stop" :checkboxes="$checkboxes" :actions="[
'This application will be stopped.', 'This application will be stopped.',
'All non-persistent data of this application will be deleted.', 'All non-persistent data of this application will be deleted.',

View file

@ -40,7 +40,7 @@ class="flex overflow-x-scroll shrink-0 gap-6 items-center whitespace-nowrap sm:o
@if ($database->destination->server->isFunctional()) @if ($database->destination->server->isFunctional())
<div class="flex flex-wrap gap-2 items-center"> <div class="flex flex-wrap gap-2 items-center">
@if (!str($database->status)->startsWith('exited')) @if (!str($database->status)->startsWith('exited'))
<x-modal-confirmation :disabled="!auth()->user()->can('manage', $database)" title="Confirm Database Restart?" buttonTitle="Restart" submitAction="restart" <x-modal-confirmation :disabled="!auth()->user()->can('manage', $database)" :authDisabled="!auth()->user()->can('manage', $database)" title="Confirm Database Restart?" buttonTitle="Restart" submitAction="restart"
:actions="[ :actions="[
'This database will be unavailable during the restart.', 'This database will be unavailable during the restart.',
'If the database is currently in use data could be lost.', 'If the database is currently in use data could be lost.',
@ -58,7 +58,7 @@ class="flex overflow-x-scroll shrink-0 gap-6 items-center whitespace-nowrap sm:o
Restart Restart
</x-slot:button-title> </x-slot:button-title>
</x-modal-confirmation> </x-modal-confirmation>
<x-modal-confirmation :disabled="!auth()->user()->can('manage', $database)" title="Confirm Database Stopping?" buttonTitle="Stop" submitAction="stop" <x-modal-confirmation :disabled="!auth()->user()->can('manage', $database)" :authDisabled="!auth()->user()->can('manage', $database)" title="Confirm Database Stopping?" buttonTitle="Stop" submitAction="stop"
:checkboxes="$checkboxes" :actions="[ :checkboxes="$checkboxes" :actions="[
'This database will be stopped.', 'This database will be stopped.',
'If the database is currently in use data could be lost.', 'If the database is currently in use data could be lost.',
@ -80,7 +80,7 @@ class="flex overflow-x-scroll shrink-0 gap-6 items-center whitespace-nowrap sm:o
</x-slot:button-title> </x-slot:button-title>
</x-modal-confirmation> </x-modal-confirmation>
@else @else
<button @disabled(!auth()->user()->can('manage', $database)) @click="$wire.dispatch('startEvent')" class="gap-2 button"> <x-forms.button canGate="manage" :canResource="$database" @click="$wire.dispatch('startEvent')" class="gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 dark:text-warning" viewBox="0 0 24 24" <svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 dark:text-warning" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round"
stroke-linejoin="round"> stroke-linejoin="round">
@ -88,17 +88,15 @@ class="flex overflow-x-scroll shrink-0 gap-6 items-center whitespace-nowrap sm:o
<path d="M7 4v16l13 -8z" /> <path d="M7 4v16l13 -8z" />
</svg> </svg>
Start Start
</button> </x-forms.button>
@endif @endif
@script @script
<script> <script>
$wire.$on('startEvent', () => { $wire.$on('startEvent', () => {
window.dispatchEvent(new CustomEvent('startdatabase'));
$wire.$call('start'); $wire.$call('start');
}); });
$wire.$on('restartEvent', () => { $wire.$on('restartEvent', () => {
$wire.$dispatch('info', 'Restarting database.'); $wire.$dispatch('info', 'Restarting database.');
window.dispatchEvent(new CustomEvent('startdatabase'));
$wire.$call('restart'); $wire.$call('restart');
}); });
</script> </script>

View file

@ -40,7 +40,7 @@
</svg> </svg>
Restart Restart
</x-forms.button> </x-forms.button>
<x-modal-confirmation :disabled="!auth()->user()->can('stop', $service)" title="Confirm Service Stopping?" buttonTitle="Stop" :dispatchEvent="true" <x-modal-confirmation :disabled="!auth()->user()->can('stop', $service)" :authDisabled="!auth()->user()->can('stop', $service)" title="Confirm Service Stopping?" buttonTitle="Stop" :dispatchEvent="true"
submitAction="stop" dispatchEventType="stopEvent" :checkboxes="$checkboxes" :actions="[__('service.stop'), __('resource.non_persistent')]" submitAction="stop" dispatchEventType="stopEvent" :checkboxes="$checkboxes" :actions="[__('service.stop'), __('resource.non_persistent')]"
:confirmWithText="false" :confirmWithPassword="false" step1ButtonText="Continue" step2ButtonText="Confirm"> :confirmWithText="false" :confirmWithPassword="false" step1ButtonText="Continue" step2ButtonText="Confirm">
<x-slot:button-title> <x-slot:button-title>
@ -68,7 +68,7 @@
</svg> </svg>
Restart Restart
</x-forms.button> </x-forms.button>
<x-modal-confirmation :disabled="!auth()->user()->can('stop', $service)" title="Confirm Service Stopping?" buttonTitle="Stop" :dispatchEvent="true" <x-modal-confirmation :disabled="!auth()->user()->can('stop', $service)" :authDisabled="!auth()->user()->can('stop', $service)" title="Confirm Service Stopping?" buttonTitle="Stop" :dispatchEvent="true"
submitAction="stop" dispatchEventType="stopEvent" :checkboxes="$checkboxes" :actions="[__('service.stop'), __('resource.non_persistent')]" submitAction="stop" dispatchEventType="stopEvent" :checkboxes="$checkboxes" :actions="[__('service.stop'), __('resource.non_persistent')]"
:confirmWithText="false" :confirmWithPassword="false" step1ButtonText="Continue" step2ButtonText="Confirm"> :confirmWithText="false" :confirmWithPassword="false" step1ButtonText="Continue" step2ButtonText="Confirm">
<x-slot:button-title> <x-slot:button-title>
@ -86,7 +86,7 @@
</x-slot:button-title> </x-slot:button-title>
</x-modal-confirmation> </x-modal-confirmation>
@elseif (str($service->status)->contains('exited')) @elseif (str($service->status)->contains('exited'))
<button @disabled(!auth()->user()->can('deploy', $service)) @click="$wire.dispatch('startEvent')" class="gap-2 button"> <x-forms.button canGate="deploy" :canResource="$service" @click="$wire.dispatch('startEvent')" class="gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 dark:text-warning" viewBox="0 0 24 24" <svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 dark:text-warning" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round"
stroke-linejoin="round"> stroke-linejoin="round">
@ -94,9 +94,9 @@
<path d="M7 4v16l13 -8z" /> <path d="M7 4v16l13 -8z" />
</svg> </svg>
Deploy Deploy
</button> </x-forms.button>
@else @else
<x-modal-confirmation :disabled="!auth()->user()->can('stop', $service)" title="Confirm Service Stopping?" buttonTitle="Stop" :dispatchEvent="true" <x-modal-confirmation :disabled="!auth()->user()->can('stop', $service)" :authDisabled="!auth()->user()->can('stop', $service)" title="Confirm Service Stopping?" buttonTitle="Stop" :dispatchEvent="true"
submitAction="stop" dispatchEventType="stopEvent" :checkboxes="$checkboxes" :actions="[__('service.stop'), __('resource.non_persistent')]" submitAction="stop" dispatchEventType="stopEvent" :checkboxes="$checkboxes" :actions="[__('service.stop'), __('resource.non_persistent')]"
:confirmWithText="false" :confirmWithPassword="false" step1ButtonText="Continue" step2ButtonText="Confirm"> :confirmWithText="false" :confirmWithPassword="false" step1ButtonText="Continue" step2ButtonText="Confirm">
<x-slot:button-title> <x-slot:button-title>
@ -113,7 +113,7 @@
Stop Stop
</x-slot:button-title> </x-slot:button-title>
</x-modal-confirmation> </x-modal-confirmation>
<button @disabled(!auth()->user()->can('deploy', $service)) @click="$wire.dispatch('startEvent')" class="gap-2 button"> <x-forms.button canGate="deploy" :canResource="$service" @click="$wire.dispatch('startEvent')" class="gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 dark:text-warning" viewBox="0 0 24 24" <svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 dark:text-warning" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round"
stroke-linejoin="round"> stroke-linejoin="round">
@ -121,7 +121,7 @@
<path d="M7 4v16l13 -8z" /> <path d="M7 4v16l13 -8z" />
</svg> </svg>
Deploy Deploy
</button> </x-forms.button>
@endif @endif
</div> </div>
@else @else
@ -149,11 +149,9 @@
); );
return; return;
} }
window.dispatchEvent(new CustomEvent('startservice'));
$wire.$call('start'); $wire.$call('start');
}); });
$wire.$on('forceDeployEvent', () => { $wire.$on('forceDeployEvent', () => {
window.dispatchEvent(new CustomEvent('startservice'));
$wire.$call('forceDeploy'); $wire.$call('forceDeploy');
}); });
$wire.$on('restartEvent', async () => { $wire.$on('restartEvent', async () => {
@ -166,12 +164,10 @@
} }
$wire.$dispatch('info', $wire.$dispatch('info',
'Gracefully stopping service.<br/><br/>It could take a while depending on the service.'); 'Gracefully stopping service.<br/><br/>It could take a while depending on the service.');
window.dispatchEvent(new CustomEvent('startservice'));
$wire.$call('restart'); $wire.$call('restart');
}); });
$wire.$on('pullAndRestartEvent', () => { $wire.$on('pullAndRestartEvent', () => {
$wire.$dispatch('info', 'Pulling new images and restarting service.'); $wire.$dispatch('info', 'Pulling new images and restarting service.');
window.dispatchEvent(new CustomEvent('startservice'));
$wire.$call('pullAndRestartEvent'); $wire.$call('pullAndRestartEvent');
}); });
$wire.on('imagePulled', () => { $wire.on('imagePulled', () => {

View file

@ -139,15 +139,22 @@
@else @else
<div class="flex flex-col w-full gap-2 lg:flex-row"> <div class="flex flex-col w-full gap-2 lg:flex-row">
<x-forms.input disabled id="key" /> <x-forms.input disabled id="key" />
<x-forms.env-var-input @if ($isValueHidden)
disabled <div class="w-full">
type="password" <input disabled type="text" value="Hidden (only admins can view)"
id="value" class="input italic !text-neutral-500 dark:!text-neutral-500" />
:availableVars="$this->availableSharedVariables" </div>
:projectUuid="data_get($parameters, 'project_uuid')" @else
:environmentUuid="data_get($parameters, 'environment_uuid')" /> <x-forms.env-var-input
@if ($is_shared) disabled
<x-forms.input disabled type="password" id="real_value" /> type="password"
id="value"
:availableVars="$this->availableSharedVariables"
:projectUuid="data_get($parameters, 'project_uuid')"
:environmentUuid="data_get($parameters, 'environment_uuid')" />
@if ($is_shared)
<x-forms.input disabled type="password" id="real_value" />
@endif
@endif @endif
</div> </div>
@endcan @endcan

View file

@ -3,7 +3,7 @@
<h2>Healthchecks</h2> <h2>Healthchecks</h2>
<x-forms.button canGate="update" :canResource="$resource" type="submit">Save</x-forms.button> <x-forms.button canGate="update" :canResource="$resource" type="submit">Save</x-forms.button>
@if (!$healthCheckEnabled) @if (!$healthCheckEnabled)
<x-modal-confirmation :disabled="!auth()->user()->can('update', $resource)" title="Confirm Healthcheck Enable?" buttonTitle="Enable Healthcheck" <x-modal-confirmation :disabled="!auth()->user()->can('update', $resource)" :authDisabled="!auth()->user()->can('update', $resource)" title="Confirm Healthcheck Enable?" buttonTitle="Enable Healthcheck"
submitAction="toggleHealthcheck" :actions="['Enable healthcheck for this resource.']" submitAction="toggleHealthcheck" :actions="['Enable healthcheck for this resource.']"
warningMessage="If the health check fails, your application will become inaccessible. Please review the <a href='https://coolify.io/docs/knowledge-base/health-checks' target='_blank' class='underline text-white'>Health Checks</a> guide before proceeding!" warningMessage="If the health check fails, your application will become inaccessible. Please review the <a href='https://coolify.io/docs/knowledge-base/health-checks' target='_blank' class='underline text-white'>Health Checks</a> guide before proceeding!"
step2ButtonText="Enable Healthcheck" :confirmWithText="false" :confirmWithPassword="false" step2ButtonText="Enable Healthcheck" :confirmWithText="false" :confirmWithPassword="false"

View file

@ -13,36 +13,43 @@
<div class="flex flex-col gap-2 pb-2"> <div class="flex flex-col gap-2 pb-2">
<div class="flex gap-2 items-end"> <div class="flex gap-2 items-end">
<h2>Scheduled Task</h2> <h2>Scheduled Task</h2>
<x-forms.button type="submit"> <x-forms.button canGate="update" :canResource="$resource" type="submit">
Save Save
</x-forms.button> </x-forms.button>
@if ($resource->isRunning()) @if ($resource->isRunning())
<x-forms.button type="button" wire:click="executeNow"> @can('update', $resource)
Execute Now <x-forms.button type="button" wire:click="executeNow">
</x-forms.button> Execute Now
</x-forms.button>
@endcan
@endif @endif
<x-modal-confirmation title="Confirm Scheduled Task Deletion?" isErrorButton buttonTitle="Delete" @can('update', $resource)
submitAction="delete({{ $task->id }})" :actions="['The selected scheduled task will be permanently deleted.']" confirmationText="{{ $task->name }}" <x-modal-confirmation title="Confirm Scheduled Task Deletion?" isErrorButton buttonTitle="Delete"
confirmationLabel="Please confirm the execution of the actions by entering the Scheduled Task Name below" submitAction="delete({{ $task->id }})" :actions="['The selected scheduled task will be permanently deleted.']" confirmationText="{{ $task->name }}"
shortConfirmationLabel="Scheduled Task Name" :confirmWithPassword="false" confirmationLabel="Please confirm the execution of the actions by entering the Scheduled Task Name below"
step2ButtonText="Permanently Delete" /> shortConfirmationLabel="Scheduled Task Name" :confirmWithPassword="false"
step2ButtonText="Permanently Delete" />
@endcan
</div> </div>
<div class="w-48"> <div class="w-48">
<x-forms.checkbox instantSave id="isEnabled" label="Enabled" /> @can('update', $resource)
<x-forms.checkbox instantSave id="isEnabled" label="Enabled" />
@else
<x-forms.checkbox disabled id="isEnabled" label="Enabled" />
@endcan
</div> </div>
<div class="flex gap-2 w-full"> <div class="flex gap-2 w-full">
<x-forms.input placeholder="Name" id="name" label="Name" required /> <x-forms.input :disabled="!auth()->user()->can('update', $resource)" placeholder="Name" id="name" label="Name" required />
<x-forms.input placeholder="php artisan schedule:run" id="command" label="Command" required /> <x-forms.input :disabled="!auth()->user()->can('update', $resource)" placeholder="php artisan schedule:run" id="command" label="Command" required />
<x-forms.input placeholder="0 0 * * * or daily" id="frequency" label="Frequency" required /> <x-forms.input :disabled="!auth()->user()->can('update', $resource)" placeholder="0 0 * * * or daily" id="frequency" label="Frequency" required />
<x-forms.input type="number" placeholder="300" id="timeout" <x-forms.input :disabled="!auth()->user()->can('update', $resource)" type="number" placeholder="300" id="timeout"
helper="Maximum execution time in seconds (60-36000)." label="Timeout (seconds)" required /> helper="Maximum execution time in seconds (60-36000)." label="Timeout (seconds)" required />
@if ($type === 'application') @if ($type === 'application')
<x-forms.input placeholder="php" <x-forms.input :disabled="!auth()->user()->can('update', $resource)" placeholder="php"
helper="You can leave this empty if your resource only has one container." id="container" helper="You can leave this empty if your resource only has one container." id="container"
label="Container name" /> label="Container name" />
@elseif ($type === 'service') @elseif ($type === 'service')
<x-forms.input placeholder="php" <x-forms.input :disabled="!auth()->user()->can('update', $resource)" placeholder="php"
helper="You can leave this empty if your resource only has one service in your stack. Otherwise use the stack name, without the random generated ID. So if you have a mysql service in your stack, use mysql." helper="You can leave this empty if your resource only has one service in your stack. Otherwise use the stack name, without the random generated ID. So if you have a mysql service in your stack, use mysql."
id="container" label="Service name" /> id="container" label="Service name" />
@endif @endif

View file

@ -18,21 +18,23 @@
<x-forms.input required id="description" label="Description" /> <x-forms.input required id="description" label="Description" />
<x-forms.button type="submit">Create</x-forms.button> <x-forms.button type="submit">Create</x-forms.button>
</div> </div>
<div class="flex"> <div class="flex items-center gap-2">
Permissions <span>Permissions</span>
<x-helper class="px-1" helper="These permissions will be granted to the token." /><span <x-helper helper="These permissions will be granted to the token." />
class="pr-1">:</span> @if ($permissions)
<div class="flex gap-1 font-bold dark:text-white"> <div class="flex gap-1.5 flex-wrap">
@if ($permissions)
@foreach ($permissions as $permission) @foreach ($permissions as $permission)
<div>{{ $permission }}</div> <span
class="px-2 py-0.5 text-xs rounded-sm font-medium {{ $permission === 'root' ? 'bg-red-500/20 text-red-400' : ($permission === 'write' || $permission === 'write:sensitive' ? 'bg-amber-500/20 text-amber-400' : ($permission === 'deploy' ? 'bg-blue-500/20 text-blue-400' : 'bg-neutral-500/20 text-neutral-300')) }}">
{{ $permission }}
</span>
@endforeach @endforeach
@endif </div>
</div> @endif
</div> </div>
<h4>Token Permissions</h4> <h4>Token Permissions</h4>
<div class="w-64"> <div class="w-96">
@if ($canUseRootPermissions) @if ($canUseRootPermissions)
<x-forms.checkbox label="root" wire:model.live="permissions" domValue="root" <x-forms.checkbox label="root" wire:model.live="permissions" domValue="root"
helper="Root access, be careful!" :checked="in_array('root', $permissions)"></x-forms.checkbox> helper="Root access, be careful!" :checked="in_array('root', $permissions)"></x-forms.checkbox>
@ -59,9 +61,14 @@ class="pr-1">:</span>
@endif @endif
<x-forms.checkbox label="read" domValue="read" wire:model.live="permissions" domValue="read" <x-forms.checkbox label="read" domValue="read" wire:model.live="permissions" domValue="read"
:checked="in_array('read', $permissions)"></x-forms.checkbox> :checked="in_array('read', $permissions)"></x-forms.checkbox>
<x-forms.checkbox label="read:sensitive" wire:model.live="permissions" domValue="read:sensitive" @if ($canUseSensitivePermissions)
helper="Responses will include secrets, logs, passwords, and compose file contents." <x-forms.checkbox label="read:sensitive" wire:model.live="permissions" domValue="read:sensitive"
:checked="in_array('read:sensitive', $permissions)"></x-forms.checkbox> helper="Responses will include secrets, logs, passwords, and compose file contents."
:checked="in_array('read:sensitive', $permissions)"></x-forms.checkbox>
@else
<x-forms.checkbox label="read:sensitive (admin/owner only)" disabled domValue="read:sensitive"
helper="Read:sensitive access requires admin or owner role" :checked="false"></x-forms.checkbox>
@endif
@endif @endif
</div> </div>
@if (in_array('root', $permissions)) @if (in_array('root', $permissions))
@ -70,44 +77,74 @@ class="pr-1">:</span>
</form> </form>
@endcan @endcan
@if (session()->has('token')) @if (session()->has('token'))
<div class="py-4 font-bold dark:text-warning">Please copy this token now. For your security, it won't be shown <div class="p-4 my-4 border rounded dark:border-coolgray-200 dark:bg-coolgray-100">
again. <div class="pb-2 font-bold dark:text-warning">Please copy this token now. For your security, it won't
be shown again.</div>
<div class="relative" x-data="{ copied: false, isSecure: window.isSecureContext }">
<input type="text" value="{{ session('token') }}" readonly
class="input !text-white !bg-coolgray-200 font-mono" />
<button x-show="isSecure"
@click.prevent="copied = true; navigator.clipboard.writeText({{ Js::from(session('token')) }}); setTimeout(() => copied = false, 1000)"
class="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 text-gray-400 hover:text-gray-300 transition-colors"
title="Copy to clipboard">
<svg x-show="!copied" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<svg x-show="copied" class="w-5 h-5 text-green-500" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M5 13l4 4L19 7" />
</svg>
</button>
</div>
</div> </div>
<div class="pb-4 font-bold dark:text-white"> {{ session('token') }}</div>
@endif @endif
<h3 class="py-4">Issued Tokens</h3> <div x-data="{ search: '' }">
<div class="grid gap-2 lg:grid-cols-1"> <div class="flex items-center justify-between py-4">
@forelse ($tokens as $token) <h3>Issued Tokens</h3>
<div wire:key="token-{{ $token->id }}" @if ($tokens->count() > 1)
class="flex flex-col gap-1 p-2 border dark:border-coolgray-200 hover:no-underline"> <input type="text" x-model="search" placeholder="Filter tokens..."
<div>Description: {{ $token->name }}</div> class="input w-64" />
<div>Last used: {{ $token->last_used_at ? $token->last_used_at->diffForHumans() : 'Never' }}</div> @endif
<div class="flex gap-1"> </div>
@if ($token->abilities) <div class="flex flex-col gap-2">
Permissions: @forelse ($tokens as $token)
@foreach ($token->abilities as $ability) <div wire:key="token-{{ $token->id }}"
<div class="font-bold dark:text-white">{{ $ability }}</div> x-show="!search || '{{ strtolower($token->name) }} {{ strtolower(implode(' ', $token->abilities ?? [])) }}'.includes(search.toLowerCase())"
@endforeach class="flex items-center justify-between p-3 border rounded dark:border-coolgray-200">
<div>
<div class="flex items-center gap-2">
<span class="font-bold dark:text-white">{{ $token->name }}</span>
@if ($token->abilities)
@foreach ($token->abilities as $ability)
<span
class="px-2 py-0.5 text-xs rounded-sm font-medium {{ $ability === 'root' ? 'bg-red-500/20 text-red-400' : ($ability === 'write' || $ability === 'write:sensitive' ? 'bg-amber-500/20 text-amber-400' : ($ability === 'deploy' ? 'bg-blue-500/20 text-blue-400' : 'bg-neutral-500/20 text-neutral-300')) }}">
{{ $ability }}
</span>
@endforeach
@endif
</div>
<div class="text-xs text-neutral-400">
Last used: {{ $token->last_used_at ? $token->last_used_at->diffForHumans() : 'Never' }}
</div>
</div>
@if (auth()->id() === $token->tokenable_id)
<x-modal-confirmation title="Confirm API Token Revocation?" isErrorButton
buttonTitle="Revoke" submitAction="revoke({{ data_get($token, 'id') }})"
:actions="[
'This API Token will be revoked and permanently deleted.',
'Any API call made with this token will fail.',
]" confirmationText="{{ $token->name }}"
confirmationLabel="Please confirm the execution of the actions by entering the API Token Description below"
shortConfirmationLabel="API Token Description" :confirmWithPassword="false"
step2ButtonText="Revoke API Token" />
@endif @endif
</div> </div>
@empty
@if (auth()->id() === $token->tokenable_id) <div class="text-neutral-400">No API tokens found.</div>
<x-modal-confirmation title="Confirm API Token Revocation?" isErrorButton buttonTitle="Revoke token" @endforelse
submitAction="revoke({{ data_get($token, 'id') }})" :actions="[ </div>
'This API Token will be revoked and permanently deleted.',
'Any API call made with this token will fail.',
]"
confirmationText="{{ $token->name }}"
confirmationLabel="Please confirm the execution of the actions by entering the API Token Description below"
shortConfirmationLabel="API Token Description" :confirmWithPassword="false"
step2ButtonText="Revoke API Token" />
@endif
</div>
@empty
<div>
<div>No API tokens found.</div>
</div>
@endforelse
</div> </div>
@endif @endif
</div> </div>

View file

@ -0,0 +1,274 @@
<?php
use App\Livewire\Project\Shared\EnvironmentVariable\All as EnvironmentVariableAll;
use App\Livewire\Project\Shared\EnvironmentVariable\Show as EnvironmentVariableShow;
use App\Models\Application;
use App\Models\EnvironmentVariable;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 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');
});

View file

@ -0,0 +1,127 @@
<?php
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::create(['id' => 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);
});
});

View file

@ -165,3 +165,29 @@
$policy = new ApiTokenPolicy; $policy = new ApiTokenPolicy;
expect($policy->useDeployPermissions($user))->toBeFalse(); 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();
});

View file

@ -52,7 +52,23 @@
expect($policy->update($user, $storage))->toBeTrue(); 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([ $teams = collect([
(object) ['id' => 1, 'pivot' => (object) ['role' => 'admin']], (object) ['id' => 1, 'pivot' => (object) ['role' => 'admin']],
]); ]);
@ -68,9 +84,9 @@
expect($policy->update($user, $storage))->toBeFalse(); 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([ $teams = collect([
(object) ['id' => 1, 'pivot' => (object) ['role' => 'member']], (object) ['id' => 1, 'pivot' => (object) ['role' => 'admin']],
]); ]);
$user = Mockery::mock(User::class)->makePartial(); $user = Mockery::mock(User::class)->makePartial();
@ -84,7 +100,23 @@
expect($policy->delete($user, $storage))->toBeTrue(); 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([ $teams = collect([
(object) ['id' => 1, 'pivot' => (object) ['role' => 'owner']], (object) ['id' => 1, 'pivot' => (object) ['role' => 'owner']],
]); ]);
@ -116,9 +148,9 @@
expect($policy->create($user))->toBeFalse(); 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([ $teams = collect([
(object) ['id' => 1, 'pivot' => (object) ['role' => 'member']], (object) ['id' => 1, 'pivot' => (object) ['role' => 'admin']],
]); ]);
$user = Mockery::mock(User::class)->makePartial(); $user = Mockery::mock(User::class)->makePartial();
@ -132,7 +164,23 @@
expect($policy->validateConnection($user, $storage))->toBeTrue(); 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([ $teams = collect([
(object) ['id' => 1, 'pivot' => (object) ['role' => 'admin']], (object) ['id' => 1, 'pivot' => (object) ['role' => 'admin']],
]); ]);