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:
parent
e82942b387
commit
b878dc8102
49 changed files with 919 additions and 136 deletions
|
|
@ -54,6 +54,10 @@ private function removeSensitiveData($application)
|
|||
]);
|
||||
}
|
||||
|
||||
if ($application->is_shown_once ?? false) {
|
||||
$application->makeHidden(['value', 'real_value']);
|
||||
}
|
||||
|
||||
return serializeApiResponse($application);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,10 @@ private function removeSensitiveData($service)
|
|||
]);
|
||||
}
|
||||
|
||||
if ($service->is_shown_once ?? false) {
|
||||
$service->makeHidden(['value', 'real_value']);
|
||||
}
|
||||
|
||||
return serializeApiResponse($service);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = '';
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
], [
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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]) }}
|
||||
{{ $attributes->merge(['type' => 'button']) }}
|
||||
@isset($confirm)
|
||||
|
|
@ -18,3 +39,9 @@
|
|||
@endif
|
||||
@endif
|
||||
</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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
<x-forms.button class="w-full" isError disabled wire:target>
|
||||
<x-forms.button class="w-full" isError disabled :authDisabled="$authDisabled" wire:target>
|
||||
{{ $buttonTitle }}
|
||||
</x-forms.button>
|
||||
@else
|
||||
<x-forms.button isError disabled wire:target>
|
||||
<x-forms.button isError disabled :authDisabled="$authDisabled" wire:target>
|
||||
{{ $buttonTitle }}
|
||||
</x-forms.button>
|
||||
@endif
|
||||
|
|
|
|||
|
|
@ -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
|
||||
</button>
|
||||
@can('update', $application)
|
||||
<button x-on:click="
|
||||
downloadingAllLogs = true;
|
||||
$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...
|
||||
</span>
|
||||
</button>
|
||||
@endcan
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@
|
|||
</x-forms.button>
|
||||
@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="[
|
||||
'This application will be stopped.',
|
||||
'All non-persistent data of this application will be deleted.',
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ class="flex overflow-x-scroll shrink-0 gap-6 items-center whitespace-nowrap sm:o
|
|||
@if ($database->destination->server->isFunctional())
|
||||
<div class="flex flex-wrap gap-2 items-center">
|
||||
@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="[
|
||||
'This database will be unavailable during the restart.',
|
||||
'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
|
||||
</x-slot:button-title>
|
||||
</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="[
|
||||
'This database will be stopped.',
|
||||
'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-modal-confirmation>
|
||||
@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"
|
||||
stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="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" />
|
||||
</svg>
|
||||
Start
|
||||
</button>
|
||||
</x-forms.button>
|
||||
@endif
|
||||
@script
|
||||
<script>
|
||||
$wire.$on('startEvent', () => {
|
||||
window.dispatchEvent(new CustomEvent('startdatabase'));
|
||||
$wire.$call('start');
|
||||
});
|
||||
$wire.$on('restartEvent', () => {
|
||||
$wire.$dispatch('info', 'Restarting database.');
|
||||
window.dispatchEvent(new CustomEvent('startdatabase'));
|
||||
$wire.$call('restart');
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@
|
|||
</svg>
|
||||
Restart
|
||||
</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')]"
|
||||
:confirmWithText="false" :confirmWithPassword="false" step1ButtonText="Continue" step2ButtonText="Confirm">
|
||||
<x-slot:button-title>
|
||||
|
|
@ -68,7 +68,7 @@
|
|||
</svg>
|
||||
Restart
|
||||
</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')]"
|
||||
:confirmWithText="false" :confirmWithPassword="false" step1ButtonText="Continue" step2ButtonText="Confirm">
|
||||
<x-slot:button-title>
|
||||
|
|
@ -86,7 +86,7 @@
|
|||
</x-slot:button-title>
|
||||
</x-modal-confirmation>
|
||||
@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"
|
||||
stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
|
|
@ -94,9 +94,9 @@
|
|||
<path d="M7 4v16l13 -8z" />
|
||||
</svg>
|
||||
Deploy
|
||||
</button>
|
||||
</x-forms.button>
|
||||
@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')]"
|
||||
:confirmWithText="false" :confirmWithPassword="false" step1ButtonText="Continue" step2ButtonText="Confirm">
|
||||
<x-slot:button-title>
|
||||
|
|
@ -113,7 +113,7 @@
|
|||
Stop
|
||||
</x-slot:button-title>
|
||||
</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"
|
||||
stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
|
|
@ -121,7 +121,7 @@
|
|||
<path d="M7 4v16l13 -8z" />
|
||||
</svg>
|
||||
Deploy
|
||||
</button>
|
||||
</x-forms.button>
|
||||
@endif
|
||||
</div>
|
||||
@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.<br/><br/>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', () => {
|
||||
|
|
|
|||
|
|
@ -139,15 +139,22 @@
|
|||
@else
|
||||
<div class="flex flex-col w-full gap-2 lg:flex-row">
|
||||
<x-forms.input disabled id="key" />
|
||||
<x-forms.env-var-input
|
||||
disabled
|
||||
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" />
|
||||
@if ($isValueHidden)
|
||||
<div class="w-full">
|
||||
<input disabled type="text" value="Hidden (only admins can view)"
|
||||
class="input italic !text-neutral-500 dark:!text-neutral-500" />
|
||||
</div>
|
||||
@else
|
||||
<x-forms.env-var-input
|
||||
disabled
|
||||
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
|
||||
</div>
|
||||
@endcan
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<h2>Healthchecks</h2>
|
||||
<x-forms.button canGate="update" :canResource="$resource" type="submit">Save</x-forms.button>
|
||||
@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.']"
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -13,36 +13,43 @@
|
|||
<div class="flex flex-col gap-2 pb-2">
|
||||
<div class="flex gap-2 items-end">
|
||||
<h2>Scheduled Task</h2>
|
||||
<x-forms.button type="submit">
|
||||
<x-forms.button canGate="update" :canResource="$resource" type="submit">
|
||||
Save
|
||||
</x-forms.button>
|
||||
@if ($resource->isRunning())
|
||||
<x-forms.button type="button" wire:click="executeNow">
|
||||
Execute Now
|
||||
</x-forms.button>
|
||||
@can('update', $resource)
|
||||
<x-forms.button type="button" wire:click="executeNow">
|
||||
Execute Now
|
||||
</x-forms.button>
|
||||
@endcan
|
||||
@endif
|
||||
<x-modal-confirmation title="Confirm Scheduled Task Deletion?" isErrorButton buttonTitle="Delete"
|
||||
submitAction="delete({{ $task->id }})" :actions="['The selected scheduled task will be permanently deleted.']" confirmationText="{{ $task->name }}"
|
||||
confirmationLabel="Please confirm the execution of the actions by entering the Scheduled Task Name below"
|
||||
shortConfirmationLabel="Scheduled Task Name" :confirmWithPassword="false"
|
||||
step2ButtonText="Permanently Delete" />
|
||||
|
||||
@can('update', $resource)
|
||||
<x-modal-confirmation title="Confirm Scheduled Task Deletion?" isErrorButton buttonTitle="Delete"
|
||||
submitAction="delete({{ $task->id }})" :actions="['The selected scheduled task will be permanently deleted.']" confirmationText="{{ $task->name }}"
|
||||
confirmationLabel="Please confirm the execution of the actions by entering the Scheduled Task Name below"
|
||||
shortConfirmationLabel="Scheduled Task Name" :confirmWithPassword="false"
|
||||
step2ButtonText="Permanently Delete" />
|
||||
@endcan
|
||||
</div>
|
||||
<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 class="flex gap-2 w-full">
|
||||
<x-forms.input placeholder="Name" id="name" label="Name" required />
|
||||
<x-forms.input 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 type="number" placeholder="300" id="timeout"
|
||||
<x-forms.input :disabled="!auth()->user()->can('update', $resource)" placeholder="Name" id="name" label="Name" required />
|
||||
<x-forms.input :disabled="!auth()->user()->can('update', $resource)" placeholder="php artisan schedule:run" id="command" label="Command" required />
|
||||
<x-forms.input :disabled="!auth()->user()->can('update', $resource)" placeholder="0 0 * * * or daily" id="frequency" label="Frequency" required />
|
||||
<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 />
|
||||
@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"
|
||||
label="Container name" />
|
||||
@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."
|
||||
id="container" label="Service name" />
|
||||
@endif
|
||||
|
|
|
|||
|
|
@ -18,21 +18,23 @@
|
|||
<x-forms.input required id="description" label="Description" />
|
||||
<x-forms.button type="submit">Create</x-forms.button>
|
||||
</div>
|
||||
<div class="flex">
|
||||
Permissions
|
||||
<x-helper class="px-1" helper="These permissions will be granted to the token." /><span
|
||||
class="pr-1">:</span>
|
||||
<div class="flex gap-1 font-bold dark:text-white">
|
||||
@if ($permissions)
|
||||
<div class="flex items-center gap-2">
|
||||
<span>Permissions</span>
|
||||
<x-helper helper="These permissions will be granted to the token." />
|
||||
@if ($permissions)
|
||||
<div class="flex gap-1.5 flex-wrap">
|
||||
@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
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<h4>Token Permissions</h4>
|
||||
<div class="w-64">
|
||||
<div class="w-96">
|
||||
@if ($canUseRootPermissions)
|
||||
<x-forms.checkbox label="root" wire:model.live="permissions" domValue="root"
|
||||
helper="Root access, be careful!" :checked="in_array('root', $permissions)"></x-forms.checkbox>
|
||||
|
|
@ -59,9 +61,14 @@ class="pr-1">:</span>
|
|||
@endif
|
||||
<x-forms.checkbox label="read" domValue="read" wire:model.live="permissions" domValue="read"
|
||||
:checked="in_array('read', $permissions)"></x-forms.checkbox>
|
||||
<x-forms.checkbox label="read:sensitive" wire:model.live="permissions" domValue="read:sensitive"
|
||||
helper="Responses will include secrets, logs, passwords, and compose file contents."
|
||||
:checked="in_array('read:sensitive', $permissions)"></x-forms.checkbox>
|
||||
@if ($canUseSensitivePermissions)
|
||||
<x-forms.checkbox label="read:sensitive" wire:model.live="permissions" domValue="read:sensitive"
|
||||
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
|
||||
</div>
|
||||
@if (in_array('root', $permissions))
|
||||
|
|
@ -70,44 +77,74 @@ class="pr-1">:</span>
|
|||
</form>
|
||||
@endcan
|
||||
@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
|
||||
again.
|
||||
<div class="p-4 my-4 border rounded dark:border-coolgray-200 dark:bg-coolgray-100">
|
||||
<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 class="pb-4 font-bold dark:text-white"> {{ session('token') }}</div>
|
||||
@endif
|
||||
<h3 class="py-4">Issued Tokens</h3>
|
||||
<div class="grid gap-2 lg:grid-cols-1">
|
||||
@forelse ($tokens as $token)
|
||||
<div wire:key="token-{{ $token->id }}"
|
||||
class="flex flex-col gap-1 p-2 border dark:border-coolgray-200 hover:no-underline">
|
||||
<div>Description: {{ $token->name }}</div>
|
||||
<div>Last used: {{ $token->last_used_at ? $token->last_used_at->diffForHumans() : 'Never' }}</div>
|
||||
<div class="flex gap-1">
|
||||
@if ($token->abilities)
|
||||
Permissions:
|
||||
@foreach ($token->abilities as $ability)
|
||||
<div class="font-bold dark:text-white">{{ $ability }}</div>
|
||||
@endforeach
|
||||
<div x-data="{ search: '' }">
|
||||
<div class="flex items-center justify-between py-4">
|
||||
<h3>Issued Tokens</h3>
|
||||
@if ($tokens->count() > 1)
|
||||
<input type="text" x-model="search" placeholder="Filter tokens..."
|
||||
class="input w-64" />
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
@forelse ($tokens as $token)
|
||||
<div wire:key="token-{{ $token->id }}"
|
||||
x-show="!search || '{{ strtolower($token->name) }} {{ strtolower(implode(' ', $token->abilities ?? [])) }}'.includes(search.toLowerCase())"
|
||||
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
|
||||
</div>
|
||||
|
||||
@if (auth()->id() === $token->tokenable_id)
|
||||
<x-modal-confirmation title="Confirm API Token Revocation?" isErrorButton buttonTitle="Revoke token"
|
||||
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
|
||||
</div>
|
||||
@empty
|
||||
<div>
|
||||
<div>No API tokens found.</div>
|
||||
</div>
|
||||
@endforelse
|
||||
@empty
|
||||
<div class="text-neutral-400">No API tokens found.</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
127
tests/Feature/Authorization/LegacyMemberTokenTest.php
Normal file
127
tests/Feature/Authorization/LegacyMemberTokenTest.php
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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']],
|
||||
]);
|
||||
|
|
|
|||
Loading…
Reference in a new issue