From 86b05b902aedbbb074e73bfe233b3ed006f19b39 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Wed, 25 Feb 2026 14:20:29 +0100
Subject: [PATCH 01/30] fix(auth): enforce authorization checks across API and
Livewire components
- Add authorization checks to API controller endpoints (view, create, update, delete)
- Wrap Livewire component methods with try-catch for consistent error handling
- Add AuthorizesRequests trait to components requiring authorization checks
- Ensure all sensitive operations verify user permissions before execution
- Implement unified error handling with handleError() helper function
---
.../Api/CloudProviderTokensController.php | 4 +
app/Http/Controllers/Api/GithubController.php | 3 +
.../Controllers/Api/HetznerController.php | 1 +
.../Controllers/Api/ProjectController.php | 6 +
.../Controllers/Api/SecurityController.php | 4 +
.../Controllers/Api/ServersController.php | 5 +
app/Http/Controllers/Api/TeamController.php | 2 +
app/Livewire/Project/Application/Heading.php | 166 +++---
app/Livewire/Project/Application/Previews.php | 24 +-
app/Livewire/Project/Database/BackupNow.php | 10 +-
app/Livewire/Project/Database/Heading.php | 20 +-
.../Project/Database/ScheduledBackups.php | 28 +-
app/Livewire/Project/DeleteEnvironment.php | 24 +-
app/Livewire/Project/DeleteProject.php | 24 +-
app/Livewire/Project/Service/Heading.php | 56 +-
app/Livewire/Project/Shared/Destination.php | 38 +-
app/Livewire/Project/Shared/HealthChecks.php | 8 +-
.../Project/Shared/ResourceOperations.php | 494 +++++++++---------
app/Livewire/Security/ApiTokens.php | 14 +
app/Livewire/Security/CloudInitScriptForm.php | 20 +-
.../Security/CloudProviderTokenForm.php | 6 +-
app/Livewire/Security/CloudProviderTokens.php | 8 +-
app/Livewire/Security/PrivateKey/Index.php | 10 +-
app/Livewire/Server/New/ByHetzner.php | 18 +-
app/Livewire/Server/Proxy.php | 12 +-
.../Proxy/DynamicConfigurationNavbar.php | 50 +-
app/Livewire/Server/Resources.php | 33 +-
app/Livewire/Server/Security/Patches.php | 24 +-
app/Livewire/Server/Show.php | 26 +-
app/Livewire/Server/ValidateAndInstall.php | 46 +-
app/Livewire/SharedVariables/Project/Show.php | 10 +-
app/Livewire/SharedVariables/Team/Index.php | 10 +-
app/Livewire/Storage/Show.php | 6 +-
app/Livewire/Team/Index.php | 34 +-
app/Models/Team.php | 2 +-
app/Policies/ApiTokenPolicy.php | 44 +-
app/Policies/ApplicationPolicy.php | 91 ++--
app/Policies/ApplicationPreviewPolicy.php | 51 +-
app/Policies/ApplicationSettingPolicy.php | 29 +-
app/Policies/DatabasePolicy.php | 60 ++-
app/Policies/EnvironmentPolicy.php | 29 +-
app/Policies/EnvironmentVariablePolicy.php | 33 +-
app/Policies/GithubAppPolicy.php | 22 +-
app/Policies/NotificationPolicy.php | 17 +-
app/Policies/ProjectPolicy.php | 18 +-
app/Policies/ResourceCreatePolicy.php | 6 +-
app/Policies/ServerPolicy.php | 21 +-
app/Policies/ServiceApplicationPolicy.php | 15 +-
app/Policies/ServiceDatabasePolicy.php | 23 +-
app/Policies/ServicePolicy.php | 72 ++-
.../SharedEnvironmentVariablePolicy.php | 18 +-
app/Policies/StandaloneDockerPolicy.php | 7 +-
app/Policies/SwarmDockerPolicy.php | 7 +-
.../applications/advanced.blade.php | 4 +-
.../components/notification/navbar.blade.php | 2 +-
.../components/services/advanced.blade.php | 8 +-
.../project/application/heading.blade.php | 154 +++---
.../project/database/heading.blade.php | 128 ++---
.../project/service/heading.blade.php | 14 +-
.../project/shared/health-checks.blade.php | 12 +-
.../livewire/security/api-tokens.blade.php | 9 +-
.../views/livewire/server/navbar.blade.php | 2 +-
.../views/livewire/server/resources.blade.php | 32 +-
tasks/lessons.md | 11 +
tests/Feature/TeamPolicyTest.php | 48 ++
tests/Unit/Policies/ApiTokenPolicyTest.php | 167 ++++++
tests/Unit/Policies/ApplicationPolicyTest.php | 237 +++++++++
.../Policies/ApplicationPreviewPolicyTest.php | 239 +++++++++
.../Policies/ApplicationSettingPolicyTest.php | 178 +++++++
tests/Unit/Policies/DatabasePolicyTest.php | 221 ++++++++
tests/Unit/Policies/EnvironmentPolicyTest.php | 155 ++++++
.../EnvironmentVariablePolicyTest.php | 199 +++++++
tests/Unit/Policies/GithubAppPolicyTest.php | 189 +++++++
.../Unit/Policies/NotificationPolicyTest.php | 175 +++++++
tests/Unit/Policies/PrivateKeyPolicyTest.php | 73 +--
tests/Unit/Policies/ProjectPolicyTest.php | 122 +++++
.../Policies/ResourceCreatePolicyTest.php | 61 +++
tests/Unit/Policies/ServerPolicyTest.php | 157 ++++++
.../Policies/ServiceApplicationPolicyTest.php | 37 ++
.../Policies/ServiceDatabasePolicyTest.php | 37 ++
tests/Unit/Policies/ServicePolicyTest.php | 233 +++++++++
.../SharedEnvironmentVariablePolicyTest.php | 144 +++++
.../Policies/StandaloneDockerPolicyTest.php | 122 +++++
tests/Unit/Policies/SwarmDockerPolicyTest.php | 122 +++++
84 files changed, 4046 insertions(+), 1055 deletions(-)
create mode 100644 tasks/lessons.md
create mode 100644 tests/Unit/Policies/ApiTokenPolicyTest.php
create mode 100644 tests/Unit/Policies/ApplicationPolicyTest.php
create mode 100644 tests/Unit/Policies/ApplicationPreviewPolicyTest.php
create mode 100644 tests/Unit/Policies/ApplicationSettingPolicyTest.php
create mode 100644 tests/Unit/Policies/DatabasePolicyTest.php
create mode 100644 tests/Unit/Policies/EnvironmentPolicyTest.php
create mode 100644 tests/Unit/Policies/EnvironmentVariablePolicyTest.php
create mode 100644 tests/Unit/Policies/GithubAppPolicyTest.php
create mode 100644 tests/Unit/Policies/NotificationPolicyTest.php
create mode 100644 tests/Unit/Policies/ProjectPolicyTest.php
create mode 100644 tests/Unit/Policies/ResourceCreatePolicyTest.php
create mode 100644 tests/Unit/Policies/ServerPolicyTest.php
create mode 100644 tests/Unit/Policies/ServiceApplicationPolicyTest.php
create mode 100644 tests/Unit/Policies/ServiceDatabasePolicyTest.php
create mode 100644 tests/Unit/Policies/ServicePolicyTest.php
create mode 100644 tests/Unit/Policies/SharedEnvironmentVariablePolicyTest.php
create mode 100644 tests/Unit/Policies/StandaloneDockerPolicyTest.php
create mode 100644 tests/Unit/Policies/SwarmDockerPolicyTest.php
diff --git a/app/Http/Controllers/Api/CloudProviderTokensController.php b/app/Http/Controllers/Api/CloudProviderTokensController.php
index 5be82a31c..5ca7212fd 100644
--- a/app/Http/Controllers/Api/CloudProviderTokensController.php
+++ b/app/Http/Controllers/Api/CloudProviderTokensController.php
@@ -176,6 +176,7 @@ class CloudProviderTokensController extends Controller
if (is_null($token)) {
return response()->json(['message' => 'Cloud provider token not found.'], 404);
}
+ $this->authorize('view', $token);
return response()->json($this->removeSensitiveData($token));
}
@@ -242,6 +243,7 @@ class CloudProviderTokensController extends Controller
if (is_null($teamId)) {
return invalidTokenResponse();
}
+ $this->authorize('create', [CloudProviderToken::class]);
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
@@ -386,6 +388,7 @@ class CloudProviderTokensController extends Controller
if (! $token) {
return response()->json(['message' => 'Cloud provider token not found.'], 404);
}
+ $this->authorize('update', $token);
$token->update(array_intersect_key($body, array_flip($allowedFields)));
@@ -459,6 +462,7 @@ class CloudProviderTokensController extends Controller
if (! $token) {
return response()->json(['message' => 'Cloud provider token not found.'], 404);
}
+ $this->authorize('delete', $token);
if ($token->hasServers()) {
return response()->json(['message' => 'Cannot delete token that is used by servers.'], 400);
diff --git a/app/Http/Controllers/Api/GithubController.php b/app/Http/Controllers/Api/GithubController.php
index f6a6b3513..b5cabcde0 100644
--- a/app/Http/Controllers/Api/GithubController.php
+++ b/app/Http/Controllers/Api/GithubController.php
@@ -180,6 +180,7 @@ class GithubController extends Controller
if (is_null($teamId)) {
return invalidTokenResponse();
}
+ $this->authorize('create', [GithubApp::class]);
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return;
@@ -555,6 +556,7 @@ class GithubController extends Controller
$githubApp = GithubApp::where('id', $github_app_id)
->where('team_id', $teamId)
->firstOrFail();
+ $this->authorize('update', $githubApp);
// Define allowed fields for update
$allowedFields = [
@@ -721,6 +723,7 @@ class GithubController extends Controller
$githubApp = GithubApp::where('id', $github_app_id)
->where('team_id', $teamId)
->firstOrFail();
+ $this->authorize('delete', $githubApp);
// Check if the GitHub app is being used by any applications
if ($githubApp->applications->isNotEmpty()) {
diff --git a/app/Http/Controllers/Api/HetznerController.php b/app/Http/Controllers/Api/HetznerController.php
index 2645c2df1..761e951ea 100644
--- a/app/Http/Controllers/Api/HetznerController.php
+++ b/app/Http/Controllers/Api/HetznerController.php
@@ -548,6 +548,7 @@ class HetznerController extends Controller
if (is_null($teamId)) {
return invalidTokenResponse();
}
+ $this->authorize('create', [Server::class]);
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
diff --git a/app/Http/Controllers/Api/ProjectController.php b/app/Http/Controllers/Api/ProjectController.php
index da553a68c..33b28fc59 100644
--- a/app/Http/Controllers/Api/ProjectController.php
+++ b/app/Http/Controllers/Api/ProjectController.php
@@ -96,6 +96,7 @@ class ProjectController extends Controller
if (! $project) {
return response()->json(['message' => 'Project not found.'], 404);
}
+ $this->authorize('view', $project);
$project->load(['environments']);
@@ -232,6 +233,7 @@ class ProjectController extends Controller
if (is_null($teamId)) {
return invalidTokenResponse();
}
+ $this->authorize('create', [Project::class]);
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
@@ -378,6 +380,7 @@ class ProjectController extends Controller
if (! $project) {
return response()->json(['message' => 'Project not found.'], 404);
}
+ $this->authorize('update', $project);
$project->update($request->only($allowedFields));
@@ -455,6 +458,7 @@ class ProjectController extends Controller
if (! $project) {
return response()->json(['message' => 'Project not found.'], 404);
}
+ $this->authorize('delete', $project);
if (! $project->isEmpty()) {
return response()->json(['message' => 'Project has resources, so it cannot be deleted.'], 400);
}
@@ -630,6 +634,7 @@ class ProjectController extends Controller
if (! $project) {
return response()->json(['message' => 'Project not found.'], 404);
}
+ $this->authorize('update', $project);
$existingEnvironment = $project->environments()->where('name', $request->name)->first();
if ($existingEnvironment) {
@@ -717,6 +722,7 @@ class ProjectController extends Controller
if (! $environment) {
return response()->json(['message' => 'Environment not found.'], 404);
}
+ $this->authorize('delete', $environment);
if (! $environment->isEmpty()) {
return response()->json(['message' => 'Environment has resources, so it cannot be deleted.'], 400);
diff --git a/app/Http/Controllers/Api/SecurityController.php b/app/Http/Controllers/Api/SecurityController.php
index e7b36cb9a..4fe738871 100644
--- a/app/Http/Controllers/Api/SecurityController.php
+++ b/app/Http/Controllers/Api/SecurityController.php
@@ -109,6 +109,7 @@ class SecurityController extends Controller
'message' => 'Private Key not found.',
], 404);
}
+ $this->authorize('view', $key);
return response()->json($this->removeSensitiveData($key));
}
@@ -175,6 +176,7 @@ class SecurityController extends Controller
if (is_null($teamId)) {
return invalidTokenResponse();
}
+ $this->authorize('create', [PrivateKey::class]);
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return;
@@ -330,6 +332,7 @@ class SecurityController extends Controller
'message' => 'Private Key not found.',
], 404);
}
+ $this->authorize('update', $foundKey);
$foundKey->update($request->all());
return response()->json(serializeApiResponse([
@@ -406,6 +409,7 @@ class SecurityController extends Controller
if (is_null($key)) {
return response()->json(['message' => 'Private Key not found.'], 404);
}
+ $this->authorize('delete', $key);
if ($key->isInUse()) {
return response()->json([
diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php
index 29c6b854a..67f61f347 100644
--- a/app/Http/Controllers/Api/ServersController.php
+++ b/app/Http/Controllers/Api/ServersController.php
@@ -144,6 +144,7 @@ class ServersController extends Controller
if (is_null($server)) {
return response()->json(['message' => 'Server not found.'], 404);
}
+ $this->authorize('view', $server);
if ($with_resources) {
$server['resources'] = $server->definedResources()->map(function ($resource) {
$payload = [
@@ -464,6 +465,7 @@ class ServersController extends Controller
if (is_null($teamId)) {
return invalidTokenResponse();
}
+ $this->authorize('create', [ModelsServer::class]);
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
@@ -664,6 +666,7 @@ class ServersController extends Controller
if (! $server) {
return response()->json(['message' => 'Server not found.'], 404);
}
+ $this->authorize('update', $server);
if ($request->proxy_type) {
$validProxyTypes = collect(ProxyTypes::cases())->map(function ($proxyType) {
return str($proxyType->value)->lower();
@@ -757,6 +760,7 @@ class ServersController extends Controller
if (! $server) {
return response()->json(['message' => 'Server not found.'], 404);
}
+ $this->authorize('delete', $server);
if ($server->definedResources()->count() > 0) {
return response()->json(['message' => 'Server has resources, so you need to delete them before.'], 400);
}
@@ -835,6 +839,7 @@ class ServersController extends Controller
if (! $server) {
return response()->json(['message' => 'Server not found.'], 404);
}
+ $this->authorize('update', $server);
ValidateServer::dispatch($server);
return response()->json(['message' => 'Validation started.'], 201);
diff --git a/app/Http/Controllers/Api/TeamController.php b/app/Http/Controllers/Api/TeamController.php
index fd0282d96..bd0f48809 100644
--- a/app/Http/Controllers/Api/TeamController.php
+++ b/app/Http/Controllers/Api/TeamController.php
@@ -118,6 +118,7 @@ class TeamController extends Controller
if (is_null($team)) {
return response()->json(['message' => 'Team not found.'], 404);
}
+ $this->authorize('view', $team);
$team = $this->removeSensitiveData($team);
return response()->json(
@@ -176,6 +177,7 @@ class TeamController extends Controller
if (is_null($team)) {
return response()->json(['message' => 'Team not found.'], 404);
}
+ $this->authorize('view', $team);
$members = $team->members;
$members->makeHidden([
'pivot',
diff --git a/app/Livewire/Project/Application/Heading.php b/app/Livewire/Project/Application/Heading.php
index a46b2f19c..eb5b5f06c 100644
--- a/app/Livewire/Project/Application/Heading.php
+++ b/app/Livewire/Project/Application/Heading.php
@@ -65,58 +65,66 @@ class Heading extends Component
public function force_deploy_without_cache()
{
- $this->authorize('deploy', $this->application);
+ try {
+ $this->authorize('deploy', $this->application);
- $this->deploy(force_rebuild: true);
+ $this->deploy(force_rebuild: true);
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
}
public function deploy(bool $force_rebuild = false)
{
- $this->authorize('deploy', $this->application);
+ try {
+ $this->authorize('deploy', $this->application);
- if ($this->application->build_pack === 'dockercompose' && is_null($this->application->docker_compose_raw)) {
- $this->dispatch('error', 'Failed to deploy', 'Please load a Compose file first.');
+ if ($this->application->build_pack === 'dockercompose' && is_null($this->application->docker_compose_raw)) {
+ $this->dispatch('error', 'Failed to deploy', 'Please load a Compose file first.');
- return;
+ return;
+ }
+ if ($this->application->destination->server->isSwarm() && str($this->application->docker_registry_image_name)->isEmpty()) {
+ $this->dispatch('error', 'Failed to deploy.', 'To deploy to a Swarm cluster you must set a Docker image name first.');
+
+ return;
+ }
+ if (data_get($this->application, 'settings.is_build_server_enabled') && str($this->application->docker_registry_image_name)->isEmpty()) {
+ $this->dispatch('error', 'Failed to deploy.', 'To use a build server, you must first set a Docker image. More information here: documentation ');
+
+ return;
+ }
+ if ($this->application->additional_servers->count() > 0 && str($this->application->docker_registry_image_name)->isEmpty()) {
+ $this->dispatch('error', 'Failed to deploy.', 'Before deploying to multiple servers, you must first set a Docker image in the General tab. More information here: documentation ');
+
+ return;
+ }
+ $this->setDeploymentUuid();
+ $result = queue_application_deployment(
+ application: $this->application,
+ deployment_uuid: $this->deploymentUuid,
+ force_rebuild: $force_rebuild,
+ );
+ if ($result['status'] === 'queue_full') {
+ $this->dispatch('error', 'Deployment queue full', $result['message']);
+
+ return;
+ }
+ if ($result['status'] === 'skipped') {
+ $this->dispatch('error', 'Deployment skipped', $result['message']);
+
+ return;
+ }
+
+ return $this->redirectRoute('project.application.deployment.show', [
+ 'project_uuid' => $this->parameters['project_uuid'],
+ 'application_uuid' => $this->parameters['application_uuid'],
+ 'deployment_uuid' => $this->deploymentUuid,
+ 'environment_uuid' => $this->parameters['environment_uuid'],
+ ], navigate: false);
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
}
- if ($this->application->destination->server->isSwarm() && str($this->application->docker_registry_image_name)->isEmpty()) {
- $this->dispatch('error', 'Failed to deploy.', 'To deploy to a Swarm cluster you must set a Docker image name first.');
-
- return;
- }
- if (data_get($this->application, 'settings.is_build_server_enabled') && str($this->application->docker_registry_image_name)->isEmpty()) {
- $this->dispatch('error', 'Failed to deploy.', 'To use a build server, you must first set a Docker image. More information here: documentation ');
-
- return;
- }
- if ($this->application->additional_servers->count() > 0 && str($this->application->docker_registry_image_name)->isEmpty()) {
- $this->dispatch('error', 'Failed to deploy.', 'Before deploying to multiple servers, you must first set a Docker image in the General tab. More information here: documentation ');
-
- return;
- }
- $this->setDeploymentUuid();
- $result = queue_application_deployment(
- application: $this->application,
- deployment_uuid: $this->deploymentUuid,
- force_rebuild: $force_rebuild,
- );
- if ($result['status'] === 'queue_full') {
- $this->dispatch('error', 'Deployment queue full', $result['message']);
-
- return;
- }
- if ($result['status'] === 'skipped') {
- $this->dispatch('error', 'Deployment skipped', $result['message']);
-
- return;
- }
-
- return $this->redirectRoute('project.application.deployment.show', [
- 'project_uuid' => $this->parameters['project_uuid'],
- 'application_uuid' => $this->parameters['application_uuid'],
- 'deployment_uuid' => $this->deploymentUuid,
- 'environment_uuid' => $this->parameters['environment_uuid'],
- ], navigate: false);
}
protected function setDeploymentUuid()
@@ -127,45 +135,53 @@ class Heading extends Component
public function stop()
{
- $this->authorize('deploy', $this->application);
+ try {
+ $this->authorize('deploy', $this->application);
- $this->dispatch('info', 'Gracefully stopping application. It could take a while depending on the application.');
- StopApplication::dispatch($this->application, false, $this->docker_cleanup);
+ $this->dispatch('info', 'Gracefully stopping application. It could take a while depending on the application.');
+ StopApplication::dispatch($this->application, false, $this->docker_cleanup);
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
}
public function restart()
{
- $this->authorize('deploy', $this->application);
+ try {
+ $this->authorize('deploy', $this->application);
- if ($this->application->additional_servers->count() > 0 && str($this->application->docker_registry_image_name)->isEmpty()) {
- $this->dispatch('error', 'Failed to deploy', 'Before deploying to multiple servers, you must first set a Docker image in the General tab. More information here: documentation ');
+ if ($this->application->additional_servers->count() > 0 && str($this->application->docker_registry_image_name)->isEmpty()) {
+ $this->dispatch('error', 'Failed to deploy', 'Before deploying to multiple servers, you must first set a Docker image in the General tab. More information here: documentation ');
- return;
+ return;
+ }
+
+ $this->setDeploymentUuid();
+ $result = queue_application_deployment(
+ application: $this->application,
+ deployment_uuid: $this->deploymentUuid,
+ restart_only: true,
+ );
+ if ($result['status'] === 'queue_full') {
+ $this->dispatch('error', 'Deployment queue full', $result['message']);
+
+ return;
+ }
+ if ($result['status'] === 'skipped') {
+ $this->dispatch('success', 'Deployment skipped', $result['message']);
+
+ return;
+ }
+
+ return $this->redirectRoute('project.application.deployment.show', [
+ 'project_uuid' => $this->parameters['project_uuid'],
+ 'application_uuid' => $this->parameters['application_uuid'],
+ 'deployment_uuid' => $this->deploymentUuid,
+ 'environment_uuid' => $this->parameters['environment_uuid'],
+ ], navigate: false);
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
}
-
- $this->setDeploymentUuid();
- $result = queue_application_deployment(
- application: $this->application,
- deployment_uuid: $this->deploymentUuid,
- restart_only: true,
- );
- if ($result['status'] === 'queue_full') {
- $this->dispatch('error', 'Deployment queue full', $result['message']);
-
- return;
- }
- if ($result['status'] === 'skipped') {
- $this->dispatch('success', 'Deployment skipped', $result['message']);
-
- return;
- }
-
- return $this->redirectRoute('project.application.deployment.show', [
- 'project_uuid' => $this->parameters['project_uuid'],
- 'application_uuid' => $this->parameters['application_uuid'],
- 'deployment_uuid' => $this->deploymentUuid,
- 'environment_uuid' => $this->parameters['environment_uuid'],
- ], navigate: false);
}
public function render()
diff --git a/app/Livewire/Project/Application/Previews.php b/app/Livewire/Project/Application/Previews.php
index 41f352c14..50acf76b2 100644
--- a/app/Livewire/Project/Application/Previews.php
+++ b/app/Livewire/Project/Application/Previews.php
@@ -215,24 +215,31 @@ class Previews extends Component
public function force_deploy_without_cache(int $pull_request_id, ?string $pull_request_html_url = null)
{
- $this->authorize('deploy', $this->application);
+ try {
+ $this->authorize('deploy', $this->application);
- $this->deploy($pull_request_id, $pull_request_html_url, force_rebuild: true);
+ $this->deploy($pull_request_id, $pull_request_html_url, force_rebuild: true);
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
}
public function add_and_deploy(int $pull_request_id, ?string $pull_request_html_url = null)
{
- $this->authorize('deploy', $this->application);
+ try {
+ $this->authorize('deploy', $this->application);
- $this->add($pull_request_id, $pull_request_html_url);
- $this->deploy($pull_request_id, $pull_request_html_url);
+ $this->add($pull_request_id, $pull_request_html_url);
+ $this->deploy($pull_request_id, $pull_request_html_url);
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
}
public function deploy(int $pull_request_id, ?string $pull_request_html_url = null, bool $force_rebuild = false)
{
- $this->authorize('deploy', $this->application);
-
try {
+ $this->authorize('deploy', $this->application);
$this->setDeploymentUuid();
$found = ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', $pull_request_id)->first();
if (! $found && ! is_null($pull_request_html_url)) {
@@ -291,9 +298,8 @@ class Previews extends Component
public function stop(int $pull_request_id)
{
- $this->authorize('deploy', $this->application);
-
try {
+ $this->authorize('deploy', $this->application);
$server = $this->application->destination->server;
if ($this->application->destination->server->isSwarm()) {
diff --git a/app/Livewire/Project/Database/BackupNow.php b/app/Livewire/Project/Database/BackupNow.php
index decd59a4c..e4ed2a366 100644
--- a/app/Livewire/Project/Database/BackupNow.php
+++ b/app/Livewire/Project/Database/BackupNow.php
@@ -14,9 +14,13 @@ class BackupNow extends Component
public function backupNow()
{
- $this->authorize('manageBackups', $this->backup->database);
+ try {
+ $this->authorize('manageBackups', $this->backup->database);
- DatabaseBackupJob::dispatch($this->backup);
- $this->dispatch('success', 'Backup queued. It will be available in a few minutes.');
+ DatabaseBackupJob::dispatch($this->backup);
+ $this->dispatch('success', 'Backup queued. It will be available in a few minutes.');
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
}
}
diff --git a/app/Livewire/Project/Database/Heading.php b/app/Livewire/Project/Database/Heading.php
index 8d3d8e294..ef2163f14 100644
--- a/app/Livewire/Project/Database/Heading.php
+++ b/app/Livewire/Project/Database/Heading.php
@@ -86,18 +86,26 @@ class Heading extends Component
public function restart()
{
- $this->authorize('manage', $this->database);
+ try {
+ $this->authorize('manage', $this->database);
- $activity = RestartDatabase::run($this->database);
- $this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class);
+ $activity = RestartDatabase::run($this->database);
+ $this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class);
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
}
public function start()
{
- $this->authorize('manage', $this->database);
+ try {
+ $this->authorize('manage', $this->database);
- $activity = StartDatabase::run($this->database);
- $this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class);
+ $activity = StartDatabase::run($this->database);
+ $this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class);
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
}
public function render()
diff --git a/app/Livewire/Project/Database/ScheduledBackups.php b/app/Livewire/Project/Database/ScheduledBackups.php
index 1cf5e53f6..06473c56a 100644
--- a/app/Livewire/Project/Database/ScheduledBackups.php
+++ b/app/Livewire/Project/Database/ScheduledBackups.php
@@ -56,22 +56,30 @@ class ScheduledBackups extends Component
public function setCustomType()
{
- $this->authorize('update', $this->database);
+ try {
+ $this->authorize('update', $this->database);
- $this->database->custom_type = $this->custom_type;
- $this->database->save();
- $this->dispatch('success', 'Database type set.');
- $this->refreshScheduledBackups();
+ $this->database->custom_type = $this->custom_type;
+ $this->database->save();
+ $this->dispatch('success', 'Database type set.');
+ $this->refreshScheduledBackups();
+ } catch (\Throwable $e) {
+ handleError($e, $this);
+ }
}
public function delete($scheduled_backup_id): void
{
- $backup = $this->database->scheduledBackups->find($scheduled_backup_id);
- $this->authorize('manageBackups', $this->database);
+ try {
+ $this->authorize('manageBackups', $this->database);
- $backup->delete();
- $this->dispatch('success', 'Scheduled backup deleted.');
- $this->refreshScheduledBackups();
+ $backup = $this->database->scheduledBackups->find($scheduled_backup_id);
+ $backup->delete();
+ $this->dispatch('success', 'Scheduled backup deleted.');
+ $this->refreshScheduledBackups();
+ } catch (\Throwable $e) {
+ handleError($e, $this);
+ }
}
public function refreshScheduledBackups(?int $id = null): void
diff --git a/app/Livewire/Project/DeleteEnvironment.php b/app/Livewire/Project/DeleteEnvironment.php
index aa6e95975..28027ce5a 100644
--- a/app/Livewire/Project/DeleteEnvironment.php
+++ b/app/Livewire/Project/DeleteEnvironment.php
@@ -30,18 +30,22 @@ class DeleteEnvironment extends Component
public function delete()
{
- $this->validate([
- 'environment_id' => 'required|int',
- ]);
- $environment = Environment::findOrFail($this->environment_id);
- $this->authorize('delete', $environment);
+ try {
+ $this->validate([
+ 'environment_id' => 'required|int',
+ ]);
+ $environment = Environment::findOrFail($this->environment_id);
+ $this->authorize('delete', $environment);
- if ($environment->isEmpty()) {
- $environment->delete();
+ if ($environment->isEmpty()) {
+ $environment->delete();
- return redirectRoute($this, 'project.show', ['project_uuid' => $this->parameters['project_uuid']]);
+ return redirectRoute($this, 'project.show', ['project_uuid' => $this->parameters['project_uuid']]);
+ }
+
+ return $this->dispatch('error', "Environment {$environment->name} has defined resources, please delete them first.");
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
}
-
- return $this->dispatch('error', "Environment {$environment->name} has defined resources, please delete them first.");
}
}
diff --git a/app/Livewire/Project/DeleteProject.php b/app/Livewire/Project/DeleteProject.php
index a018046fd..0b99f57a4 100644
--- a/app/Livewire/Project/DeleteProject.php
+++ b/app/Livewire/Project/DeleteProject.php
@@ -26,18 +26,22 @@ class DeleteProject extends Component
public function delete()
{
- $this->validate([
- 'project_id' => 'required|int',
- ]);
- $project = Project::findOrFail($this->project_id);
- $this->authorize('delete', $project);
+ try {
+ $this->validate([
+ 'project_id' => 'required|int',
+ ]);
+ $project = Project::findOrFail($this->project_id);
+ $this->authorize('delete', $project);
- if ($project->isEmpty()) {
- $project->delete();
+ if ($project->isEmpty()) {
+ $project->delete();
- return redirectRoute($this, 'project.index');
+ return redirectRoute($this, 'project.index');
+ }
+
+ return $this->dispatch('error', "Project {$project->name} has resources defined, please delete them first.");
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
}
-
- return $this->dispatch('error', "Project {$project->name} has resources defined, please delete them first.");
}
}
diff --git a/app/Livewire/Project/Service/Heading.php b/app/Livewire/Project/Service/Heading.php
index c8a08d8f9..adc2b151b 100644
--- a/app/Livewire/Project/Service/Heading.php
+++ b/app/Livewire/Project/Service/Heading.php
@@ -7,12 +7,15 @@ use App\Actions\Service\StartService;
use App\Actions\Service\StopService;
use App\Enums\ProcessStatus;
use App\Models\Service;
+use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
use Spatie\Activitylog\Models\Activity;
class Heading extends Component
{
+ use AuthorizesRequests;
+
public Service $service;
public array $parameters;
@@ -99,13 +102,19 @@ class Heading extends Component
public function start()
{
- $activity = StartService::run($this->service, pullLatestImages: true);
- $this->dispatch('activityMonitor', $activity->id);
+ try {
+ $this->authorize('deploy', $this->service);
+ $activity = StartService::run($this->service, pullLatestImages: true);
+ $this->dispatch('activityMonitor', $activity->id);
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
}
public function forceDeploy()
{
try {
+ $this->authorize('deploy', $this->service);
$activities = Activity::where('properties->type_uuid', $this->service->uuid)
->where(function ($q) {
$q->where('properties->status', ProcessStatus::IN_PROGRESS->value)
@@ -117,42 +126,53 @@ class Heading extends Component
}
$activity = StartService::run($this->service, pullLatestImages: true, stopBeforeStart: true);
$this->dispatch('activityMonitor', $activity->id);
- } catch (\Exception $e) {
- $this->dispatch('error', $e->getMessage());
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
}
}
public function stop()
{
try {
+ $this->authorize('stop', $this->service);
StopService::dispatch($this->service, false, $this->docker_cleanup);
- } catch (\Exception $e) {
- $this->dispatch('error', $e->getMessage());
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
}
}
public function restart()
{
- $this->checkDeployments();
- if ($this->isDeploymentProgress) {
- $this->dispatch('error', 'There is a deployment in progress.');
+ try {
+ $this->authorize('deploy', $this->service);
+ $this->checkDeployments();
+ if ($this->isDeploymentProgress) {
+ $this->dispatch('error', 'There is a deployment in progress.');
- return;
+ return;
+ }
+ $activity = StartService::run($this->service, stopBeforeStart: true);
+ $this->dispatch('activityMonitor', $activity->id);
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
}
- $activity = StartService::run($this->service, stopBeforeStart: true);
- $this->dispatch('activityMonitor', $activity->id);
}
public function pullAndRestartEvent()
{
- $this->checkDeployments();
- if ($this->isDeploymentProgress) {
- $this->dispatch('error', 'There is a deployment in progress.');
+ try {
+ $this->authorize('deploy', $this->service);
+ $this->checkDeployments();
+ if ($this->isDeploymentProgress) {
+ $this->dispatch('error', 'There is a deployment in progress.');
- return;
+ return;
+ }
+ $activity = StartService::run($this->service, pullLatestImages: true, stopBeforeStart: true);
+ $this->dispatch('activityMonitor', $activity->id);
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
}
- $activity = StartService::run($this->service, pullLatestImages: true, stopBeforeStart: true);
- $this->dispatch('activityMonitor', $activity->id);
}
public function render()
diff --git a/app/Livewire/Project/Shared/Destination.php b/app/Livewire/Project/Shared/Destination.php
index 7ab81b7d1..1eb1dc580 100644
--- a/app/Livewire/Project/Shared/Destination.php
+++ b/app/Livewire/Project/Shared/Destination.php
@@ -7,12 +7,15 @@ use App\Actions\Docker\GetContainersStatus;
use App\Events\ApplicationStatusChanged;
use App\Models\Server;
use App\Models\StandaloneDocker;
+use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Livewire\Component;
use Visus\Cuid2\Cuid2;
class Destination extends Component
{
+ use AuthorizesRequests;
+
public $resource;
public Collection $networks;
@@ -59,6 +62,7 @@ class Destination extends Component
public function stop($serverId)
{
try {
+ $this->authorize('deploy', $this->resource);
$server = Server::ownedByCurrentTeam()->findOrFail($serverId);
StopApplicationOneServer::run($this->resource, $server);
$this->refreshServers();
@@ -70,6 +74,7 @@ class Destination extends Component
public function redeploy(int $network_id, int $server_id)
{
try {
+ $this->authorize('deploy', $this->resource);
if ($this->resource->additional_servers->count() > 0 && str($this->resource->docker_registry_image_name)->isEmpty()) {
$this->dispatch('error', 'Failed to deploy.', 'Before deploying to multiple servers, you must first set a Docker image in the General tab. More information here: documentation ');
@@ -110,15 +115,20 @@ class Destination extends Component
public function promote(int $network_id, int $server_id)
{
- $main_destination = $this->resource->destination;
- $this->resource->update([
- 'destination_id' => $network_id,
- 'destination_type' => StandaloneDocker::class,
- ]);
- $this->resource->additional_networks()->detach($network_id, ['server_id' => $server_id]);
- $this->resource->additional_networks()->attach($main_destination->id, ['server_id' => $main_destination->server->id]);
- $this->refreshServers();
- $this->resource->refresh();
+ try {
+ $this->authorize('update', $this->resource);
+ $main_destination = $this->resource->destination;
+ $this->resource->update([
+ 'destination_id' => $network_id,
+ 'destination_type' => StandaloneDocker::class,
+ ]);
+ $this->resource->additional_networks()->detach($network_id, ['server_id' => $server_id]);
+ $this->resource->additional_networks()->attach($main_destination->id, ['server_id' => $main_destination->server->id]);
+ $this->refreshServers();
+ $this->resource->refresh();
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
}
public function refreshServers()
@@ -130,13 +140,19 @@ class Destination extends Component
public function addServer(int $network_id, int $server_id)
{
- $this->resource->additional_networks()->attach($network_id, ['server_id' => $server_id]);
- $this->dispatch('refresh');
+ try {
+ $this->authorize('update', $this->resource);
+ $this->resource->additional_networks()->attach($network_id, ['server_id' => $server_id]);
+ $this->dispatch('refresh');
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
}
public function removeServer(int $network_id, int $server_id, $password)
{
try {
+ $this->authorize('update', $this->resource);
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
diff --git a/app/Livewire/Project/Shared/HealthChecks.php b/app/Livewire/Project/Shared/HealthChecks.php
index df2de5142..0a47034fd 100644
--- a/app/Livewire/Project/Shared/HealthChecks.php
+++ b/app/Livewire/Project/Shared/HealthChecks.php
@@ -70,8 +70,12 @@ class HealthChecks extends Component
public function mount()
{
- $this->authorize('view', $this->resource);
- $this->syncData();
+ try {
+ $this->authorize('view', $this->resource);
+ $this->syncData();
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
}
public function syncData(bool $toModel = false): void
diff --git a/app/Livewire/Project/Shared/ResourceOperations.php b/app/Livewire/Project/Shared/ResourceOperations.php
index e769e4bcb..25545c4b0 100644
--- a/app/Livewire/Project/Shared/ResourceOperations.php
+++ b/app/Livewire/Project/Shared/ResourceOperations.php
@@ -47,224 +47,87 @@ class ResourceOperations extends Component
public function cloneTo($destination_id)
{
- $this->authorize('update', $this->resource);
+ try {
+ $this->authorize('update', $this->resource);
- $teamScope = fn ($q) => $q->where('team_id', currentTeam()->id);
- $new_destination = StandaloneDocker::whereHas('server', $teamScope)->find($destination_id);
- if (! $new_destination) {
- $new_destination = SwarmDocker::whereHas('server', $teamScope)->find($destination_id);
- }
- if (! $new_destination) {
- return $this->addError('destination_id', 'Destination not found.');
- }
- $uuid = (string) new Cuid2;
- $server = $new_destination->server;
-
- if ($this->resource->getMorphClass() === \App\Models\Application::class) {
- $new_resource = clone_application($this->resource, $new_destination, ['uuid' => $uuid], $this->cloneVolumeData);
-
- $route = route('project.application.configuration', [
- 'project_uuid' => $this->projectUuid,
- 'environment_uuid' => $this->environmentUuid,
- 'application_uuid' => $new_resource->uuid,
- ]).'#resource-operations';
-
- return redirect()->to($route);
- } elseif (
- $this->resource->getMorphClass() === \App\Models\StandalonePostgresql::class ||
- $this->resource->getMorphClass() === \App\Models\StandaloneMongodb::class ||
- $this->resource->getMorphClass() === \App\Models\StandaloneMysql::class ||
- $this->resource->getMorphClass() === \App\Models\StandaloneMariadb::class ||
- $this->resource->getMorphClass() === \App\Models\StandaloneRedis::class ||
- $this->resource->getMorphClass() === \App\Models\StandaloneKeydb::class ||
- $this->resource->getMorphClass() === \App\Models\StandaloneDragonfly::class ||
- $this->resource->getMorphClass() === \App\Models\StandaloneClickhouse::class
- ) {
+ $teamScope = fn ($q) => $q->where('team_id', currentTeam()->id);
+ $new_destination = StandaloneDocker::whereHas('server', $teamScope)->find($destination_id);
+ if (! $new_destination) {
+ $new_destination = SwarmDocker::whereHas('server', $teamScope)->find($destination_id);
+ }
+ if (! $new_destination) {
+ return $this->addError('destination_id', 'Destination not found.');
+ }
$uuid = (string) new Cuid2;
- $new_resource = $this->resource->replicate([
- 'id',
- 'created_at',
- 'updated_at',
- ])->fill([
- 'uuid' => $uuid,
- 'name' => $this->resource->name.'-clone-'.$uuid,
- 'status' => 'exited',
- 'started_at' => null,
- 'destination_id' => $new_destination->id,
- ]);
- $new_resource->save();
+ $server = $new_destination->server;
- $tags = $this->resource->tags;
- foreach ($tags as $tag) {
- $new_resource->tags()->attach($tag->id);
- }
+ if ($this->resource->getMorphClass() === \App\Models\Application::class) {
+ $new_resource = clone_application($this->resource, $new_destination, ['uuid' => $uuid], $this->cloneVolumeData);
- $new_resource->persistentStorages()->delete();
- $persistentVolumes = $this->resource->persistentStorages()->get();
- foreach ($persistentVolumes as $volume) {
- $originalName = $volume->name;
- $newName = '';
+ $route = route('project.application.configuration', [
+ 'project_uuid' => $this->projectUuid,
+ 'environment_uuid' => $this->environmentUuid,
+ 'application_uuid' => $new_resource->uuid,
+ ]).'#resource-operations';
- if (str_starts_with($originalName, 'postgres-data-')) {
- $newName = 'postgres-data-'.$new_resource->uuid;
- } elseif (str_starts_with($originalName, 'mysql-data-')) {
- $newName = 'mysql-data-'.$new_resource->uuid;
- } elseif (str_starts_with($originalName, 'redis-data-')) {
- $newName = 'redis-data-'.$new_resource->uuid;
- } elseif (str_starts_with($originalName, 'clickhouse-data-')) {
- $newName = 'clickhouse-data-'.$new_resource->uuid;
- } elseif (str_starts_with($originalName, 'mariadb-data-')) {
- $newName = 'mariadb-data-'.$new_resource->uuid;
- } elseif (str_starts_with($originalName, 'mongodb-data-')) {
- $newName = 'mongodb-data-'.$new_resource->uuid;
- } elseif (str_starts_with($originalName, 'keydb-data-')) {
- $newName = 'keydb-data-'.$new_resource->uuid;
- } elseif (str_starts_with($originalName, 'dragonfly-data-')) {
- $newName = 'dragonfly-data-'.$new_resource->uuid;
- } else {
- if (str_starts_with($volume->name, $this->resource->uuid)) {
- $newName = str($volume->name)->replace($this->resource->uuid, $new_resource->uuid);
- } else {
- $newName = $new_resource->uuid.'-'.$volume->name;
- }
- }
-
- $newPersistentVolume = $volume->replicate([
- 'id',
- 'created_at',
- 'updated_at',
- ])->fill([
- 'name' => $newName,
- 'resource_id' => $new_resource->id,
- ]);
- $newPersistentVolume->save();
-
- if ($this->cloneVolumeData) {
- try {
- StopDatabase::dispatch($this->resource);
- $sourceVolume = $volume->name;
- $targetVolume = $newPersistentVolume->name;
- $sourceServer = $this->resource->destination->server;
- $targetServer = $new_resource->destination->server;
-
- VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume);
-
- StartDatabase::dispatch($this->resource);
- } catch (\Exception $e) {
- \Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage());
- }
- }
- }
-
- $fileStorages = $this->resource->fileStorages()->get();
- foreach ($fileStorages as $storage) {
- $newStorage = $storage->replicate([
- 'id',
- 'created_at',
- 'updated_at',
- ])->fill([
- 'resource_id' => $new_resource->id,
- ]);
- $newStorage->save();
- }
-
- $scheduledBackups = $this->resource->scheduledBackups()->get();
- foreach ($scheduledBackups as $backup) {
+ return redirect()->to($route);
+ } elseif (
+ $this->resource->getMorphClass() === \App\Models\StandalonePostgresql::class ||
+ $this->resource->getMorphClass() === \App\Models\StandaloneMongodb::class ||
+ $this->resource->getMorphClass() === \App\Models\StandaloneMysql::class ||
+ $this->resource->getMorphClass() === \App\Models\StandaloneMariadb::class ||
+ $this->resource->getMorphClass() === \App\Models\StandaloneRedis::class ||
+ $this->resource->getMorphClass() === \App\Models\StandaloneKeydb::class ||
+ $this->resource->getMorphClass() === \App\Models\StandaloneDragonfly::class ||
+ $this->resource->getMorphClass() === \App\Models\StandaloneClickhouse::class
+ ) {
$uuid = (string) new Cuid2;
- $newBackup = $backup->replicate([
+ $new_resource = $this->resource->replicate([
'id',
'created_at',
'updated_at',
])->fill([
'uuid' => $uuid,
- 'database_id' => $new_resource->id,
- 'database_type' => $new_resource->getMorphClass(),
- 'team_id' => currentTeam()->id,
- ]);
- $newBackup->save();
- }
-
- $environmentVaribles = $this->resource->environment_variables()->get();
- foreach ($environmentVaribles as $environmentVarible) {
- $payload = [
- 'resourceable_id' => $new_resource->id,
- 'resourceable_type' => $new_resource->getMorphClass(),
- ];
- $newEnvironmentVariable = $environmentVarible->replicate([
- 'id',
- 'created_at',
- 'updated_at',
- ])->fill($payload);
- $newEnvironmentVariable->save();
- }
-
- $route = route('project.database.configuration', [
- 'project_uuid' => $this->projectUuid,
- 'environment_uuid' => $this->environmentUuid,
- 'database_uuid' => $new_resource->uuid,
- ]).'#resource-operations';
-
- return redirect()->to($route);
- } elseif ($this->resource->type() === 'service') {
- $uuid = (string) new Cuid2;
- $new_resource = $this->resource->replicate([
- 'id',
- 'created_at',
- 'updated_at',
- ])->fill([
- 'uuid' => $uuid,
- 'name' => $this->resource->name.'-clone-'.$uuid,
- 'destination_id' => $new_destination->id,
- 'destination_type' => $new_destination->getMorphClass(),
- 'server_id' => $new_destination->server_id, // server_id is probably not needed anymore because of the new polymorphic relationships (here it is needed for clone to a different server to work - but maybe we can drop the column)
- ]);
-
- $new_resource->save();
-
- $tags = $this->resource->tags;
- foreach ($tags as $tag) {
- $new_resource->tags()->attach($tag->id);
- }
-
- $scheduledTasks = $this->resource->scheduled_tasks()->get();
- foreach ($scheduledTasks as $task) {
- $newTask = $task->replicate([
- 'id',
- 'created_at',
- 'updated_at',
- ])->fill([
- 'uuid' => (string) new Cuid2,
- 'service_id' => $new_resource->id,
- 'team_id' => currentTeam()->id,
- ]);
- $newTask->save();
- }
-
- $environmentVariables = $this->resource->environment_variables()->get();
- foreach ($environmentVariables as $environmentVariable) {
- $newEnvironmentVariable = $environmentVariable->replicate([
- 'id',
- 'created_at',
- 'updated_at',
- ])->fill([
- 'resourceable_id' => $new_resource->id,
- 'resourceable_type' => $new_resource->getMorphClass(),
- ]);
- $newEnvironmentVariable->save();
- }
-
- foreach ($new_resource->applications() as $application) {
- $application->update([
+ 'name' => $this->resource->name.'-clone-'.$uuid,
'status' => 'exited',
+ 'started_at' => null,
+ 'destination_id' => $new_destination->id,
]);
+ $new_resource->save();
- $persistentVolumes = $application->persistentStorages()->get();
+ $tags = $this->resource->tags;
+ foreach ($tags as $tag) {
+ $new_resource->tags()->attach($tag->id);
+ }
+
+ $new_resource->persistentStorages()->delete();
+ $persistentVolumes = $this->resource->persistentStorages()->get();
foreach ($persistentVolumes as $volume) {
+ $originalName = $volume->name;
$newName = '';
- if (str_starts_with($volume->name, $volume->resource->uuid)) {
- $newName = str($volume->name)->replace($volume->resource->uuid, $application->uuid);
+
+ if (str_starts_with($originalName, 'postgres-data-')) {
+ $newName = 'postgres-data-'.$new_resource->uuid;
+ } elseif (str_starts_with($originalName, 'mysql-data-')) {
+ $newName = 'mysql-data-'.$new_resource->uuid;
+ } elseif (str_starts_with($originalName, 'redis-data-')) {
+ $newName = 'redis-data-'.$new_resource->uuid;
+ } elseif (str_starts_with($originalName, 'clickhouse-data-')) {
+ $newName = 'clickhouse-data-'.$new_resource->uuid;
+ } elseif (str_starts_with($originalName, 'mariadb-data-')) {
+ $newName = 'mariadb-data-'.$new_resource->uuid;
+ } elseif (str_starts_with($originalName, 'mongodb-data-')) {
+ $newName = 'mongodb-data-'.$new_resource->uuid;
+ } elseif (str_starts_with($originalName, 'keydb-data-')) {
+ $newName = 'keydb-data-'.$new_resource->uuid;
+ } elseif (str_starts_with($originalName, 'dragonfly-data-')) {
+ $newName = 'dragonfly-data-'.$new_resource->uuid;
} else {
- $newName = $application->uuid.'-'.str($volume->name)->afterLast('-');
+ if (str_starts_with($volume->name, $this->resource->uuid)) {
+ $newName = str($volume->name)->replace($this->resource->uuid, $new_resource->uuid);
+ } else {
+ $newName = $new_resource->uuid.'-'.$volume->name;
+ }
}
$newPersistentVolume = $volume->replicate([
@@ -273,79 +136,220 @@ class ResourceOperations extends Component
'updated_at',
])->fill([
'name' => $newName,
- 'resource_id' => $application->id,
+ 'resource_id' => $new_resource->id,
]);
$newPersistentVolume->save();
if ($this->cloneVolumeData) {
try {
- StopService::dispatch($application);
+ StopDatabase::dispatch($this->resource);
$sourceVolume = $volume->name;
$targetVolume = $newPersistentVolume->name;
- $sourceServer = $application->service->destination->server;
+ $sourceServer = $this->resource->destination->server;
$targetServer = $new_resource->destination->server;
VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume);
- StartService::dispatch($application);
+ StartDatabase::dispatch($this->resource);
} catch (\Exception $e) {
\Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage());
}
}
}
- }
- foreach ($new_resource->databases() as $database) {
- $database->update([
- 'status' => 'exited',
- ]);
-
- $persistentVolumes = $database->persistentStorages()->get();
- foreach ($persistentVolumes as $volume) {
- $newName = '';
- if (str_starts_with($volume->name, $volume->resource->uuid)) {
- $newName = str($volume->name)->replace($volume->resource->uuid, $database->uuid);
- } else {
- $newName = $database->uuid.'-'.str($volume->name)->afterLast('-');
- }
-
- $newPersistentVolume = $volume->replicate([
+ $fileStorages = $this->resource->fileStorages()->get();
+ foreach ($fileStorages as $storage) {
+ $newStorage = $storage->replicate([
'id',
'created_at',
'updated_at',
])->fill([
- 'name' => $newName,
- 'resource_id' => $database->id,
+ 'resource_id' => $new_resource->id,
]);
- $newPersistentVolume->save();
+ $newStorage->save();
+ }
- if ($this->cloneVolumeData) {
- try {
- StopService::dispatch($database->service);
- $sourceVolume = $volume->name;
- $targetVolume = $newPersistentVolume->name;
- $sourceServer = $database->service->destination->server;
- $targetServer = $new_resource->destination->server;
+ $scheduledBackups = $this->resource->scheduledBackups()->get();
+ foreach ($scheduledBackups as $backup) {
+ $uuid = (string) new Cuid2;
+ $newBackup = $backup->replicate([
+ 'id',
+ 'created_at',
+ 'updated_at',
+ ])->fill([
+ 'uuid' => $uuid,
+ 'database_id' => $new_resource->id,
+ 'database_type' => $new_resource->getMorphClass(),
+ 'team_id' => currentTeam()->id,
+ ]);
+ $newBackup->save();
+ }
- VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume);
+ $environmentVaribles = $this->resource->environment_variables()->get();
+ foreach ($environmentVaribles as $environmentVarible) {
+ $payload = [
+ 'resourceable_id' => $new_resource->id,
+ 'resourceable_type' => $new_resource->getMorphClass(),
+ ];
+ $newEnvironmentVariable = $environmentVarible->replicate([
+ 'id',
+ 'created_at',
+ 'updated_at',
+ ])->fill($payload);
+ $newEnvironmentVariable->save();
+ }
- StartService::dispatch($database->service);
- } catch (\Exception $e) {
- \Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage());
+ $route = route('project.database.configuration', [
+ 'project_uuid' => $this->projectUuid,
+ 'environment_uuid' => $this->environmentUuid,
+ 'database_uuid' => $new_resource->uuid,
+ ]).'#resource-operations';
+
+ return redirect()->to($route);
+ } elseif ($this->resource->type() === 'service') {
+ $uuid = (string) new Cuid2;
+ $new_resource = $this->resource->replicate([
+ 'id',
+ 'created_at',
+ 'updated_at',
+ ])->fill([
+ 'uuid' => $uuid,
+ 'name' => $this->resource->name.'-clone-'.$uuid,
+ 'destination_id' => $new_destination->id,
+ 'destination_type' => $new_destination->getMorphClass(),
+ 'server_id' => $new_destination->server_id, // server_id is probably not needed anymore because of the new polymorphic relationships (here it is needed for clone to a different server to work - but maybe we can drop the column)
+ ]);
+
+ $new_resource->save();
+
+ $tags = $this->resource->tags;
+ foreach ($tags as $tag) {
+ $new_resource->tags()->attach($tag->id);
+ }
+
+ $scheduledTasks = $this->resource->scheduled_tasks()->get();
+ foreach ($scheduledTasks as $task) {
+ $newTask = $task->replicate([
+ 'id',
+ 'created_at',
+ 'updated_at',
+ ])->fill([
+ 'uuid' => (string) new Cuid2,
+ 'service_id' => $new_resource->id,
+ 'team_id' => currentTeam()->id,
+ ]);
+ $newTask->save();
+ }
+
+ $environmentVariables = $this->resource->environment_variables()->get();
+ foreach ($environmentVariables as $environmentVariable) {
+ $newEnvironmentVariable = $environmentVariable->replicate([
+ 'id',
+ 'created_at',
+ 'updated_at',
+ ])->fill([
+ 'resourceable_id' => $new_resource->id,
+ 'resourceable_type' => $new_resource->getMorphClass(),
+ ]);
+ $newEnvironmentVariable->save();
+ }
+
+ foreach ($new_resource->applications() as $application) {
+ $application->update([
+ 'status' => 'exited',
+ ]);
+
+ $persistentVolumes = $application->persistentStorages()->get();
+ foreach ($persistentVolumes as $volume) {
+ $newName = '';
+ if (str_starts_with($volume->name, $volume->resource->uuid)) {
+ $newName = str($volume->name)->replace($volume->resource->uuid, $application->uuid);
+ } else {
+ $newName = $application->uuid.'-'.str($volume->name)->afterLast('-');
+ }
+
+ $newPersistentVolume = $volume->replicate([
+ 'id',
+ 'created_at',
+ 'updated_at',
+ ])->fill([
+ 'name' => $newName,
+ 'resource_id' => $application->id,
+ ]);
+ $newPersistentVolume->save();
+
+ if ($this->cloneVolumeData) {
+ try {
+ StopService::dispatch($application);
+ $sourceVolume = $volume->name;
+ $targetVolume = $newPersistentVolume->name;
+ $sourceServer = $application->service->destination->server;
+ $targetServer = $new_resource->destination->server;
+
+ VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume);
+
+ StartService::dispatch($application);
+ } catch (\Exception $e) {
+ \Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage());
+ }
}
}
}
+
+ foreach ($new_resource->databases() as $database) {
+ $database->update([
+ 'status' => 'exited',
+ ]);
+
+ $persistentVolumes = $database->persistentStorages()->get();
+ foreach ($persistentVolumes as $volume) {
+ $newName = '';
+ if (str_starts_with($volume->name, $volume->resource->uuid)) {
+ $newName = str($volume->name)->replace($volume->resource->uuid, $database->uuid);
+ } else {
+ $newName = $database->uuid.'-'.str($volume->name)->afterLast('-');
+ }
+
+ $newPersistentVolume = $volume->replicate([
+ 'id',
+ 'created_at',
+ 'updated_at',
+ ])->fill([
+ 'name' => $newName,
+ 'resource_id' => $database->id,
+ ]);
+ $newPersistentVolume->save();
+
+ if ($this->cloneVolumeData) {
+ try {
+ StopService::dispatch($database->service);
+ $sourceVolume = $volume->name;
+ $targetVolume = $newPersistentVolume->name;
+ $sourceServer = $database->service->destination->server;
+ $targetServer = $new_resource->destination->server;
+
+ VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume);
+
+ StartService::dispatch($database->service);
+ } catch (\Exception $e) {
+ \Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage());
+ }
+ }
+ }
+ }
+
+ $new_resource->parse();
+
+ $route = route('project.service.configuration', [
+ 'project_uuid' => $this->projectUuid,
+ 'environment_uuid' => $this->environmentUuid,
+ 'service_uuid' => $new_resource->uuid,
+ ]).'#resource-operations';
+
+ return redirect()->to($route);
}
-
- $new_resource->parse();
-
- $route = route('project.service.configuration', [
- 'project_uuid' => $this->projectUuid,
- 'environment_uuid' => $this->environmentUuid,
- 'service_uuid' => $new_resource->uuid,
- ]).'#resource-operations';
-
- return redirect()->to($route);
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
}
}
diff --git a/app/Livewire/Security/ApiTokens.php b/app/Livewire/Security/ApiTokens.php
index a263acedf..d22d5d9fc 100644
--- a/app/Livewire/Security/ApiTokens.php
+++ b/app/Livewire/Security/ApiTokens.php
@@ -23,6 +23,8 @@ class ApiTokens extends Component
public bool $canUseWritePermissions = false;
+ public bool $canUseDeployPermissions = false;
+
public function render()
{
return view('livewire.security.api-tokens');
@@ -33,6 +35,7 @@ class ApiTokens extends Component
$this->isApiEnabled = InstanceSettings::get()->is_api_enabled;
$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->getTokens();
}
@@ -60,6 +63,13 @@ class ApiTokens extends Component
return;
}
+ if ($permissionToUpdate == 'deploy' && ! $this->canUseDeployPermissions) {
+ $this->dispatch('error', 'You do not have permission to use deploy permissions.');
+ $this->permissions = array_diff($this->permissions, ['deploy']);
+
+ return;
+ }
+
if ($permissionToUpdate == 'root') {
$this->permissions = ['root'];
} elseif ($permissionToUpdate == 'read:sensitive' && ! in_array('read', $this->permissions)) {
@@ -88,6 +98,10 @@ class ApiTokens extends Component
throw new \Exception('You do not have permission to create tokens with write permissions.');
}
+ if (in_array('deploy', $this->permissions) && ! $this->canUseDeployPermissions) {
+ throw new \Exception('You do not have permission to create tokens with deploy permissions.');
+ }
+
$this->validate([
'description' => 'required|min:3|max:255',
]);
diff --git a/app/Livewire/Security/CloudInitScriptForm.php b/app/Livewire/Security/CloudInitScriptForm.php
index 33beff334..5e4ca9853 100644
--- a/app/Livewire/Security/CloudInitScriptForm.php
+++ b/app/Livewire/Security/CloudInitScriptForm.php
@@ -20,15 +20,19 @@ class CloudInitScriptForm extends Component
public function mount(?int $scriptId = null)
{
- if ($scriptId) {
- $this->scriptId = $scriptId;
- $cloudInitScript = CloudInitScript::ownedByCurrentTeam()->findOrFail($scriptId);
- $this->authorize('update', $cloudInitScript);
+ try {
+ if ($scriptId) {
+ $this->scriptId = $scriptId;
+ $cloudInitScript = CloudInitScript::ownedByCurrentTeam()->findOrFail($scriptId);
+ $this->authorize('update', $cloudInitScript);
- $this->name = $cloudInitScript->name;
- $this->script = $cloudInitScript->script;
- } else {
- $this->authorize('create', CloudInitScript::class);
+ $this->name = $cloudInitScript->name;
+ $this->script = $cloudInitScript->script;
+ } else {
+ $this->authorize('create', CloudInitScript::class);
+ }
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
}
}
diff --git a/app/Livewire/Security/CloudProviderTokenForm.php b/app/Livewire/Security/CloudProviderTokenForm.php
index 7affb1531..ec4513ff3 100644
--- a/app/Livewire/Security/CloudProviderTokenForm.php
+++ b/app/Livewire/Security/CloudProviderTokenForm.php
@@ -21,7 +21,11 @@ class CloudProviderTokenForm extends Component
public function mount()
{
- $this->authorize('create', CloudProviderToken::class);
+ try {
+ $this->authorize('create', CloudProviderToken::class);
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
}
protected function rules(): array
diff --git a/app/Livewire/Security/CloudProviderTokens.php b/app/Livewire/Security/CloudProviderTokens.php
index cfef30772..b7f389534 100644
--- a/app/Livewire/Security/CloudProviderTokens.php
+++ b/app/Livewire/Security/CloudProviderTokens.php
@@ -14,8 +14,12 @@ class CloudProviderTokens extends Component
public function mount()
{
- $this->authorize('viewAny', CloudProviderToken::class);
- $this->loadTokens();
+ try {
+ $this->authorize('viewAny', CloudProviderToken::class);
+ $this->loadTokens();
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
}
public function getListeners()
diff --git a/app/Livewire/Security/PrivateKey/Index.php b/app/Livewire/Security/PrivateKey/Index.php
index 1eb66ae3e..0362b65fa 100644
--- a/app/Livewire/Security/PrivateKey/Index.php
+++ b/app/Livewire/Security/PrivateKey/Index.php
@@ -21,8 +21,12 @@ class Index extends Component
public function cleanupUnusedKeys()
{
- $this->authorize('create', PrivateKey::class);
- PrivateKey::cleanupUnusedKeys();
- $this->dispatch('success', 'Unused keys have been cleaned up.');
+ try {
+ $this->authorize('create', PrivateKey::class);
+ PrivateKey::cleanupUnusedKeys();
+ $this->dispatch('success', 'Unused keys have been cleaned up.');
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
}
}
diff --git a/app/Livewire/Server/New/ByHetzner.php b/app/Livewire/Server/New/ByHetzner.php
index f1ffa60f2..e8df99d65 100644
--- a/app/Livewire/Server/New/ByHetzner.php
+++ b/app/Livewire/Server/New/ByHetzner.php
@@ -78,14 +78,18 @@ class ByHetzner extends Component
public function mount()
{
- $this->authorize('viewAny', CloudProviderToken::class);
- $this->loadTokens();
- $this->loadSavedCloudInitScripts();
- $this->server_name = generate_random_name();
- $this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get();
+ try {
+ $this->authorize('viewAny', CloudProviderToken::class);
+ $this->loadTokens();
+ $this->loadSavedCloudInitScripts();
+ $this->server_name = generate_random_name();
+ $this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get();
- if ($this->private_keys->count() > 0) {
- $this->private_key_id = $this->private_keys->first()->id;
+ if ($this->private_keys->count() > 0) {
+ $this->private_key_id = $this->private_keys->first()->id;
+ }
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
}
}
diff --git a/app/Livewire/Server/Proxy.php b/app/Livewire/Server/Proxy.php
index 1a14baf89..6c163a112 100644
--- a/app/Livewire/Server/Proxy.php
+++ b/app/Livewire/Server/Proxy.php
@@ -96,11 +96,15 @@ class Proxy extends Component
public function changeProxy()
{
- $this->authorize('update', $this->server);
- $this->server->proxy = null;
- $this->server->save();
+ try {
+ $this->authorize('update', $this->server);
+ $this->server->proxy = null;
+ $this->server->save();
- $this->dispatch('reloadWindow');
+ $this->dispatch('reloadWindow');
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
}
public function selectProxy($proxy_type)
diff --git a/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php b/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php
index c67591cf5..f7db1257c 100644
--- a/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php
+++ b/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php
@@ -22,34 +22,38 @@ class DynamicConfigurationNavbar extends Component
public function delete(string $fileName)
{
- $this->authorize('update', $this->server);
- $proxy_path = $this->server->proxyPath();
- $proxy_type = $this->server->proxyType();
+ try {
+ $this->authorize('update', $this->server);
+ $proxy_path = $this->server->proxyPath();
+ $proxy_type = $this->server->proxyType();
- // Decode filename: pipes are used to encode dots for Livewire property binding
- // (e.g., 'my|service.yaml' -> 'my.service.yaml')
- // This must happen BEFORE validation because validateShellSafePath() correctly
- // rejects pipe characters as dangerous shell metacharacters
- $file = str_replace('|', '.', $fileName);
+ // Decode filename: pipes are used to encode dots for Livewire property binding
+ // (e.g., 'my|service.yaml' -> 'my.service.yaml')
+ // This must happen BEFORE validation because validateShellSafePath() correctly
+ // rejects pipe characters as dangerous shell metacharacters
+ $file = str_replace('|', '.', $fileName);
- // Validate filename to prevent command injection
- validateShellSafePath($file, 'proxy configuration filename');
+ // Validate filename to prevent command injection
+ validateShellSafePath($file, 'proxy configuration filename');
- if ($proxy_type === 'CADDY' && $file === 'Caddyfile') {
- $this->dispatch('error', 'Cannot delete Caddyfile.');
+ if ($proxy_type === 'CADDY' && $file === 'Caddyfile') {
+ $this->dispatch('error', 'Cannot delete Caddyfile.');
- return;
+ return;
+ }
+
+ $fullPath = "{$proxy_path}/dynamic/{$file}";
+ $escapedPath = escapeshellarg($fullPath);
+ instant_remote_process(["rm -f {$escapedPath}"], $this->server);
+ if ($proxy_type === 'CADDY') {
+ $this->server->reloadCaddy();
+ }
+ $this->dispatch('success', 'File deleted.');
+ $this->dispatch('loadDynamicConfigurations');
+ $this->dispatch('refresh');
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
}
-
- $fullPath = "{$proxy_path}/dynamic/{$file}";
- $escapedPath = escapeshellarg($fullPath);
- instant_remote_process(["rm -f {$escapedPath}"], $this->server);
- if ($proxy_type === 'CADDY') {
- $this->server->reloadCaddy();
- }
- $this->dispatch('success', 'File deleted.');
- $this->dispatch('loadDynamicConfigurations');
- $this->dispatch('refresh');
}
public function render()
diff --git a/app/Livewire/Server/Resources.php b/app/Livewire/Server/Resources.php
index a21b0372b..31e57b301 100644
--- a/app/Livewire/Server/Resources.php
+++ b/app/Livewire/Server/Resources.php
@@ -29,23 +29,38 @@ class Resources extends Component
public function startUnmanaged($id)
{
- $this->server->startUnmanaged($id);
- $this->dispatch('success', 'Container started.');
- $this->loadUnmanagedContainers();
+ try {
+ $this->authorize('update', $this->server);
+ $this->server->startUnmanaged($id);
+ $this->dispatch('success', 'Container started.');
+ $this->loadUnmanagedContainers();
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
}
public function restartUnmanaged($id)
{
- $this->server->restartUnmanaged($id);
- $this->dispatch('success', 'Container restarted.');
- $this->loadUnmanagedContainers();
+ try {
+ $this->authorize('update', $this->server);
+ $this->server->restartUnmanaged($id);
+ $this->dispatch('success', 'Container restarted.');
+ $this->loadUnmanagedContainers();
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
}
public function stopUnmanaged($id)
{
- $this->server->stopUnmanaged($id);
- $this->dispatch('success', 'Container stopped.');
- $this->loadUnmanagedContainers();
+ try {
+ $this->authorize('update', $this->server);
+ $this->server->stopUnmanaged($id);
+ $this->dispatch('success', 'Container stopped.');
+ $this->loadUnmanagedContainers();
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
}
public function refreshStatus()
diff --git a/app/Livewire/Server/Security/Patches.php b/app/Livewire/Server/Security/Patches.php
index b4d151424..087836da3 100644
--- a/app/Livewire/Server/Security/Patches.php
+++ b/app/Livewire/Server/Security/Patches.php
@@ -41,7 +41,11 @@ class Patches extends Component
{
$this->parameters = get_route_parameters();
$this->server = Server::ownedByCurrentTeam()->whereUuid($this->parameters['server_uuid'])->firstOrFail();
- $this->authorize('viewSecurity', $this->server);
+ try {
+ $this->authorize('viewSecurity', $this->server);
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
}
public function checkForUpdatesDispatch()
@@ -69,14 +73,14 @@ class Patches extends Component
public function updateAllPackages()
{
- $this->authorize('update', $this->server);
- if (! $this->packageManager || ! $this->osId) {
- $this->dispatch('error', message: 'Run "Check for updates" first.');
-
- return;
- }
-
try {
+ $this->authorize('update', $this->server);
+ if (! $this->packageManager || ! $this->osId) {
+ $this->dispatch('error', message: 'Run "Check for updates" first.');
+
+ return;
+ }
+
$activity = UpdatePackage::run(
server: $this->server,
packageManager: $this->packageManager,
@@ -84,8 +88,8 @@ class Patches extends Component
all: true
);
$this->dispatch('activityMonitor', $activity->id, ServerPackageUpdated::class);
- } catch (\Exception $e) {
- $this->dispatch('error', message: $e->getMessage());
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
}
}
diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php
index 83c63a81c..38053386e 100644
--- a/app/Livewire/Server/Show.php
+++ b/app/Livewire/Server/Show.php
@@ -286,18 +286,22 @@ class Show extends Component
public function checkLocalhostConnection()
{
- $this->syncData(true);
- ['uptime' => $uptime, 'error' => $error] = $this->server->validateConnection();
- if ($uptime) {
- $this->dispatch('success', 'Server is reachable.');
- $this->server->settings->is_reachable = $this->isReachable = true;
- $this->server->settings->is_usable = $this->isUsable = true;
- $this->server->settings->save();
- ServerReachabilityChanged::dispatch($this->server);
- } else {
- $this->dispatch('error', 'Server is not reachable.', 'Please validate your configuration and connection. Check this documentation for further help. Error: '.$error);
+ try {
+ $this->syncData(true);
+ ['uptime' => $uptime, 'error' => $error] = $this->server->validateConnection();
+ if ($uptime) {
+ $this->dispatch('success', 'Server is reachable.');
+ $this->server->settings->is_reachable = $this->isReachable = true;
+ $this->server->settings->is_usable = $this->isUsable = true;
+ $this->server->settings->save();
+ ServerReachabilityChanged::dispatch($this->server);
+ } else {
+ $this->dispatch('error', 'Server is not reachable.', 'Please validate your configuration and connection. Check this documentation for further help. Error: '.$error);
- return;
+ return;
+ }
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
}
}
diff --git a/app/Livewire/Server/ValidateAndInstall.php b/app/Livewire/Server/ValidateAndInstall.php
index 1a5bd381b..9b0f02573 100644
--- a/app/Livewire/Server/ValidateAndInstall.php
+++ b/app/Livewire/Server/ValidateAndInstall.php
@@ -72,31 +72,39 @@ class ValidateAndInstall extends Component
public function retry()
{
- $this->authorize('update', $this->server);
- $this->uptime = null;
- $this->supported_os_type = null;
- $this->prerequisites_installed = null;
- $this->docker_installed = null;
- $this->docker_compose_installed = null;
- $this->docker_version = null;
- $this->error = null;
- $this->number_of_tries = 0;
- $this->init();
+ try {
+ $this->authorize('update', $this->server);
+ $this->uptime = null;
+ $this->supported_os_type = null;
+ $this->prerequisites_installed = null;
+ $this->docker_installed = null;
+ $this->docker_compose_installed = null;
+ $this->docker_version = null;
+ $this->error = null;
+ $this->number_of_tries = 0;
+ $this->init();
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
}
public function validateConnection()
{
- $this->authorize('update', $this->server);
- ['uptime' => $this->uptime, 'error' => $error] = $this->server->validateConnection();
- if (! $this->uptime) {
- $this->error = 'Server is not reachable. Please validate your configuration and connection. Check this documentation for further help.
Error: '.$error.'
';
- $this->server->update([
- 'validation_logs' => $this->error,
- ]);
+ try {
+ $this->authorize('update', $this->server);
+ ['uptime' => $this->uptime, 'error' => $error] = $this->server->validateConnection();
+ if (! $this->uptime) {
+ $this->error = 'Server is not reachable. Please validate your configuration and connection. Check this documentation for further help. Error: '.$error.'
';
+ $this->server->update([
+ 'validation_logs' => $this->error,
+ ]);
- return;
+ return;
+ }
+ $this->dispatch('validateOS');
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
}
- $this->dispatch('validateOS');
}
public function validateOS()
diff --git a/app/Livewire/SharedVariables/Project/Show.php b/app/Livewire/SharedVariables/Project/Show.php
index b205ea1ec..008f4af5a 100644
--- a/app/Livewire/SharedVariables/Project/Show.php
+++ b/app/Livewire/SharedVariables/Project/Show.php
@@ -57,9 +57,13 @@ class Show extends Component
public function switch()
{
- $this->authorize('view', $this->project);
- $this->view = $this->view === 'normal' ? 'dev' : 'normal';
- $this->getDevView();
+ try {
+ $this->authorize('view', $this->project);
+ $this->view = $this->view === 'normal' ? 'dev' : 'normal';
+ $this->getDevView();
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
}
public function getDevView()
diff --git a/app/Livewire/SharedVariables/Team/Index.php b/app/Livewire/SharedVariables/Team/Index.php
index e420686f0..93e12f376 100644
--- a/app/Livewire/SharedVariables/Team/Index.php
+++ b/app/Livewire/SharedVariables/Team/Index.php
@@ -51,9 +51,13 @@ class Index extends Component
public function switch()
{
- $this->authorize('view', $this->team);
- $this->view = $this->view === 'normal' ? 'dev' : 'normal';
- $this->getDevView();
+ try {
+ $this->authorize('view', $this->team);
+ $this->view = $this->view === 'normal' ? 'dev' : 'normal';
+ $this->getDevView();
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
}
public function getDevView()
diff --git a/app/Livewire/Storage/Show.php b/app/Livewire/Storage/Show.php
index fdf3d0d28..fd8c12292 100644
--- a/app/Livewire/Storage/Show.php
+++ b/app/Livewire/Storage/Show.php
@@ -18,7 +18,11 @@ class Show extends Component
if (! $this->storage) {
abort(404);
}
- $this->authorize('view', $this->storage);
+ try {
+ $this->authorize('view', $this->storage);
+ } catch (\Illuminate\Auth\Access\AuthorizationException) {
+ return $this->redirectRoute('storage.index', navigate: true);
+ }
}
public function render()
diff --git a/app/Livewire/Team/Index.php b/app/Livewire/Team/Index.php
index 8a943e6b6..e5ceb2cc9 100644
--- a/app/Livewire/Team/Index.php
+++ b/app/Livewire/Team/Index.php
@@ -95,23 +95,27 @@ class Index extends Component
public function delete()
{
- $currentTeam = currentTeam();
- $this->authorize('delete', $currentTeam);
- $currentTeam->delete();
+ try {
+ $currentTeam = currentTeam();
+ $this->authorize('delete', $currentTeam);
+ $currentTeam->delete();
- $currentTeam->members->each(function ($user) use ($currentTeam) {
- if ($user->id === Auth::id()) {
- return;
- }
- $user->teams()->detach($currentTeam);
- $session = DB::table('sessions')->where('user_id', $user->id)->first();
- if ($session) {
- DB::table('sessions')->where('id', $session->id)->delete();
- }
- });
+ $currentTeam->members->each(function ($user) use ($currentTeam) {
+ if ($user->id === Auth::id()) {
+ return;
+ }
+ $user->teams()->detach($currentTeam);
+ $session = DB::table('sessions')->where('user_id', $user->id)->first();
+ if ($session) {
+ DB::table('sessions')->where('id', $session->id)->delete();
+ }
+ });
- refreshSession();
+ refreshSession();
- return redirect()->route('team.index');
+ return redirect()->route('team.index');
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
}
}
diff --git a/app/Models/Team.php b/app/Models/Team.php
index e32526169..92fed1128 100644
--- a/app/Models/Team.php
+++ b/app/Models/Team.php
@@ -59,7 +59,7 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen
$team->webhookNotificationSettings()->create();
});
- static::saving(function ($team) {
+ static::updating(function ($team) {
if (auth()->user()?->isMember()) {
throw new \Exception('You are not allowed to update this team.');
}
diff --git a/app/Policies/ApiTokenPolicy.php b/app/Policies/ApiTokenPolicy.php
index 761227118..5eb1a05eb 100644
--- a/app/Policies/ApiTokenPolicy.php
+++ b/app/Policies/ApiTokenPolicy.php
@@ -12,11 +12,6 @@ class ApiTokenPolicy
*/
public function viewAny(User $user): bool
{
- // Authorization temporarily disabled
- /*
- // Users can view their own API tokens
- return true;
- */
return true;
}
@@ -25,12 +20,7 @@ class ApiTokenPolicy
*/
public function view(User $user, PersonalAccessToken $token): bool
{
- // Authorization temporarily disabled
- /*
- // Users can only view their own tokens
return $user->id === $token->tokenable_id && $token->tokenable_type === User::class;
- */
- return true;
}
/**
@@ -38,11 +28,6 @@ class ApiTokenPolicy
*/
public function create(User $user): bool
{
- // Authorization temporarily disabled
- /*
- // All authenticated users can create their own API tokens
- return true;
- */
return true;
}
@@ -51,12 +36,7 @@ class ApiTokenPolicy
*/
public function update(User $user, PersonalAccessToken $token): bool
{
- // Authorization temporarily disabled
- /*
- // Users can only update their own tokens
return $user->id === $token->tokenable_id && $token->tokenable_type === User::class;
- */
- return true;
}
/**
@@ -64,12 +44,7 @@ class ApiTokenPolicy
*/
public function delete(User $user, PersonalAccessToken $token): bool
{
- // Authorization temporarily disabled
- /*
- // Users can only delete their own tokens
return $user->id === $token->tokenable_id && $token->tokenable_type === User::class;
- */
- return true;
}
/**
@@ -77,11 +52,6 @@ class ApiTokenPolicy
*/
public function manage(User $user): bool
{
- // Authorization temporarily disabled
- /*
- // All authenticated users can manage their own API tokens
- return true;
- */
return true;
}
@@ -90,7 +60,6 @@ class ApiTokenPolicy
*/
public function useRootPermissions(User $user): bool
{
- // Only admins and owners can use root permissions
return $user->isAdmin() || $user->isOwner();
}
@@ -99,11 +68,14 @@ class ApiTokenPolicy
*/
public function useWritePermissions(User $user): bool
{
- // Authorization temporarily disabled
- /*
- // Only admins and owners can use write permissions
return $user->isAdmin() || $user->isOwner();
- */
- return true;
+ }
+
+ /**
+ * Determine whether the user can use deploy permissions for API tokens.
+ */
+ public function useDeployPermissions(User $user): bool
+ {
+ return $user->isAdmin() || $user->isOwner();
}
}
diff --git a/app/Policies/ApplicationPolicy.php b/app/Policies/ApplicationPolicy.php
index d64a436ad..7a992f2fd 100644
--- a/app/Policies/ApplicationPolicy.php
+++ b/app/Policies/ApplicationPolicy.php
@@ -13,10 +13,6 @@ class ApplicationPolicy
*/
public function viewAny(User $user): bool
{
- // Authorization temporarily disabled
- /*
- return true;
- */
return true;
}
@@ -25,11 +21,9 @@ class ApplicationPolicy
*/
public function view(User $user, Application $application): bool
{
- // Authorization temporarily disabled
- /*
- return true;
- */
- return true;
+ $teamId = $this->getTeamId($application);
+
+ return $teamId !== null && $user->teams->contains('id', $teamId);
}
/**
@@ -37,15 +31,7 @@ class ApplicationPolicy
*/
public function create(User $user): bool
{
- // Authorization temporarily disabled
- /*
- if ($user->isAdmin()) {
- return true;
- }
-
- return false;
- */
- return true;
+ return $user->isAdmin();
}
/**
@@ -53,15 +39,17 @@ class ApplicationPolicy
*/
public function update(User $user, Application $application): Response
{
- // Authorization temporarily disabled
- /*
- if ($user->isAdmin()) {
+ $teamId = $this->getTeamId($application);
+
+ if ($teamId === null) {
+ return Response::deny('Application team not found.');
+ }
+
+ if ($user->isAdminOfTeam($teamId)) {
return Response::allow();
}
- return Response::deny('As a member, you cannot update this application. You need at least admin or owner permissions.');
- */
- return Response::allow();
+ return Response::deny('You need at least admin or owner permissions to update this application.');
}
/**
@@ -69,15 +57,9 @@ class ApplicationPolicy
*/
public function delete(User $user, Application $application): bool
{
- // Authorization temporarily disabled
- /*
- if ($user->isAdmin()) {
- return true;
- }
+ $teamId = $this->getTeamId($application);
- return false;
- */
- return true;
+ return $teamId !== null && $user->isAdminOfTeam($teamId);
}
/**
@@ -85,11 +67,7 @@ class ApplicationPolicy
*/
public function restore(User $user, Application $application): bool
{
- // Authorization temporarily disabled
- /*
- return true;
- */
- return true;
+ return false;
}
/**
@@ -97,11 +75,7 @@ class ApplicationPolicy
*/
public function forceDelete(User $user, Application $application): bool
{
- // Authorization temporarily disabled
- /*
- return $user->isAdmin() && $user->teams->contains('id', $application->team()->first()->id);
- */
- return true;
+ return false;
}
/**
@@ -109,11 +83,9 @@ class ApplicationPolicy
*/
public function deploy(User $user, Application $application): bool
{
- // Authorization temporarily disabled
- /*
- return $user->teams->contains('id', $application->team()->first()->id);
- */
- return true;
+ $teamId = $this->getTeamId($application);
+
+ return $teamId !== null && $user->isAdminOfTeam($teamId);
}
/**
@@ -121,11 +93,9 @@ class ApplicationPolicy
*/
public function manageDeployments(User $user, Application $application): bool
{
- // Authorization temporarily disabled
- /*
- return $user->isAdmin() && $user->teams->contains('id', $application->team()->first()->id);
- */
- return true;
+ $teamId = $this->getTeamId($application);
+
+ return $teamId !== null && $user->isAdminOfTeam($teamId);
}
/**
@@ -133,11 +103,9 @@ class ApplicationPolicy
*/
public function manageEnvironment(User $user, Application $application): bool
{
- // Authorization temporarily disabled
- /*
- return $user->isAdmin() && $user->teams->contains('id', $application->team()->first()->id);
- */
- return true;
+ $teamId = $this->getTeamId($application);
+
+ return $teamId !== null && $user->isAdminOfTeam($teamId);
}
/**
@@ -145,10 +113,11 @@ class ApplicationPolicy
*/
public function cleanupDeploymentQueue(User $user): bool
{
- // Authorization temporarily disabled
- /*
return $user->isAdmin();
- */
- return true;
+ }
+
+ private function getTeamId(Application $application): ?int
+ {
+ return $application->team()?->id;
}
}
diff --git a/app/Policies/ApplicationPreviewPolicy.php b/app/Policies/ApplicationPreviewPolicy.php
index 4d371cc38..f3c13acd9 100644
--- a/app/Policies/ApplicationPreviewPolicy.php
+++ b/app/Policies/ApplicationPreviewPolicy.php
@@ -21,8 +21,9 @@ class ApplicationPreviewPolicy
*/
public function view(User $user, ApplicationPreview $applicationPreview): bool
{
- // return $user->teams->contains('id', $applicationPreview->application->team()->first()->id);
- return true;
+ $teamId = $this->getTeamId($applicationPreview);
+
+ return $teamId !== null && $user->teams->contains('id', $teamId);
}
/**
@@ -30,21 +31,25 @@ class ApplicationPreviewPolicy
*/
public function create(User $user): bool
{
- // return $user->isAdmin();
- return true;
+ return $user->isAdmin();
}
/**
* Determine whether the user can update the model.
*/
- public function update(User $user, ApplicationPreview $applicationPreview)
+ public function update(User $user, ApplicationPreview $applicationPreview): Response
{
- // if ($user->isAdmin()) {
- // return Response::allow();
- // }
+ $teamId = $this->getTeamId($applicationPreview);
- // return Response::deny('As a member, you cannot update this preview. You need at least admin or owner permissions.');
- return true;
+ if ($teamId === null) {
+ return Response::deny('Application preview team not found.');
+ }
+
+ if ($user->isAdminOfTeam($teamId)) {
+ return Response::allow();
+ }
+
+ return Response::deny('You need at least admin or owner permissions to update this preview.');
}
/**
@@ -52,8 +57,9 @@ class ApplicationPreviewPolicy
*/
public function delete(User $user, ApplicationPreview $applicationPreview): bool
{
- // return $user->isAdmin() && $user->teams->contains('id', $applicationPreview->application->team()->first()->id);
- return true;
+ $teamId = $this->getTeamId($applicationPreview);
+
+ return $teamId !== null && $user->isAdminOfTeam($teamId);
}
/**
@@ -61,8 +67,7 @@ class ApplicationPreviewPolicy
*/
public function restore(User $user, ApplicationPreview $applicationPreview): bool
{
- // return $user->isAdmin() && $user->teams->contains('id', $applicationPreview->application->team()->first()->id);
- return true;
+ return false;
}
/**
@@ -70,8 +75,7 @@ class ApplicationPreviewPolicy
*/
public function forceDelete(User $user, ApplicationPreview $applicationPreview): bool
{
- // return $user->isAdmin() && $user->teams->contains('id', $applicationPreview->application->team()->first()->id);
- return true;
+ return false;
}
/**
@@ -79,8 +83,9 @@ class ApplicationPreviewPolicy
*/
public function deploy(User $user, ApplicationPreview $applicationPreview): bool
{
- // return $user->teams->contains('id', $applicationPreview->application->team()->first()->id);
- return true;
+ $teamId = $this->getTeamId($applicationPreview);
+
+ return $teamId !== null && $user->isAdminOfTeam($teamId);
}
/**
@@ -88,7 +93,13 @@ class ApplicationPreviewPolicy
*/
public function manageDeployments(User $user, ApplicationPreview $applicationPreview): bool
{
- // return $user->isAdmin() && $user->teams->contains('id', $applicationPreview->application->team()->first()->id);
- return true;
+ $teamId = $this->getTeamId($applicationPreview);
+
+ return $teamId !== null && $user->isAdminOfTeam($teamId);
+ }
+
+ private function getTeamId(ApplicationPreview $applicationPreview): ?int
+ {
+ return $applicationPreview->application?->team()?->id;
}
}
diff --git a/app/Policies/ApplicationSettingPolicy.php b/app/Policies/ApplicationSettingPolicy.php
index 848dc9aee..be2137cb8 100644
--- a/app/Policies/ApplicationSettingPolicy.php
+++ b/app/Policies/ApplicationSettingPolicy.php
@@ -20,8 +20,9 @@ class ApplicationSettingPolicy
*/
public function view(User $user, ApplicationSetting $applicationSetting): bool
{
- // return $user->teams->contains('id', $applicationSetting->application->team()->first()->id);
- return true;
+ $teamId = $this->getTeamId($applicationSetting);
+
+ return $teamId !== null && $user->teams->contains('id', $teamId);
}
/**
@@ -29,8 +30,7 @@ class ApplicationSettingPolicy
*/
public function create(User $user): bool
{
- // return $user->isAdmin();
- return true;
+ return $user->isAdmin();
}
/**
@@ -38,8 +38,9 @@ class ApplicationSettingPolicy
*/
public function update(User $user, ApplicationSetting $applicationSetting): bool
{
- // return $user->isAdmin() && $user->teams->contains('id', $applicationSetting->application->team()->first()->id);
- return true;
+ $teamId = $this->getTeamId($applicationSetting);
+
+ return $teamId !== null && $user->isAdminOfTeam($teamId);
}
/**
@@ -47,8 +48,9 @@ class ApplicationSettingPolicy
*/
public function delete(User $user, ApplicationSetting $applicationSetting): bool
{
- // return $user->isAdmin() && $user->teams->contains('id', $applicationSetting->application->team()->first()->id);
- return true;
+ $teamId = $this->getTeamId($applicationSetting);
+
+ return $teamId !== null && $user->isAdminOfTeam($teamId);
}
/**
@@ -56,8 +58,7 @@ class ApplicationSettingPolicy
*/
public function restore(User $user, ApplicationSetting $applicationSetting): bool
{
- // return $user->isAdmin() && $user->teams->contains('id', $applicationSetting->application->team()->first()->id);
- return true;
+ return false;
}
/**
@@ -65,7 +66,11 @@ class ApplicationSettingPolicy
*/
public function forceDelete(User $user, ApplicationSetting $applicationSetting): bool
{
- // return $user->isAdmin() && $user->teams->contains('id', $applicationSetting->application->team()->first()->id);
- return true;
+ return false;
+ }
+
+ private function getTeamId(ApplicationSetting $applicationSetting): ?int
+ {
+ return $applicationSetting->application?->team()?->id;
}
}
diff --git a/app/Policies/DatabasePolicy.php b/app/Policies/DatabasePolicy.php
index f8e8af637..6a5348224 100644
--- a/app/Policies/DatabasePolicy.php
+++ b/app/Policies/DatabasePolicy.php
@@ -20,8 +20,9 @@ class DatabasePolicy
*/
public function view(User $user, $database): bool
{
- // return $user->teams->contains('id', $database->team()->first()->id);
- return true;
+ $teamId = $this->getTeamId($database);
+
+ return $teamId !== null && $user->teams->contains('id', $teamId);
}
/**
@@ -29,21 +30,25 @@ class DatabasePolicy
*/
public function create(User $user): bool
{
- // return $user->isAdmin();
- return true;
+ return $user->isAdmin();
}
/**
* Determine whether the user can update the model.
*/
- public function update(User $user, $database)
+ public function update(User $user, $database): Response
{
- // if ($user->isAdmin() && $user->teams->contains('id', $database->team()->first()->id)) {
- // return Response::allow();
- // }
+ $teamId = $this->getTeamId($database);
- // return Response::deny('As a member, you cannot update this database. You need at least admin or owner permissions.');
- return true;
+ if ($teamId === null) {
+ return Response::deny('Database team not found.');
+ }
+
+ if ($user->isAdminOfTeam($teamId)) {
+ return Response::allow();
+ }
+
+ return Response::deny('You need at least admin or owner permissions to update this database.');
}
/**
@@ -51,8 +56,9 @@ class DatabasePolicy
*/
public function delete(User $user, $database): bool
{
- // return $user->isAdmin() && $user->teams->contains('id', $database->team()->first()->id);
- return true;
+ $teamId = $this->getTeamId($database);
+
+ return $teamId !== null && $user->isAdminOfTeam($teamId);
}
/**
@@ -60,8 +66,7 @@ class DatabasePolicy
*/
public function restore(User $user, $database): bool
{
- // return $user->isAdmin() && $user->teams->contains('id', $database->team()->first()->id);
- return true;
+ return false;
}
/**
@@ -69,8 +74,7 @@ class DatabasePolicy
*/
public function forceDelete(User $user, $database): bool
{
- // return $user->isAdmin() && $user->teams->contains('id', $database->team()->first()->id);
- return true;
+ return false;
}
/**
@@ -78,8 +82,9 @@ class DatabasePolicy
*/
public function manage(User $user, $database): bool
{
- // return $user->isAdmin() && $user->teams->contains('id', $database->team()->first()->id);
- return true;
+ $teamId = $this->getTeamId($database);
+
+ return $teamId !== null && $user->isAdminOfTeam($teamId);
}
/**
@@ -87,8 +92,9 @@ class DatabasePolicy
*/
public function manageBackups(User $user, $database): bool
{
- // return $user->isAdmin() && $user->teams->contains('id', $database->team()->first()->id);
- return true;
+ $teamId = $this->getTeamId($database);
+
+ return $teamId !== null && $user->isAdminOfTeam($teamId);
}
/**
@@ -96,7 +102,17 @@ class DatabasePolicy
*/
public function manageEnvironment(User $user, $database): bool
{
- // return $user->isAdmin() && $user->teams->contains('id', $database->team()->first()->id);
- return true;
+ $teamId = $this->getTeamId($database);
+
+ return $teamId !== null && $user->isAdminOfTeam($teamId);
+ }
+
+ private function getTeamId($database): ?int
+ {
+ if (method_exists($database, 'team')) {
+ return $database->team()?->id;
+ }
+
+ return null;
}
}
diff --git a/app/Policies/EnvironmentPolicy.php b/app/Policies/EnvironmentPolicy.php
index 7199abb25..e400ec903 100644
--- a/app/Policies/EnvironmentPolicy.php
+++ b/app/Policies/EnvironmentPolicy.php
@@ -20,8 +20,9 @@ class EnvironmentPolicy
*/
public function view(User $user, Environment $environment): bool
{
- // return $user->teams->contains('id', $environment->project->team_id);
- return true;
+ $teamId = $this->getTeamId($environment);
+
+ return $teamId !== null && $user->teams->contains('id', $teamId);
}
/**
@@ -29,8 +30,7 @@ class EnvironmentPolicy
*/
public function create(User $user): bool
{
- // return $user->isAdmin();
- return true;
+ return $user->isAdmin();
}
/**
@@ -38,8 +38,9 @@ class EnvironmentPolicy
*/
public function update(User $user, Environment $environment): bool
{
- // return $user->isAdmin() && $user->teams->contains('id', $environment->project->team_id);
- return true;
+ $teamId = $this->getTeamId($environment);
+
+ return $teamId !== null && $user->isAdminOfTeam($teamId);
}
/**
@@ -47,8 +48,9 @@ class EnvironmentPolicy
*/
public function delete(User $user, Environment $environment): bool
{
- // return $user->isAdmin() && $user->teams->contains('id', $environment->project->team_id);
- return true;
+ $teamId = $this->getTeamId($environment);
+
+ return $teamId !== null && $user->isAdminOfTeam($teamId);
}
/**
@@ -56,8 +58,7 @@ class EnvironmentPolicy
*/
public function restore(User $user, Environment $environment): bool
{
- // return $user->isAdmin() && $user->teams->contains('id', $environment->project->team_id);
- return true;
+ return false;
}
/**
@@ -65,7 +66,11 @@ class EnvironmentPolicy
*/
public function forceDelete(User $user, Environment $environment): bool
{
- // return $user->isAdmin() && $user->teams->contains('id', $environment->project->team_id);
- return true;
+ return false;
+ }
+
+ private function getTeamId(Environment $environment): ?int
+ {
+ return $environment->project?->team_id;
}
}
diff --git a/app/Policies/EnvironmentVariablePolicy.php b/app/Policies/EnvironmentVariablePolicy.php
index 21e2ea443..dd0f58918 100644
--- a/app/Policies/EnvironmentVariablePolicy.php
+++ b/app/Policies/EnvironmentVariablePolicy.php
@@ -20,7 +20,9 @@ class EnvironmentVariablePolicy
*/
public function view(User $user, EnvironmentVariable $environmentVariable): bool
{
- return true;
+ $teamId = $this->getTeamId($environmentVariable);
+
+ return $teamId !== null && $user->teams->contains('id', $teamId);
}
/**
@@ -28,7 +30,7 @@ class EnvironmentVariablePolicy
*/
public function create(User $user): bool
{
- return true;
+ return $user->isAdmin();
}
/**
@@ -36,7 +38,9 @@ class EnvironmentVariablePolicy
*/
public function update(User $user, EnvironmentVariable $environmentVariable): bool
{
- return true;
+ $teamId = $this->getTeamId($environmentVariable);
+
+ return $teamId !== null && $user->isAdminOfTeam($teamId);
}
/**
@@ -44,7 +48,9 @@ class EnvironmentVariablePolicy
*/
public function delete(User $user, EnvironmentVariable $environmentVariable): bool
{
- return true;
+ $teamId = $this->getTeamId($environmentVariable);
+
+ return $teamId !== null && $user->isAdminOfTeam($teamId);
}
/**
@@ -52,7 +58,7 @@ class EnvironmentVariablePolicy
*/
public function restore(User $user, EnvironmentVariable $environmentVariable): bool
{
- return true;
+ return false;
}
/**
@@ -60,7 +66,7 @@ class EnvironmentVariablePolicy
*/
public function forceDelete(User $user, EnvironmentVariable $environmentVariable): bool
{
- return true;
+ return false;
}
/**
@@ -68,6 +74,19 @@ class EnvironmentVariablePolicy
*/
public function manageEnvironment(User $user, EnvironmentVariable $environmentVariable): bool
{
- return true;
+ $teamId = $this->getTeamId($environmentVariable);
+
+ return $teamId !== null && $user->isAdminOfTeam($teamId);
+ }
+
+ private function getTeamId(EnvironmentVariable $environmentVariable): ?int
+ {
+ $resource = $environmentVariable->resourceable;
+
+ if (! $resource || ! method_exists($resource, 'team')) {
+ return null;
+ }
+
+ return $resource->team()?->id;
}
}
diff --git a/app/Policies/GithubAppPolicy.php b/app/Policies/GithubAppPolicy.php
index 56bec7032..79dd79838 100644
--- a/app/Policies/GithubAppPolicy.php
+++ b/app/Policies/GithubAppPolicy.php
@@ -20,8 +20,11 @@ class GithubAppPolicy
*/
public function view(User $user, GithubApp $githubApp): bool
{
- // return $user->teams->contains('id', $githubApp->team_id) || $githubApp->is_system_wide;
- return true;
+ if ($githubApp->is_system_wide) {
+ return true;
+ }
+
+ return $user->teams->contains('id', $githubApp->team_id);
}
/**
@@ -29,8 +32,7 @@ class GithubAppPolicy
*/
public function create(User $user): bool
{
- // return $user->isAdmin();
- return true;
+ return $user->isAdmin();
}
/**
@@ -39,12 +41,10 @@ class GithubAppPolicy
public function update(User $user, GithubApp $githubApp): bool
{
if ($githubApp->is_system_wide) {
- // return $user->isAdmin();
- return true;
+ return $user->canAccessSystemResources();
}
- // return $user->isAdmin() && $user->teams->contains('id', $githubApp->team_id);
- return true;
+ return $user->isAdminOfTeam($githubApp->team_id);
}
/**
@@ -53,12 +53,10 @@ class GithubAppPolicy
public function delete(User $user, GithubApp $githubApp): bool
{
if ($githubApp->is_system_wide) {
- // return $user->isAdmin();
- return true;
+ return $user->canAccessSystemResources();
}
- // return $user->isAdmin() && $user->teams->contains('id', $githubApp->team_id);
- return true;
+ return $user->isAdminOfTeam($githubApp->team_id);
}
/**
diff --git a/app/Policies/NotificationPolicy.php b/app/Policies/NotificationPolicy.php
index 4f3be431d..e8764bf13 100644
--- a/app/Policies/NotificationPolicy.php
+++ b/app/Policies/NotificationPolicy.php
@@ -12,13 +12,11 @@ class NotificationPolicy
*/
public function view(User $user, Model $notificationSettings): bool
{
- // Check if the notification settings belong to the user's current team
if (! $notificationSettings->team) {
return false;
}
- // return $user->teams()->where('teams.id', $notificationSettings->team->id)->exists();
- return true;
+ return $user->teams->contains('id', $notificationSettings->team->id);
}
/**
@@ -26,14 +24,13 @@ class NotificationPolicy
*/
public function update(User $user, Model $notificationSettings): bool
{
- // Check if the notification settings belong to the user's current team
if (! $notificationSettings->team) {
return false;
}
- // Only owners and admins can update notification settings
- // return $user->isAdmin() || $user->isOwner();
- return true;
+ $teamId = $notificationSettings->team->id;
+
+ return $user->isAdminOfTeam($teamId);
}
/**
@@ -41,8 +38,7 @@ class NotificationPolicy
*/
public function manage(User $user, Model $notificationSettings): bool
{
- // return $this->update($user, $notificationSettings);
- return true;
+ return $this->update($user, $notificationSettings);
}
/**
@@ -50,7 +46,6 @@ class NotificationPolicy
*/
public function sendTest(User $user, Model $notificationSettings): bool
{
- // return $this->update($user, $notificationSettings);
- return true;
+ return $this->update($user, $notificationSettings);
}
}
diff --git a/app/Policies/ProjectPolicy.php b/app/Policies/ProjectPolicy.php
index e188c293f..9d65b9130 100644
--- a/app/Policies/ProjectPolicy.php
+++ b/app/Policies/ProjectPolicy.php
@@ -20,8 +20,7 @@ class ProjectPolicy
*/
public function view(User $user, Project $project): bool
{
- // return $user->teams->contains('id', $project->team_id);
- return true;
+ return $user->teams->contains('id', $project->team_id);
}
/**
@@ -29,8 +28,7 @@ class ProjectPolicy
*/
public function create(User $user): bool
{
- // return $user->isAdmin();
- return true;
+ return $user->isAdmin();
}
/**
@@ -38,8 +36,7 @@ class ProjectPolicy
*/
public function update(User $user, Project $project): bool
{
- // return $user->isAdmin() && $user->teams->contains('id', $project->team_id);
- return true;
+ return $user->isAdminOfTeam($project->team_id);
}
/**
@@ -47,8 +44,7 @@ class ProjectPolicy
*/
public function delete(User $user, Project $project): bool
{
- // return $user->isAdmin() && $user->teams->contains('id', $project->team_id);
- return true;
+ return $user->isAdminOfTeam($project->team_id);
}
/**
@@ -56,8 +52,7 @@ class ProjectPolicy
*/
public function restore(User $user, Project $project): bool
{
- // return $user->isAdmin() && $user->teams->contains('id', $project->team_id);
- return true;
+ return false;
}
/**
@@ -65,7 +60,6 @@ class ProjectPolicy
*/
public function forceDelete(User $user, Project $project): bool
{
- // return $user->isAdmin() && $user->teams->contains('id', $project->team_id);
- return true;
+ return false;
}
}
diff --git a/app/Policies/ResourceCreatePolicy.php b/app/Policies/ResourceCreatePolicy.php
index 9ed2b66ab..a7a855402 100644
--- a/app/Policies/ResourceCreatePolicy.php
+++ b/app/Policies/ResourceCreatePolicy.php
@@ -38,8 +38,7 @@ class ResourceCreatePolicy
*/
public function createAny(User $user): bool
{
- // return $user->isAdmin();
- return true;
+ return $user->isAdmin();
}
/**
@@ -51,8 +50,7 @@ class ResourceCreatePolicy
return false;
}
- // return $user->isAdmin();
- return true;
+ return $user->isAdmin();
}
/**
diff --git a/app/Policies/ServerPolicy.php b/app/Policies/ServerPolicy.php
index 6d2396a7d..32436987c 100644
--- a/app/Policies/ServerPolicy.php
+++ b/app/Policies/ServerPolicy.php
@@ -28,8 +28,7 @@ class ServerPolicy
*/
public function create(User $user): bool
{
- // return $user->isAdmin();
- return true;
+ return $user->isAdmin();
}
/**
@@ -37,8 +36,7 @@ class ServerPolicy
*/
public function update(User $user, Server $server): bool
{
- // return $user->isAdmin() && $user->teams->contains('id', $server->team_id);
- return true;
+ return $user->isAdminOfTeam($server->team_id);
}
/**
@@ -46,8 +44,7 @@ class ServerPolicy
*/
public function delete(User $user, Server $server): bool
{
- // return $user->isAdmin() && $user->teams->contains('id', $server->team_id);
- return true;
+ return $user->isAdminOfTeam($server->team_id);
}
/**
@@ -71,8 +68,7 @@ class ServerPolicy
*/
public function manageProxy(User $user, Server $server): bool
{
- // return $user->isAdmin() && $user->teams->contains('id', $server->team_id);
- return true;
+ return $user->isAdminOfTeam($server->team_id);
}
/**
@@ -80,8 +76,7 @@ class ServerPolicy
*/
public function manageSentinel(User $user, Server $server): bool
{
- // return $user->isAdmin() && $user->teams->contains('id', $server->team_id);
- return true;
+ return $user->isAdminOfTeam($server->team_id);
}
/**
@@ -89,8 +84,7 @@ class ServerPolicy
*/
public function manageCaCertificate(User $user, Server $server): bool
{
- // return $user->isAdmin() && $user->teams->contains('id', $server->team_id);
- return true;
+ return $user->isAdminOfTeam($server->team_id);
}
/**
@@ -98,7 +92,6 @@ class ServerPolicy
*/
public function viewSecurity(User $user, Server $server): bool
{
- // return $user->isAdmin() && $user->teams->contains('id', $server->team_id);
- return true;
+ return $user->isAdminOfTeam($server->team_id);
}
}
diff --git a/app/Policies/ServiceApplicationPolicy.php b/app/Policies/ServiceApplicationPolicy.php
index af380a90f..c730ab0c6 100644
--- a/app/Policies/ServiceApplicationPolicy.php
+++ b/app/Policies/ServiceApplicationPolicy.php
@@ -21,8 +21,7 @@ class ServiceApplicationPolicy
*/
public function create(User $user): bool
{
- // return $user->isAdmin();
- return true;
+ return $user->isAdmin();
}
/**
@@ -30,8 +29,7 @@ class ServiceApplicationPolicy
*/
public function update(User $user, ServiceApplication $serviceApplication): bool
{
- // return Gate::allows('update', $serviceApplication->service);
- return true;
+ return Gate::allows('update', $serviceApplication->service);
}
/**
@@ -39,8 +37,7 @@ class ServiceApplicationPolicy
*/
public function delete(User $user, ServiceApplication $serviceApplication): bool
{
- // return Gate::allows('delete', $serviceApplication->service);
- return true;
+ return Gate::allows('delete', $serviceApplication->service);
}
/**
@@ -48,8 +45,7 @@ class ServiceApplicationPolicy
*/
public function restore(User $user, ServiceApplication $serviceApplication): bool
{
- // return Gate::allows('update', $serviceApplication->service);
- return true;
+ return false;
}
/**
@@ -57,7 +53,6 @@ class ServiceApplicationPolicy
*/
public function forceDelete(User $user, ServiceApplication $serviceApplication): bool
{
- // return Gate::allows('delete', $serviceApplication->service);
- return true;
+ return false;
}
}
diff --git a/app/Policies/ServiceDatabasePolicy.php b/app/Policies/ServiceDatabasePolicy.php
index f72f1f327..e5cbe91a0 100644
--- a/app/Policies/ServiceDatabasePolicy.php
+++ b/app/Policies/ServiceDatabasePolicy.php
@@ -13,7 +13,7 @@ class ServiceDatabasePolicy
*/
public function view(User $user, ServiceDatabase $serviceDatabase): bool
{
- return true;
+ return Gate::allows('view', $serviceDatabase->service);
}
/**
@@ -21,8 +21,7 @@ class ServiceDatabasePolicy
*/
public function create(User $user): bool
{
- // return $user->isAdmin();
- return true;
+ return $user->isAdmin();
}
/**
@@ -30,9 +29,7 @@ class ServiceDatabasePolicy
*/
public function update(User $user, ServiceDatabase $serviceDatabase): bool
{
-
- // return Gate::allows('update', $serviceDatabase->service);
- return true;
+ return Gate::allows('update', $serviceDatabase->service);
}
/**
@@ -40,8 +37,7 @@ class ServiceDatabasePolicy
*/
public function delete(User $user, ServiceDatabase $serviceDatabase): bool
{
- // return Gate::allows('delete', $serviceDatabase->service);
- return true;
+ return Gate::allows('delete', $serviceDatabase->service);
}
/**
@@ -49,8 +45,7 @@ class ServiceDatabasePolicy
*/
public function restore(User $user, ServiceDatabase $serviceDatabase): bool
{
- // return Gate::allows('update', $serviceDatabase->service);
- return true;
+ return false;
}
/**
@@ -58,12 +53,14 @@ class ServiceDatabasePolicy
*/
public function forceDelete(User $user, ServiceDatabase $serviceDatabase): bool
{
- // return Gate::allows('delete', $serviceDatabase->service);
- return true;
+ return false;
}
+ /**
+ * Determine whether the user can manage database backups.
+ */
public function manageBackups(User $user, ServiceDatabase $serviceDatabase): bool
{
- return true;
+ return Gate::allows('update', $serviceDatabase->service);
}
}
diff --git a/app/Policies/ServicePolicy.php b/app/Policies/ServicePolicy.php
index 7ab0fe7d0..d48728cdf 100644
--- a/app/Policies/ServicePolicy.php
+++ b/app/Policies/ServicePolicy.php
@@ -20,7 +20,9 @@ class ServicePolicy
*/
public function view(User $user, Service $service): bool
{
- return true;
+ $teamId = $this->getTeamId($service);
+
+ return $teamId !== null && $user->teams->contains('id', $teamId);
}
/**
@@ -28,8 +30,7 @@ class ServicePolicy
*/
public function create(User $user): bool
{
- // return $user->isAdmin();
- return true;
+ return $user->isAdmin();
}
/**
@@ -37,13 +38,9 @@ class ServicePolicy
*/
public function update(User $user, Service $service): bool
{
- $team = $service->team();
- if (! $team) {
- return false;
- }
+ $teamId = $this->getTeamId($service);
- // return $user->isAdmin() && $user->teams->contains('id', $team->id);
- return true;
+ return $teamId !== null && $user->isAdminOfTeam($teamId);
}
/**
@@ -51,12 +48,9 @@ class ServicePolicy
*/
public function delete(User $user, Service $service): bool
{
- // if ($user->isAdmin()) {
- // return true;
- // }
+ $teamId = $this->getTeamId($service);
- // return false;
- return true;
+ return $teamId !== null && $user->isAdminOfTeam($teamId);
}
/**
@@ -64,8 +58,7 @@ class ServicePolicy
*/
public function restore(User $user, Service $service): bool
{
- // return true;
- return true;
+ return false;
}
/**
@@ -73,23 +66,17 @@ class ServicePolicy
*/
public function forceDelete(User $user, Service $service): bool
{
- // if ($user->isAdmin()) {
- // return true;
- // }
-
- // return false;
- return true;
+ return false;
}
+ /**
+ * Determine whether the user can stop the service.
+ */
public function stop(User $user, Service $service): bool
{
- $team = $service->team();
- if (! $team) {
- return false;
- }
+ $teamId = $this->getTeamId($service);
- // return $user->teams->contains('id', $team->id);
- return true;
+ return $teamId !== null && $user->isAdminOfTeam($teamId);
}
/**
@@ -97,13 +84,9 @@ class ServicePolicy
*/
public function manageEnvironment(User $user, Service $service): bool
{
- $team = $service->team();
- if (! $team) {
- return false;
- }
+ $teamId = $this->getTeamId($service);
- // return $user->isAdmin() && $user->teams->contains('id', $team->id);
- return true;
+ return $teamId !== null && $user->isAdminOfTeam($teamId);
}
/**
@@ -111,18 +94,23 @@ class ServicePolicy
*/
public function deploy(User $user, Service $service): bool
{
- $team = $service->team();
- if (! $team) {
- return false;
- }
+ $teamId = $this->getTeamId($service);
- // return $user->teams->contains('id', $team->id);
- return true;
+ return $teamId !== null && $user->isAdminOfTeam($teamId);
}
+ /**
+ * Determine whether the user can access the terminal.
+ */
public function accessTerminal(User $user, Service $service): bool
{
- // return $user->isAdmin() || $user->teams->contains('id', $service->team()->id);
- return true;
+ $teamId = $this->getTeamId($service);
+
+ return $teamId !== null && $user->isAdminOfTeam($teamId);
+ }
+
+ private function getTeamId(Service $service): ?int
+ {
+ return $service->team()?->id;
}
}
diff --git a/app/Policies/SharedEnvironmentVariablePolicy.php b/app/Policies/SharedEnvironmentVariablePolicy.php
index b465d8a0c..21b6acb27 100644
--- a/app/Policies/SharedEnvironmentVariablePolicy.php
+++ b/app/Policies/SharedEnvironmentVariablePolicy.php
@@ -28,8 +28,7 @@ class SharedEnvironmentVariablePolicy
*/
public function create(User $user): bool
{
- // return $user->isAdmin();
- return true;
+ return $user->isAdmin();
}
/**
@@ -37,8 +36,7 @@ class SharedEnvironmentVariablePolicy
*/
public function update(User $user, SharedEnvironmentVariable $sharedEnvironmentVariable): bool
{
- // return $user->isAdmin() && $user->teams->contains('id', $sharedEnvironmentVariable->team_id);
- return true;
+ return $user->isAdminOfTeam($sharedEnvironmentVariable->team_id);
}
/**
@@ -46,8 +44,7 @@ class SharedEnvironmentVariablePolicy
*/
public function delete(User $user, SharedEnvironmentVariable $sharedEnvironmentVariable): bool
{
- // return $user->isAdmin() && $user->teams->contains('id', $sharedEnvironmentVariable->team_id);
- return true;
+ return $user->isAdminOfTeam($sharedEnvironmentVariable->team_id);
}
/**
@@ -55,8 +52,7 @@ class SharedEnvironmentVariablePolicy
*/
public function restore(User $user, SharedEnvironmentVariable $sharedEnvironmentVariable): bool
{
- // return $user->isAdmin() && $user->teams->contains('id', $sharedEnvironmentVariable->team_id);
- return true;
+ return false;
}
/**
@@ -64,8 +60,7 @@ class SharedEnvironmentVariablePolicy
*/
public function forceDelete(User $user, SharedEnvironmentVariable $sharedEnvironmentVariable): bool
{
- // return $user->isAdmin() && $user->teams->contains('id', $sharedEnvironmentVariable->team_id);
- return true;
+ return false;
}
/**
@@ -73,7 +68,6 @@ class SharedEnvironmentVariablePolicy
*/
public function manageEnvironment(User $user, SharedEnvironmentVariable $sharedEnvironmentVariable): bool
{
- // return $user->isAdmin() && $user->teams->contains('id', $sharedEnvironmentVariable->team_id);
- return true;
+ return $user->isAdminOfTeam($sharedEnvironmentVariable->team_id);
}
}
diff --git a/app/Policies/StandaloneDockerPolicy.php b/app/Policies/StandaloneDockerPolicy.php
index 3e1f83d12..33eda183a 100644
--- a/app/Policies/StandaloneDockerPolicy.php
+++ b/app/Policies/StandaloneDockerPolicy.php
@@ -28,8 +28,7 @@ class StandaloneDockerPolicy
*/
public function create(User $user): bool
{
- // return $user->isAdmin();
- return true;
+ return $user->isAdmin();
}
/**
@@ -37,7 +36,7 @@ class StandaloneDockerPolicy
*/
public function update(User $user, StandaloneDocker $standaloneDocker): bool
{
- return $user->teams->contains('id', $standaloneDocker->server->team_id);
+ return $user->isAdminOfTeam($standaloneDocker->server->team_id);
}
/**
@@ -45,7 +44,7 @@ class StandaloneDockerPolicy
*/
public function delete(User $user, StandaloneDocker $standaloneDocker): bool
{
- return $user->teams->contains('id', $standaloneDocker->server->team_id);
+ return $user->isAdminOfTeam($standaloneDocker->server->team_id);
}
/**
diff --git a/app/Policies/SwarmDockerPolicy.php b/app/Policies/SwarmDockerPolicy.php
index 82a75910b..b19ab4907 100644
--- a/app/Policies/SwarmDockerPolicy.php
+++ b/app/Policies/SwarmDockerPolicy.php
@@ -28,8 +28,7 @@ class SwarmDockerPolicy
*/
public function create(User $user): bool
{
- // return $user->isAdmin();
- return true;
+ return $user->isAdmin();
}
/**
@@ -37,7 +36,7 @@ class SwarmDockerPolicy
*/
public function update(User $user, SwarmDocker $swarmDocker): bool
{
- return $user->teams->contains('id', $swarmDocker->server->team_id);
+ return $user->isAdminOfTeam($swarmDocker->server->team_id);
}
/**
@@ -45,7 +44,7 @@ class SwarmDockerPolicy
*/
public function delete(User $user, SwarmDocker $swarmDocker): bool
{
- return $user->teams->contains('id', $swarmDocker->server->team_id);
+ return $user->isAdminOfTeam($swarmDocker->server->team_id);
}
/**
diff --git a/resources/views/components/applications/advanced.blade.php b/resources/views/components/applications/advanced.blade.php
index e36583741..5964abb4e 100644
--- a/resources/views/components/applications/advanced.blade.php
+++ b/resources/views/components/applications/advanced.blade.php
@@ -3,7 +3,7 @@
Advanced
@if ($application->status === 'running')
-
+
user()->can('deploy', $application)) data-disabled @endif wire:click='force_deploy_without_cache'>
@@ -18,7 +18,7 @@
cache)
@else
-
+
user()->can('deploy', $application)) data-disabled @endif wire:click='deploy(true)'>
diff --git a/resources/views/components/notification/navbar.blade.php b/resources/views/components/notification/navbar.blade.php
index 0ee3b8ee4..256c4d528 100644
--- a/resources/views/components/notification/navbar.blade.php
+++ b/resources/views/components/notification/navbar.blade.php
@@ -2,7 +2,7 @@
Notifications
Get notified about your infrastructure.
-
+
Email
diff --git a/resources/views/components/services/advanced.blade.php b/resources/views/components/services/advanced.blade.php
index 963de5c57..ed8c5d647 100644
--- a/resources/views/components/services/advanced.blade.php
+++ b/resources/views/components/services/advanced.blade.php
@@ -3,7 +3,7 @@
Advanced
@if (str($service->status)->contains('running'))
-
+
user()->can('deploy', $service)) data-disabled @endif @click="$wire.dispatch('pullAndRestartEvent')">
@@ -17,7 +17,7 @@
Pull Latest Images & Restart
@elseif (str($service->status)->contains('degraded'))
-
+
user()->can('deploy', $service)) data-disabled @endif @click="$wire.dispatch('forceDeployEvent')">
@@ -27,7 +27,7 @@
Force Restart
@else
-
+
user()->can('deploy', $service)) data-disabled @endif @click="$wire.dispatch('forceDeployEvent')">
@@ -36,7 +36,7 @@
Force Deploy
-
+
user()->can('stop', $service)) data-disabled @endif wire:click='stop(true)''>
Please load a Compose file.
@else
@if (!$application->destination->server->isSwarm())
-
-
-
+
+
+
@endif
-
- @if (!str($application->status)->startsWith('exited'))
- @if (!$application->destination->server->isSwarm())
-
-
-
-
-
-
-
- Redeploy
-
- @endif
- @if ($application->build_pack !== 'dockercompose')
- @if ($application->destination->server->isSwarm())
-
-
-
-
-
-
+
+ @if (!str($application->status)->startsWith('exited'))
+ @if (!$application->destination->server->isSwarm())
+
+
+
+
+
+
- Update Service
-
- @else
-
-
-
-
-
-
-
- Restart
+ Redeploy
@endif
- @endif
-
-
-
-
-
-
-
-
+ @if ($application->build_pack !== 'dockercompose')
+ @if ($application->destination->server->isSwarm())
+
+
+
+
+
+
+
+ Update Service
+
+ @else
+
+
+
+
+
+
+
+ Restart
+
+ @endif
+ @endif
+
+
+
+
+
+
+
+
+
+ Stop
+
+
+ @else
+
+
+
+
- Stop
-
-
- @else
-
-
-
-
-
- Deploy
-
- @endif
-
+ Deploy
+
+ @endif
+
@endif
diff --git a/resources/views/livewire/project/database/heading.blade.php b/resources/views/livewire/project/database/heading.blade.php
index 4087769cc..56c92110f 100644
--- a/resources/views/livewire/project/database/heading.blade.php
+++ b/resources/views/livewire/project/database/heading.blade.php
@@ -38,72 +38,72 @@
@endif
@if ($database->destination->server->isFunctional())
-
- @if (!str($database->status)->startsWith('exited'))
-
-
-
-
-
-
-
-
- Restart
-
-
-
-
-
+ @if (!str($database->status)->startsWith('exited'))
+
+
+
+
+
+
+
+
+ Restart
+
+
+
+
+
+
+
+
+
+
+
+ Stop
+
+
+ @else
+ user()->can('manage', $database)) @click="$wire.dispatch('startEvent')" class="gap-2 button">
+
-
-
-
-
-
+
+
- Stop
-
-
- @else
-
-
-
-
-
- Start
-
- @endif
- @script
-
- @endscript
-
+ Start
+
+ @endif
+ @script
+
+ @endscript
+
@else
Underlying server is not functional.
@endif
diff --git a/resources/views/livewire/project/service/heading.blade.php b/resources/views/livewire/project/service/heading.blade.php
index c33ebc279..af057813c 100644
--- a/resources/views/livewire/project/service/heading.blade.php
+++ b/resources/views/livewire/project/service/heading.blade.php
@@ -30,7 +30,7 @@
@if (str($service->status)->contains('running'))
-
+
@@ -40,7 +40,7 @@
Restart
-
@@ -58,7 +58,7 @@
@elseif (str($service->status)->contains('degraded'))
-
+
@@ -68,7 +68,7 @@
Restart
-
@@ -86,7 +86,7 @@
@elseif (str($service->status)->contains('exited'))
-
+ user()->can('deploy', $service)) @click="$wire.dispatch('startEvent')" class="gap-2 button">
@@ -96,7 +96,7 @@
Deploy
@else
-
@@ -113,7 +113,7 @@
Stop
-
+ user()->can('deploy', $service)) @click="$wire.dispatch('startEvent')" class="gap-2 button">
diff --git a/resources/views/livewire/project/shared/health-checks.blade.php b/resources/views/livewire/project/shared/health-checks.blade.php
index 730353c87..44c0f2fc5 100644
--- a/resources/views/livewire/project/shared/health-checks.blade.php
+++ b/resources/views/livewire/project/shared/health-checks.blade.php
@@ -3,12 +3,12 @@
Healthchecks
Save
@if (!$healthCheckEnabled)
-
-
+
+
@else
Disable Healthcheck
@endif
diff --git a/resources/views/livewire/security/api-tokens.blade.php b/resources/views/livewire/security/api-tokens.blade.php
index 23f0e263e..1a7b6eb79 100644
--- a/resources/views/livewire/security/api-tokens.blade.php
+++ b/resources/views/livewire/security/api-tokens.blade.php
@@ -50,8 +50,13 @@
helper="Write access requires admin or owner role" :checked="false">
@endif
-
+ @if ($canUseDeployPermissions)
+
+ @else
+
+ @endif
{{ data_get($server, 'name') }}
+ class="flex items-center gap-6 overflow-x-scroll sm:overflow-x-hidden scrollbar min-h-10 whitespace-nowrap pt-2">
diff --git a/resources/views/livewire/server/resources.blade.php b/resources/views/livewire/server/resources.blade.php
index 8610cd704..a13f88e83 100644
--- a/resources/views/livewire/server/resources.blade.php
+++ b/resources/views/livewire/server/resources.blade.php
@@ -134,22 +134,22 @@
{{ data_get($resource, 'State') }}
- @if (data_get($resource, 'State') === 'running')
- Restart
- Stop
- @elseif (data_get($resource, 'State') === 'exited')
- Start
- @elseif (data_get($resource, 'State') === 'restarting')
- Stop
- @endif
+ @if (data_get($resource, 'State') === 'running')
+ Restart
+ Stop
+ @elseif (data_get($resource, 'State') === 'exited')
+ Start
+ @elseif (data_get($resource, 'State') === 'restarting')
+ Stop
+ @endif
@endforeach
diff --git a/tasks/lessons.md b/tasks/lessons.md
new file mode 100644
index 000000000..c4e4cc24d
--- /dev/null
+++ b/tasks/lessons.md
@@ -0,0 +1,11 @@
+# Lessons Learned
+
+## Docker / Worktree Setup
+- The Docker dev container mounts from `young-stork` worktree, NOT `ivory-raccoon`
+- Do NOT copy files to `young-stork` or use `docker cp` — only modify files in the `ivory-raccoon` worktree
+- Do NOT use `docker exec` to run tests — work entirely within the `ivory-raccoon` worktree
+
+## Policy Tests
+- Policy methods have typed parameters (e.g., `Server $server`) — anonymous classes cause TypeError
+- Must use `Mockery::mock(Model::class)->makePartial()` instead of anonymous classes for model stubs
+- Use `shouldReceive('getAttribute')->with('property')->andReturn(value)` for model properties accessed via relationship chains
diff --git a/tests/Feature/TeamPolicyTest.php b/tests/Feature/TeamPolicyTest.php
index d6a65e231..53b716095 100644
--- a/tests/Feature/TeamPolicyTest.php
+++ b/tests/Feature/TeamPolicyTest.php
@@ -156,6 +156,54 @@ describe('manageInvitations permission (privilege escalation fix)', function ()
});
});
+describe('create team', function () {
+ test('member can create a new independent team', function () {
+ $this->actingAs($this->member);
+ session(['currentTeam' => $this->team]);
+
+ $newTeam = Team::create([
+ 'name' => 'New Team',
+ 'description' => 'Created by member',
+ 'personal_team' => false,
+ ]);
+
+ expect($newTeam)->toBeInstanceOf(Team::class)
+ ->and($newTeam->name)->toBe('New Team');
+ });
+
+ test('member cannot update an existing team', function () {
+ $this->actingAs($this->member);
+ session(['currentTeam' => $this->team]);
+
+ expect(fn () => $this->team->update(['name' => 'Hacked']))
+ ->toThrow(\Exception::class, 'You are not allowed to update this team.');
+ });
+
+ test('owner can create a new team', function () {
+ $this->actingAs($this->owner);
+ session(['currentTeam' => $this->team]);
+
+ $newTeam = Team::create([
+ 'name' => 'Owner New Team',
+ 'personal_team' => false,
+ ]);
+
+ expect($newTeam)->toBeInstanceOf(Team::class);
+ });
+
+ test('admin can create a new team', function () {
+ $this->actingAs($this->admin);
+ session(['currentTeam' => $this->team]);
+
+ $newTeam = Team::create([
+ 'name' => 'Admin New Team',
+ 'personal_team' => false,
+ ]);
+
+ expect($newTeam)->toBeInstanceOf(Team::class);
+ });
+});
+
describe('view permission', function () {
test('owner can view team', function () {
$this->actingAs($this->owner);
diff --git a/tests/Unit/Policies/ApiTokenPolicyTest.php b/tests/Unit/Policies/ApiTokenPolicyTest.php
new file mode 100644
index 000000000..98b59319a
--- /dev/null
+++ b/tests/Unit/Policies/ApiTokenPolicyTest.php
@@ -0,0 +1,167 @@
+makePartial();
+
+ $policy = new ApiTokenPolicy;
+ expect($policy->viewAny($user))->toBeTrue();
+});
+
+it('allows any user to create api tokens', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $policy = new ApiTokenPolicy;
+ expect($policy->create($user))->toBeTrue();
+});
+
+it('allows any user to manage api tokens', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $policy = new ApiTokenPolicy;
+ expect($policy->manage($user))->toBeTrue();
+});
+
+it('allows owner to view their own api token', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->id = 1;
+
+ $token = Mockery::mock(PersonalAccessToken::class)->makePartial();
+ $token->tokenable_id = 1;
+ $token->tokenable_type = User::class;
+
+ $policy = new ApiTokenPolicy;
+ expect($policy->view($user, $token))->toBeTrue();
+});
+
+it('denies non-owner from viewing api token', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->id = 2;
+
+ $token = Mockery::mock(PersonalAccessToken::class)->makePartial();
+ $token->tokenable_id = 1;
+ $token->tokenable_type = User::class;
+
+ $policy = new ApiTokenPolicy;
+ expect($policy->view($user, $token))->toBeFalse();
+});
+
+it('allows owner to update their own api token', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->id = 1;
+
+ $token = Mockery::mock(PersonalAccessToken::class)->makePartial();
+ $token->tokenable_id = 1;
+ $token->tokenable_type = User::class;
+
+ $policy = new ApiTokenPolicy;
+ expect($policy->update($user, $token))->toBeTrue();
+});
+
+it('denies non-owner from updating api token', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->id = 2;
+
+ $token = Mockery::mock(PersonalAccessToken::class)->makePartial();
+ $token->tokenable_id = 1;
+ $token->tokenable_type = User::class;
+
+ $policy = new ApiTokenPolicy;
+ expect($policy->update($user, $token))->toBeFalse();
+});
+
+it('allows owner to delete their own api token', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->id = 1;
+
+ $token = Mockery::mock(PersonalAccessToken::class)->makePartial();
+ $token->tokenable_id = 1;
+ $token->tokenable_type = User::class;
+
+ $policy = new ApiTokenPolicy;
+ expect($policy->delete($user, $token))->toBeTrue();
+});
+
+it('denies non-owner from deleting api token', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->id = 2;
+
+ $token = Mockery::mock(PersonalAccessToken::class)->makePartial();
+ $token->tokenable_id = 1;
+ $token->tokenable_type = User::class;
+
+ $policy = new ApiTokenPolicy;
+ expect($policy->delete($user, $token))->toBeFalse();
+});
+
+it('allows admin to use root permissions', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(true);
+
+ $policy = new ApiTokenPolicy;
+ expect($policy->useRootPermissions($user))->toBeTrue();
+});
+
+it('allows owner to use root permissions', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(false);
+ $user->shouldReceive('isOwner')->andReturn(true);
+
+ $policy = new ApiTokenPolicy;
+ expect($policy->useRootPermissions($user))->toBeTrue();
+});
+
+it('denies member from using root permissions', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(false);
+ $user->shouldReceive('isOwner')->andReturn(false);
+
+ $policy = new ApiTokenPolicy;
+ expect($policy->useRootPermissions($user))->toBeFalse();
+});
+
+it('allows admin to use write permissions', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(true);
+
+ $policy = new ApiTokenPolicy;
+ expect($policy->useWritePermissions($user))->toBeTrue();
+});
+
+it('denies member from using write permissions', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(false);
+ $user->shouldReceive('isOwner')->andReturn(false);
+
+ $policy = new ApiTokenPolicy;
+ expect($policy->useWritePermissions($user))->toBeFalse();
+});
+
+it('allows admin to use deploy permissions', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(true);
+
+ $policy = new ApiTokenPolicy;
+ expect($policy->useDeployPermissions($user))->toBeTrue();
+});
+
+it('allows owner to use deploy permissions', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(false);
+ $user->shouldReceive('isOwner')->andReturn(true);
+
+ $policy = new ApiTokenPolicy;
+ expect($policy->useDeployPermissions($user))->toBeTrue();
+});
+
+it('denies member from using deploy permissions', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(false);
+ $user->shouldReceive('isOwner')->andReturn(false);
+
+ $policy = new ApiTokenPolicy;
+ expect($policy->useDeployPermissions($user))->toBeFalse();
+});
diff --git a/tests/Unit/Policies/ApplicationPolicyTest.php b/tests/Unit/Policies/ApplicationPolicyTest.php
new file mode 100644
index 000000000..e3c80a4e5
--- /dev/null
+++ b/tests/Unit/Policies/ApplicationPolicyTest.php
@@ -0,0 +1,237 @@
+makePartial();
+
+ $policy = new ApplicationPolicy;
+ expect($policy->viewAny($user))->toBeTrue();
+});
+
+it('allows team member to view their own team application', function () {
+ $teams = collect([
+ (object) ['id' => 1],
+ ]);
+
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
+
+ $application = Mockery::mock(Application::class)->makePartial();
+ $application->shouldReceive('team')->andReturn((object) ['id' => 1]);
+
+ $policy = new ApplicationPolicy;
+ expect($policy->view($user, $application))->toBeTrue();
+});
+
+it('denies non-member to view another team application', function () {
+ $teams = collect([
+ (object) ['id' => 1],
+ ]);
+
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
+
+ $application = Mockery::mock(Application::class)->makePartial();
+ $application->shouldReceive('team')->andReturn((object) ['id' => 2]);
+
+ $policy = new ApplicationPolicy;
+ expect($policy->view($user, $application))->toBeFalse();
+});
+
+it('denies view when application has no team', function () {
+ $teams = collect([
+ (object) ['id' => 1],
+ ]);
+
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
+
+ $application = Mockery::mock(Application::class)->makePartial();
+ $application->shouldReceive('team')->andReturn(null);
+
+ $policy = new ApplicationPolicy;
+ expect($policy->view($user, $application))->toBeFalse();
+});
+
+it('allows admin to create an application', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(true);
+
+ $policy = new ApplicationPolicy;
+ expect($policy->create($user))->toBeTrue();
+});
+
+it('denies member to create an application', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(false);
+
+ $policy = new ApplicationPolicy;
+ expect($policy->create($user))->toBeFalse();
+});
+
+it('allows team admin to update their own team application', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $application = Mockery::mock(Application::class)->makePartial();
+ $application->shouldReceive('team')->andReturn((object) ['id' => 1]);
+
+ $policy = new ApplicationPolicy;
+ expect($policy->update($user, $application)->allowed())->toBeTrue();
+});
+
+it('denies team member to update their own team application', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $application = Mockery::mock(Application::class)->makePartial();
+ $application->shouldReceive('team')->andReturn((object) ['id' => 1]);
+
+ $policy = new ApplicationPolicy;
+ expect($policy->update($user, $application)->allowed())->toBeFalse();
+});
+
+it('denies update when application has no team', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $application = Mockery::mock(Application::class)->makePartial();
+ $application->shouldReceive('team')->andReturn(null);
+
+ $policy = new ApplicationPolicy;
+ expect($policy->update($user, $application)->allowed())->toBeFalse();
+});
+
+it('allows team admin to delete their own team application', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $application = Mockery::mock(Application::class)->makePartial();
+ $application->shouldReceive('team')->andReturn((object) ['id' => 1]);
+
+ $policy = new ApplicationPolicy;
+ expect($policy->delete($user, $application))->toBeTrue();
+});
+
+it('denies team member to delete their own team application', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $application = Mockery::mock(Application::class)->makePartial();
+ $application->shouldReceive('team')->andReturn((object) ['id' => 1]);
+
+ $policy = new ApplicationPolicy;
+ expect($policy->delete($user, $application))->toBeFalse();
+});
+
+it('denies delete when application has no team', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $application = Mockery::mock(Application::class)->makePartial();
+ $application->shouldReceive('team')->andReturn(null);
+
+ $policy = new ApplicationPolicy;
+ expect($policy->delete($user, $application))->toBeFalse();
+});
+
+it('allows team admin to deploy their own team application', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $application = Mockery::mock(Application::class)->makePartial();
+ $application->shouldReceive('team')->andReturn((object) ['id' => 1]);
+
+ $policy = new ApplicationPolicy;
+ expect($policy->deploy($user, $application))->toBeTrue();
+});
+
+it('denies team member to deploy their own team application', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $application = Mockery::mock(Application::class)->makePartial();
+ $application->shouldReceive('team')->andReturn((object) ['id' => 1]);
+
+ $policy = new ApplicationPolicy;
+ expect($policy->deploy($user, $application))->toBeFalse();
+});
+
+it('allows team admin to manage deployments', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $application = Mockery::mock(Application::class)->makePartial();
+ $application->shouldReceive('team')->andReturn((object) ['id' => 1]);
+
+ $policy = new ApplicationPolicy;
+ expect($policy->manageDeployments($user, $application))->toBeTrue();
+});
+
+it('denies team member to manage deployments', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $application = Mockery::mock(Application::class)->makePartial();
+ $application->shouldReceive('team')->andReturn((object) ['id' => 1]);
+
+ $policy = new ApplicationPolicy;
+ expect($policy->manageDeployments($user, $application))->toBeFalse();
+});
+
+it('allows team admin to manage environment variables', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $application = Mockery::mock(Application::class)->makePartial();
+ $application->shouldReceive('team')->andReturn((object) ['id' => 1]);
+
+ $policy = new ApplicationPolicy;
+ expect($policy->manageEnvironment($user, $application))->toBeTrue();
+});
+
+it('denies team member to manage environment variables', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $application = Mockery::mock(Application::class)->makePartial();
+ $application->shouldReceive('team')->andReturn((object) ['id' => 1]);
+
+ $policy = new ApplicationPolicy;
+ expect($policy->manageEnvironment($user, $application))->toBeFalse();
+});
+
+it('allows admin to cleanup deployment queue', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(true);
+
+ $policy = new ApplicationPolicy;
+ expect($policy->cleanupDeploymentQueue($user))->toBeTrue();
+});
+
+it('denies member to cleanup deployment queue', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(false);
+
+ $policy = new ApplicationPolicy;
+ expect($policy->cleanupDeploymentQueue($user))->toBeFalse();
+});
+
+it('denies restore for any user', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $application = Mockery::mock(Application::class)->makePartial();
+
+ $policy = new ApplicationPolicy;
+ expect($policy->restore($user, $application))->toBeFalse();
+});
+
+it('denies force delete for any user', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $application = Mockery::mock(Application::class)->makePartial();
+
+ $policy = new ApplicationPolicy;
+ expect($policy->forceDelete($user, $application))->toBeFalse();
+});
diff --git a/tests/Unit/Policies/ApplicationPreviewPolicyTest.php b/tests/Unit/Policies/ApplicationPreviewPolicyTest.php
new file mode 100644
index 000000000..38e4eef29
--- /dev/null
+++ b/tests/Unit/Policies/ApplicationPreviewPolicyTest.php
@@ -0,0 +1,239 @@
+makePartial();
+
+ $policy = new ApplicationPreviewPolicy;
+ expect($policy->viewAny($user))->toBeTrue();
+});
+
+it('allows team member to view application preview', function () {
+ $teams = collect([
+ (object) ['id' => 1, 'pivot' => (object) ['role' => 'member']],
+ ]);
+
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
+
+ $team = (object) ['id' => 1];
+ $application = Mockery::mock(Application::class)->makePartial();
+ $application->shouldReceive('team')->andReturn($team);
+
+ $preview = Mockery::mock(ApplicationPreview::class)->makePartial();
+ $preview->shouldReceive('getAttribute')->with('application')->andReturn($application);
+
+ $policy = new ApplicationPreviewPolicy;
+ expect($policy->view($user, $preview))->toBeTrue();
+});
+
+it('denies non-team member from viewing application preview', function () {
+ $teams = collect([
+ (object) ['id' => 2, 'pivot' => (object) ['role' => 'member']],
+ ]);
+
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
+
+ $team = (object) ['id' => 1];
+ $application = Mockery::mock(Application::class)->makePartial();
+ $application->shouldReceive('team')->andReturn($team);
+
+ $preview = Mockery::mock(ApplicationPreview::class)->makePartial();
+ $preview->shouldReceive('getAttribute')->with('application')->andReturn($application);
+
+ $policy = new ApplicationPreviewPolicy;
+ expect($policy->view($user, $preview))->toBeFalse();
+});
+
+it('denies viewing application preview with null application', function () {
+ $teams = collect([
+ (object) ['id' => 1, 'pivot' => (object) ['role' => 'admin']],
+ ]);
+
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
+
+ $preview = Mockery::mock(ApplicationPreview::class)->makePartial();
+ $preview->shouldReceive('getAttribute')->with('application')->andReturn(null);
+
+ $policy = new ApplicationPreviewPolicy;
+ expect($policy->view($user, $preview))->toBeFalse();
+});
+
+it('allows admin user to create application preview', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(true);
+
+ $policy = new ApplicationPreviewPolicy;
+ expect($policy->create($user))->toBeTrue();
+});
+
+it('denies non-admin user from creating application preview', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(false);
+
+ $policy = new ApplicationPreviewPolicy;
+ expect($policy->create($user))->toBeFalse();
+});
+
+it('allows team admin to update application preview', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $team = (object) ['id' => 1];
+ $application = Mockery::mock(Application::class)->makePartial();
+ $application->shouldReceive('team')->andReturn($team);
+
+ $preview = Mockery::mock(ApplicationPreview::class)->makePartial();
+ $preview->shouldReceive('getAttribute')->with('application')->andReturn($application);
+
+ $policy = new ApplicationPreviewPolicy;
+ expect($policy->update($user, $preview)->allowed())->toBeTrue();
+});
+
+it('denies team member from updating application preview', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $team = (object) ['id' => 1];
+ $application = Mockery::mock(Application::class)->makePartial();
+ $application->shouldReceive('team')->andReturn($team);
+
+ $preview = Mockery::mock(ApplicationPreview::class)->makePartial();
+ $preview->shouldReceive('getAttribute')->with('application')->andReturn($application);
+
+ $policy = new ApplicationPreviewPolicy;
+ expect($policy->update($user, $preview)->allowed())->toBeFalse();
+});
+
+it('denies updating application preview with null application', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $preview = Mockery::mock(ApplicationPreview::class)->makePartial();
+ $preview->shouldReceive('getAttribute')->with('application')->andReturn(null);
+
+ $policy = new ApplicationPreviewPolicy;
+ $response = $policy->update($user, $preview);
+ expect($response->allowed())->toBeFalse();
+});
+
+it('allows team admin to delete application preview', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $team = (object) ['id' => 1];
+ $application = Mockery::mock(Application::class)->makePartial();
+ $application->shouldReceive('team')->andReturn($team);
+
+ $preview = Mockery::mock(ApplicationPreview::class)->makePartial();
+ $preview->shouldReceive('getAttribute')->with('application')->andReturn($application);
+
+ $policy = new ApplicationPreviewPolicy;
+ expect($policy->delete($user, $preview))->toBeTrue();
+});
+
+it('denies team member from deleting application preview', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $team = (object) ['id' => 1];
+ $application = Mockery::mock(Application::class)->makePartial();
+ $application->shouldReceive('team')->andReturn($team);
+
+ $preview = Mockery::mock(ApplicationPreview::class)->makePartial();
+ $preview->shouldReceive('getAttribute')->with('application')->andReturn($application);
+
+ $policy = new ApplicationPreviewPolicy;
+ expect($policy->delete($user, $preview))->toBeFalse();
+});
+
+it('denies deleting application preview with null application', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $preview = Mockery::mock(ApplicationPreview::class)->makePartial();
+ $preview->shouldReceive('getAttribute')->with('application')->andReturn(null);
+
+ $policy = new ApplicationPreviewPolicy;
+ expect($policy->delete($user, $preview))->toBeFalse();
+});
+
+it('allows team admin to deploy application preview', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $team = (object) ['id' => 1];
+ $application = Mockery::mock(Application::class)->makePartial();
+ $application->shouldReceive('team')->andReturn($team);
+
+ $preview = Mockery::mock(ApplicationPreview::class)->makePartial();
+ $preview->shouldReceive('getAttribute')->with('application')->andReturn($application);
+
+ $policy = new ApplicationPreviewPolicy;
+ expect($policy->deploy($user, $preview))->toBeTrue();
+});
+
+it('denies team member from deploying application preview', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $team = (object) ['id' => 1];
+ $application = Mockery::mock(Application::class)->makePartial();
+ $application->shouldReceive('team')->andReturn($team);
+
+ $preview = Mockery::mock(ApplicationPreview::class)->makePartial();
+ $preview->shouldReceive('getAttribute')->with('application')->andReturn($application);
+
+ $policy = new ApplicationPreviewPolicy;
+ expect($policy->deploy($user, $preview))->toBeFalse();
+});
+
+it('allows team admin to manage preview deployments', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $team = (object) ['id' => 1];
+ $application = Mockery::mock(Application::class)->makePartial();
+ $application->shouldReceive('team')->andReturn($team);
+
+ $preview = Mockery::mock(ApplicationPreview::class)->makePartial();
+ $preview->shouldReceive('getAttribute')->with('application')->andReturn($application);
+
+ $policy = new ApplicationPreviewPolicy;
+ expect($policy->manageDeployments($user, $preview))->toBeTrue();
+});
+
+it('denies team member from managing preview deployments', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $team = (object) ['id' => 1];
+ $application = Mockery::mock(Application::class)->makePartial();
+ $application->shouldReceive('team')->andReturn($team);
+
+ $preview = Mockery::mock(ApplicationPreview::class)->makePartial();
+ $preview->shouldReceive('getAttribute')->with('application')->andReturn($application);
+
+ $policy = new ApplicationPreviewPolicy;
+ expect($policy->manageDeployments($user, $preview))->toBeFalse();
+});
+
+it('denies restoring application preview', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $preview = Mockery::mock(ApplicationPreview::class)->makePartial();
+
+ $policy = new ApplicationPreviewPolicy;
+ expect($policy->restore($user, $preview))->toBeFalse();
+});
+
+it('denies force deleting application preview', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $preview = Mockery::mock(ApplicationPreview::class)->makePartial();
+
+ $policy = new ApplicationPreviewPolicy;
+ expect($policy->forceDelete($user, $preview))->toBeFalse();
+});
diff --git a/tests/Unit/Policies/ApplicationSettingPolicyTest.php b/tests/Unit/Policies/ApplicationSettingPolicyTest.php
new file mode 100644
index 000000000..c595a3e96
--- /dev/null
+++ b/tests/Unit/Policies/ApplicationSettingPolicyTest.php
@@ -0,0 +1,178 @@
+makePartial();
+
+ $policy = new ApplicationSettingPolicy;
+ expect($policy->viewAny($user))->toBeTrue();
+});
+
+it('allows team member to view application setting', function () {
+ $teams = collect([
+ (object) ['id' => 1, 'pivot' => (object) ['role' => 'member']],
+ ]);
+
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
+
+ $team = (object) ['id' => 1];
+ $application = Mockery::mock(Application::class)->makePartial();
+ $application->shouldReceive('team')->andReturn($team);
+
+ $setting = Mockery::mock(ApplicationSetting::class)->makePartial();
+ $setting->shouldReceive('getAttribute')->with('application')->andReturn($application);
+
+ $policy = new ApplicationSettingPolicy;
+ expect($policy->view($user, $setting))->toBeTrue();
+});
+
+it('denies non-team member from viewing application setting', function () {
+ $teams = collect([
+ (object) ['id' => 2, 'pivot' => (object) ['role' => 'member']],
+ ]);
+
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
+
+ $team = (object) ['id' => 1];
+ $application = Mockery::mock(Application::class)->makePartial();
+ $application->shouldReceive('team')->andReturn($team);
+
+ $setting = Mockery::mock(ApplicationSetting::class)->makePartial();
+ $setting->shouldReceive('getAttribute')->with('application')->andReturn($application);
+
+ $policy = new ApplicationSettingPolicy;
+ expect($policy->view($user, $setting))->toBeFalse();
+});
+
+it('denies viewing application setting with null application', function () {
+ $teams = collect([
+ (object) ['id' => 1, 'pivot' => (object) ['role' => 'admin']],
+ ]);
+
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
+
+ $setting = Mockery::mock(ApplicationSetting::class)->makePartial();
+ $setting->shouldReceive('getAttribute')->with('application')->andReturn(null);
+
+ $policy = new ApplicationSettingPolicy;
+ expect($policy->view($user, $setting))->toBeFalse();
+});
+
+it('allows admin user to create application setting', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(true);
+
+ $policy = new ApplicationSettingPolicy;
+ expect($policy->create($user))->toBeTrue();
+});
+
+it('denies non-admin user from creating application setting', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(false);
+
+ $policy = new ApplicationSettingPolicy;
+ expect($policy->create($user))->toBeFalse();
+});
+
+it('allows team admin to update application setting', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $team = (object) ['id' => 1];
+ $application = Mockery::mock(Application::class)->makePartial();
+ $application->shouldReceive('team')->andReturn($team);
+
+ $setting = Mockery::mock(ApplicationSetting::class)->makePartial();
+ $setting->shouldReceive('getAttribute')->with('application')->andReturn($application);
+
+ $policy = new ApplicationSettingPolicy;
+ expect($policy->update($user, $setting))->toBeTrue();
+});
+
+it('denies team member from updating application setting', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $team = (object) ['id' => 1];
+ $application = Mockery::mock(Application::class)->makePartial();
+ $application->shouldReceive('team')->andReturn($team);
+
+ $setting = Mockery::mock(ApplicationSetting::class)->makePartial();
+ $setting->shouldReceive('getAttribute')->with('application')->andReturn($application);
+
+ $policy = new ApplicationSettingPolicy;
+ expect($policy->update($user, $setting))->toBeFalse();
+});
+
+it('denies updating application setting with null application', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $setting = Mockery::mock(ApplicationSetting::class)->makePartial();
+ $setting->shouldReceive('getAttribute')->with('application')->andReturn(null);
+
+ $policy = new ApplicationSettingPolicy;
+ expect($policy->update($user, $setting))->toBeFalse();
+});
+
+it('allows team admin to delete application setting', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $team = (object) ['id' => 1];
+ $application = Mockery::mock(Application::class)->makePartial();
+ $application->shouldReceive('team')->andReturn($team);
+
+ $setting = Mockery::mock(ApplicationSetting::class)->makePartial();
+ $setting->shouldReceive('getAttribute')->with('application')->andReturn($application);
+
+ $policy = new ApplicationSettingPolicy;
+ expect($policy->delete($user, $setting))->toBeTrue();
+});
+
+it('denies team member from deleting application setting', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $team = (object) ['id' => 1];
+ $application = Mockery::mock(Application::class)->makePartial();
+ $application->shouldReceive('team')->andReturn($team);
+
+ $setting = Mockery::mock(ApplicationSetting::class)->makePartial();
+ $setting->shouldReceive('getAttribute')->with('application')->andReturn($application);
+
+ $policy = new ApplicationSettingPolicy;
+ expect($policy->delete($user, $setting))->toBeFalse();
+});
+
+it('denies deleting application setting with null application', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $setting = Mockery::mock(ApplicationSetting::class)->makePartial();
+ $setting->shouldReceive('getAttribute')->with('application')->andReturn(null);
+
+ $policy = new ApplicationSettingPolicy;
+ expect($policy->delete($user, $setting))->toBeFalse();
+});
+
+it('denies restoring application setting', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $setting = Mockery::mock(ApplicationSetting::class)->makePartial();
+
+ $policy = new ApplicationSettingPolicy;
+ expect($policy->restore($user, $setting))->toBeFalse();
+});
+
+it('denies force deleting application setting', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $setting = Mockery::mock(ApplicationSetting::class)->makePartial();
+
+ $policy = new ApplicationSettingPolicy;
+ expect($policy->forceDelete($user, $setting))->toBeFalse();
+});
diff --git a/tests/Unit/Policies/DatabasePolicyTest.php b/tests/Unit/Policies/DatabasePolicyTest.php
new file mode 100644
index 000000000..9924a43cb
--- /dev/null
+++ b/tests/Unit/Policies/DatabasePolicyTest.php
@@ -0,0 +1,221 @@
+makePartial();
+
+ $policy = new DatabasePolicy;
+ expect($policy->viewAny($user))->toBeTrue();
+});
+
+it('allows team member to view their own team database', function () {
+ $teams = collect([
+ (object) ['id' => 1],
+ ]);
+
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
+
+ $database = Mockery::mock(StandalonePostgresql::class)->makePartial();
+ $database->shouldReceive('team')->andReturn((object) ['id' => 1]);
+
+ $policy = new DatabasePolicy;
+ expect($policy->view($user, $database))->toBeTrue();
+});
+
+it('denies non-member to view another team database', function () {
+ $teams = collect([
+ (object) ['id' => 1],
+ ]);
+
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
+
+ $database = Mockery::mock(StandalonePostgresql::class)->makePartial();
+ $database->shouldReceive('team')->andReturn((object) ['id' => 2]);
+
+ $policy = new DatabasePolicy;
+ expect($policy->view($user, $database))->toBeFalse();
+});
+
+it('denies view when database has no team', function () {
+ $teams = collect([
+ (object) ['id' => 1],
+ ]);
+
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
+
+ $database = Mockery::mock(StandalonePostgresql::class)->makePartial();
+ $database->shouldReceive('team')->andReturn(null);
+
+ $policy = new DatabasePolicy;
+ expect($policy->view($user, $database))->toBeFalse();
+});
+
+it('allows admin to create a database', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(true);
+
+ $policy = new DatabasePolicy;
+ expect($policy->create($user))->toBeTrue();
+});
+
+it('denies member to create a database', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(false);
+
+ $policy = new DatabasePolicy;
+ expect($policy->create($user))->toBeFalse();
+});
+
+it('allows team admin to update their own team database', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $database = Mockery::mock(StandalonePostgresql::class)->makePartial();
+ $database->shouldReceive('team')->andReturn((object) ['id' => 1]);
+
+ $policy = new DatabasePolicy;
+ expect($policy->update($user, $database)->allowed())->toBeTrue();
+});
+
+it('denies team member to update their own team database', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $database = Mockery::mock(StandalonePostgresql::class)->makePartial();
+ $database->shouldReceive('team')->andReturn((object) ['id' => 1]);
+
+ $policy = new DatabasePolicy;
+ expect($policy->update($user, $database)->allowed())->toBeFalse();
+});
+
+it('denies update when database has no team', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $database = Mockery::mock(StandalonePostgresql::class)->makePartial();
+ $database->shouldReceive('team')->andReturn(null);
+
+ $policy = new DatabasePolicy;
+ expect($policy->update($user, $database)->allowed())->toBeFalse();
+});
+
+it('allows team admin to delete their own team database', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $database = Mockery::mock(StandalonePostgresql::class)->makePartial();
+ $database->shouldReceive('team')->andReturn((object) ['id' => 1]);
+
+ $policy = new DatabasePolicy;
+ expect($policy->delete($user, $database))->toBeTrue();
+});
+
+it('denies team member to delete their own team database', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $database = Mockery::mock(StandalonePostgresql::class)->makePartial();
+ $database->shouldReceive('team')->andReturn((object) ['id' => 1]);
+
+ $policy = new DatabasePolicy;
+ expect($policy->delete($user, $database))->toBeFalse();
+});
+
+it('denies delete when database has no team', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $database = Mockery::mock(StandalonePostgresql::class)->makePartial();
+ $database->shouldReceive('team')->andReturn(null);
+
+ $policy = new DatabasePolicy;
+ expect($policy->delete($user, $database))->toBeFalse();
+});
+
+it('allows team admin to manage their own team database', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $database = Mockery::mock(StandalonePostgresql::class)->makePartial();
+ $database->shouldReceive('team')->andReturn((object) ['id' => 1]);
+
+ $policy = new DatabasePolicy;
+ expect($policy->manage($user, $database))->toBeTrue();
+});
+
+it('denies team member to manage their own team database', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $database = Mockery::mock(StandalonePostgresql::class)->makePartial();
+ $database->shouldReceive('team')->andReturn((object) ['id' => 1]);
+
+ $policy = new DatabasePolicy;
+ expect($policy->manage($user, $database))->toBeFalse();
+});
+
+it('allows team admin to manage backups', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $database = Mockery::mock(StandalonePostgresql::class)->makePartial();
+ $database->shouldReceive('team')->andReturn((object) ['id' => 1]);
+
+ $policy = new DatabasePolicy;
+ expect($policy->manageBackups($user, $database))->toBeTrue();
+});
+
+it('denies team member to manage backups', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $database = Mockery::mock(StandalonePostgresql::class)->makePartial();
+ $database->shouldReceive('team')->andReturn((object) ['id' => 1]);
+
+ $policy = new DatabasePolicy;
+ expect($policy->manageBackups($user, $database))->toBeFalse();
+});
+
+it('allows team admin to manage database environment variables', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $database = Mockery::mock(StandalonePostgresql::class)->makePartial();
+ $database->shouldReceive('team')->andReturn((object) ['id' => 1]);
+
+ $policy = new DatabasePolicy;
+ expect($policy->manageEnvironment($user, $database))->toBeTrue();
+});
+
+it('denies team member to manage database environment variables', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $database = Mockery::mock(StandalonePostgresql::class)->makePartial();
+ $database->shouldReceive('team')->andReturn((object) ['id' => 1]);
+
+ $policy = new DatabasePolicy;
+ expect($policy->manageEnvironment($user, $database))->toBeFalse();
+});
+
+it('denies restore for any user', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $database = Mockery::mock(StandalonePostgresql::class)->makePartial();
+
+ $policy = new DatabasePolicy;
+ expect($policy->restore($user, $database))->toBeFalse();
+});
+
+it('denies force delete for any user', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $database = Mockery::mock(StandalonePostgresql::class)->makePartial();
+
+ $policy = new DatabasePolicy;
+ expect($policy->forceDelete($user, $database))->toBeFalse();
+});
diff --git a/tests/Unit/Policies/EnvironmentPolicyTest.php b/tests/Unit/Policies/EnvironmentPolicyTest.php
new file mode 100644
index 000000000..3fe8f1703
--- /dev/null
+++ b/tests/Unit/Policies/EnvironmentPolicyTest.php
@@ -0,0 +1,155 @@
+makePartial();
+
+ $policy = new EnvironmentPolicy;
+ expect($policy->viewAny($user))->toBeTrue();
+});
+
+it('allows team member to view their own team environment', function () {
+ $teams = collect([
+ (object) ['id' => 1],
+ ]);
+
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
+
+ $environment = Mockery::mock(Environment::class)->makePartial();
+ $environment->shouldReceive('getAttribute')->with('project')->andReturn((object) ['team_id' => 1]);
+
+ $policy = new EnvironmentPolicy;
+ expect($policy->view($user, $environment))->toBeTrue();
+});
+
+it('denies non-member to view another team environment', function () {
+ $teams = collect([
+ (object) ['id' => 1],
+ ]);
+
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
+
+ $environment = Mockery::mock(Environment::class)->makePartial();
+ $environment->shouldReceive('getAttribute')->with('project')->andReturn((object) ['team_id' => 2]);
+
+ $policy = new EnvironmentPolicy;
+ expect($policy->view($user, $environment))->toBeFalse();
+});
+
+it('denies view when environment has no project', function () {
+ $teams = collect([
+ (object) ['id' => 1],
+ ]);
+
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
+
+ $environment = Mockery::mock(Environment::class)->makePartial();
+ $environment->shouldReceive('getAttribute')->with('project')->andReturn(null);
+
+ $policy = new EnvironmentPolicy;
+ expect($policy->view($user, $environment))->toBeFalse();
+});
+
+it('allows admin to create an environment', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(true);
+
+ $policy = new EnvironmentPolicy;
+ expect($policy->create($user))->toBeTrue();
+});
+
+it('denies member to create an environment', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(false);
+
+ $policy = new EnvironmentPolicy;
+ expect($policy->create($user))->toBeFalse();
+});
+
+it('allows team admin to update their own team environment', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $environment = Mockery::mock(Environment::class)->makePartial();
+ $environment->shouldReceive('getAttribute')->with('project')->andReturn((object) ['team_id' => 1]);
+
+ $policy = new EnvironmentPolicy;
+ expect($policy->update($user, $environment))->toBeTrue();
+});
+
+it('denies team member to update their own team environment', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $environment = Mockery::mock(Environment::class)->makePartial();
+ $environment->shouldReceive('getAttribute')->with('project')->andReturn((object) ['team_id' => 1]);
+
+ $policy = new EnvironmentPolicy;
+ expect($policy->update($user, $environment))->toBeFalse();
+});
+
+it('denies update when environment has no project', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $environment = Mockery::mock(Environment::class)->makePartial();
+ $environment->shouldReceive('getAttribute')->with('project')->andReturn(null);
+
+ $policy = new EnvironmentPolicy;
+ expect($policy->update($user, $environment))->toBeFalse();
+});
+
+it('allows team admin to delete their own team environment', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $environment = Mockery::mock(Environment::class)->makePartial();
+ $environment->shouldReceive('getAttribute')->with('project')->andReturn((object) ['team_id' => 1]);
+
+ $policy = new EnvironmentPolicy;
+ expect($policy->delete($user, $environment))->toBeTrue();
+});
+
+it('denies team member to delete their own team environment', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $environment = Mockery::mock(Environment::class)->makePartial();
+ $environment->shouldReceive('getAttribute')->with('project')->andReturn((object) ['team_id' => 1]);
+
+ $policy = new EnvironmentPolicy;
+ expect($policy->delete($user, $environment))->toBeFalse();
+});
+
+it('denies delete when environment has no project', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $environment = Mockery::mock(Environment::class)->makePartial();
+ $environment->shouldReceive('getAttribute')->with('project')->andReturn(null);
+
+ $policy = new EnvironmentPolicy;
+ expect($policy->delete($user, $environment))->toBeFalse();
+});
+
+it('denies restore for any user', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $environment = Mockery::mock(Environment::class)->makePartial();
+
+ $policy = new EnvironmentPolicy;
+ expect($policy->restore($user, $environment))->toBeFalse();
+});
+
+it('denies force delete for any user', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $environment = Mockery::mock(Environment::class)->makePartial();
+
+ $policy = new EnvironmentPolicy;
+ expect($policy->forceDelete($user, $environment))->toBeFalse();
+});
diff --git a/tests/Unit/Policies/EnvironmentVariablePolicyTest.php b/tests/Unit/Policies/EnvironmentVariablePolicyTest.php
new file mode 100644
index 000000000..d5d3ec410
--- /dev/null
+++ b/tests/Unit/Policies/EnvironmentVariablePolicyTest.php
@@ -0,0 +1,199 @@
+makePartial();
+
+ $policy = new EnvironmentVariablePolicy;
+ expect($policy->viewAny($user))->toBeTrue();
+});
+
+it('allows team member to view environment variable', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->teams = new Collection([(object) ['id' => 1]]);
+
+ $resource = Mockery::mock(Application::class)->makePartial();
+ $resource->shouldReceive('team')->andReturn((object) ['id' => 1]);
+
+ $envVar = Mockery::mock(EnvironmentVariable::class)->makePartial();
+ $envVar->shouldReceive('getAttribute')->with('resourceable')->andReturn($resource);
+
+ $policy = new EnvironmentVariablePolicy;
+ expect($policy->view($user, $envVar))->toBeTrue();
+});
+
+it('denies non-team member from viewing environment variable', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->teams = new Collection([(object) ['id' => 2]]);
+
+ $resource = Mockery::mock(Application::class)->makePartial();
+ $resource->shouldReceive('team')->andReturn((object) ['id' => 1]);
+
+ $envVar = Mockery::mock(EnvironmentVariable::class)->makePartial();
+ $envVar->shouldReceive('getAttribute')->with('resourceable')->andReturn($resource);
+
+ $policy = new EnvironmentVariablePolicy;
+ expect($policy->view($user, $envVar))->toBeFalse();
+});
+
+it('denies view when resourceable is null', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->teams = new Collection([(object) ['id' => 1]]);
+
+ $envVar = Mockery::mock(EnvironmentVariable::class)->makePartial();
+ $envVar->shouldReceive('getAttribute')->with('resourceable')->andReturn(null);
+
+ $policy = new EnvironmentVariablePolicy;
+ expect($policy->view($user, $envVar))->toBeFalse();
+});
+
+it('allows admin to create environment variable', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(true);
+
+ $policy = new EnvironmentVariablePolicy;
+ expect($policy->create($user))->toBeTrue();
+});
+
+it('denies member from creating environment variable', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(false);
+
+ $policy = new EnvironmentVariablePolicy;
+ expect($policy->create($user))->toBeFalse();
+});
+
+it('allows team admin to update environment variable', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $resource = Mockery::mock(Application::class)->makePartial();
+ $resource->shouldReceive('team')->andReturn((object) ['id' => 1]);
+
+ $envVar = Mockery::mock(EnvironmentVariable::class)->makePartial();
+ $envVar->shouldReceive('getAttribute')->with('resourceable')->andReturn($resource);
+
+ $policy = new EnvironmentVariablePolicy;
+ expect($policy->update($user, $envVar))->toBeTrue();
+});
+
+it('denies non-admin from updating environment variable', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $resource = Mockery::mock(Application::class)->makePartial();
+ $resource->shouldReceive('team')->andReturn((object) ['id' => 1]);
+
+ $envVar = Mockery::mock(EnvironmentVariable::class)->makePartial();
+ $envVar->shouldReceive('getAttribute')->with('resourceable')->andReturn($resource);
+
+ $policy = new EnvironmentVariablePolicy;
+ expect($policy->update($user, $envVar))->toBeFalse();
+});
+
+it('denies update when resourceable is null', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $envVar = Mockery::mock(EnvironmentVariable::class)->makePartial();
+ $envVar->shouldReceive('getAttribute')->with('resourceable')->andReturn(null);
+
+ $policy = new EnvironmentVariablePolicy;
+ expect($policy->update($user, $envVar))->toBeFalse();
+});
+
+it('allows team admin to delete environment variable', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $resource = Mockery::mock(Application::class)->makePartial();
+ $resource->shouldReceive('team')->andReturn((object) ['id' => 1]);
+
+ $envVar = Mockery::mock(EnvironmentVariable::class)->makePartial();
+ $envVar->shouldReceive('getAttribute')->with('resourceable')->andReturn($resource);
+
+ $policy = new EnvironmentVariablePolicy;
+ expect($policy->delete($user, $envVar))->toBeTrue();
+});
+
+it('denies non-admin from deleting environment variable', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $resource = Mockery::mock(Application::class)->makePartial();
+ $resource->shouldReceive('team')->andReturn((object) ['id' => 1]);
+
+ $envVar = Mockery::mock(EnvironmentVariable::class)->makePartial();
+ $envVar->shouldReceive('getAttribute')->with('resourceable')->andReturn($resource);
+
+ $policy = new EnvironmentVariablePolicy;
+ expect($policy->delete($user, $envVar))->toBeFalse();
+});
+
+it('denies delete when resourceable is null', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $envVar = Mockery::mock(EnvironmentVariable::class)->makePartial();
+ $envVar->shouldReceive('getAttribute')->with('resourceable')->andReturn(null);
+
+ $policy = new EnvironmentVariablePolicy;
+ expect($policy->delete($user, $envVar))->toBeFalse();
+});
+
+it('denies restore for environment variable', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $envVar = Mockery::mock(EnvironmentVariable::class)->makePartial();
+
+ $policy = new EnvironmentVariablePolicy;
+ expect($policy->restore($user, $envVar))->toBeFalse();
+});
+
+it('denies force delete for environment variable', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $envVar = Mockery::mock(EnvironmentVariable::class)->makePartial();
+
+ $policy = new EnvironmentVariablePolicy;
+ expect($policy->forceDelete($user, $envVar))->toBeFalse();
+});
+
+it('allows team admin to manage environment', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $resource = Mockery::mock(Application::class)->makePartial();
+ $resource->shouldReceive('team')->andReturn((object) ['id' => 1]);
+
+ $envVar = Mockery::mock(EnvironmentVariable::class)->makePartial();
+ $envVar->shouldReceive('getAttribute')->with('resourceable')->andReturn($resource);
+
+ $policy = new EnvironmentVariablePolicy;
+ expect($policy->manageEnvironment($user, $envVar))->toBeTrue();
+});
+
+it('denies non-admin from managing environment', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $resource = Mockery::mock(Application::class)->makePartial();
+ $resource->shouldReceive('team')->andReturn((object) ['id' => 1]);
+
+ $envVar = Mockery::mock(EnvironmentVariable::class)->makePartial();
+ $envVar->shouldReceive('getAttribute')->with('resourceable')->andReturn($resource);
+
+ $policy = new EnvironmentVariablePolicy;
+ expect($policy->manageEnvironment($user, $envVar))->toBeFalse();
+});
+
+it('denies manage environment when resourceable is null', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $envVar = Mockery::mock(EnvironmentVariable::class)->makePartial();
+ $envVar->shouldReceive('getAttribute')->with('resourceable')->andReturn(null);
+
+ $policy = new EnvironmentVariablePolicy;
+ expect($policy->manageEnvironment($user, $envVar))->toBeFalse();
+});
diff --git a/tests/Unit/Policies/GithubAppPolicyTest.php b/tests/Unit/Policies/GithubAppPolicyTest.php
new file mode 100644
index 000000000..1aa09395f
--- /dev/null
+++ b/tests/Unit/Policies/GithubAppPolicyTest.php
@@ -0,0 +1,189 @@
+makePartial();
+
+ $policy = new GithubAppPolicy;
+ expect($policy->viewAny($user))->toBeTrue();
+});
+
+it('allows any user to view system-wide github app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $model = Mockery::mock(GithubApp::class)->makePartial();
+ $model->team_id = 1;
+ $model->is_system_wide = true;
+
+ $policy = new GithubAppPolicy;
+ expect($policy->view($user, $model))->toBeTrue();
+});
+
+it('allows team member to view non-system-wide github app', function () {
+ $teams = collect([
+ (object) ['id' => 1, 'pivot' => (object) ['role' => 'member']],
+ ]);
+
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
+
+ $model = Mockery::mock(GithubApp::class)->makePartial();
+ $model->team_id = 1;
+ $model->is_system_wide = false;
+
+ $policy = new GithubAppPolicy;
+ expect($policy->view($user, $model))->toBeTrue();
+});
+
+it('denies non-team member to view non-system-wide github app', function () {
+ $teams = collect([
+ (object) ['id' => 2, 'pivot' => (object) ['role' => 'member']],
+ ]);
+
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
+
+ $model = Mockery::mock(GithubApp::class)->makePartial();
+ $model->team_id = 1;
+ $model->is_system_wide = false;
+
+ $policy = new GithubAppPolicy;
+ expect($policy->view($user, $model))->toBeFalse();
+});
+
+it('allows admin to create github app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(true);
+
+ $policy = new GithubAppPolicy;
+ expect($policy->create($user))->toBeTrue();
+});
+
+it('denies non-admin to create github app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(false);
+
+ $policy = new GithubAppPolicy;
+ expect($policy->create($user))->toBeFalse();
+});
+
+it('allows user with system access to update system-wide github app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('canAccessSystemResources')->andReturn(true);
+
+ $model = Mockery::mock(GithubApp::class)->makePartial();
+ $model->team_id = 1;
+ $model->is_system_wide = true;
+
+ $policy = new GithubAppPolicy;
+ expect($policy->update($user, $model))->toBeTrue();
+});
+
+it('denies user without system access to update system-wide github app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('canAccessSystemResources')->andReturn(false);
+
+ $model = Mockery::mock(GithubApp::class)->makePartial();
+ $model->team_id = 1;
+ $model->is_system_wide = true;
+
+ $policy = new GithubAppPolicy;
+ expect($policy->update($user, $model))->toBeFalse();
+});
+
+it('allows team admin to update non-system-wide github app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $model = Mockery::mock(GithubApp::class)->makePartial();
+ $model->team_id = 1;
+ $model->is_system_wide = false;
+
+ $policy = new GithubAppPolicy;
+ expect($policy->update($user, $model))->toBeTrue();
+});
+
+it('denies team member to update non-system-wide github app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $model = Mockery::mock(GithubApp::class)->makePartial();
+ $model->team_id = 1;
+ $model->is_system_wide = false;
+
+ $policy = new GithubAppPolicy;
+ expect($policy->update($user, $model))->toBeFalse();
+});
+
+it('allows user with system access to delete system-wide github app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('canAccessSystemResources')->andReturn(true);
+
+ $model = Mockery::mock(GithubApp::class)->makePartial();
+ $model->team_id = 1;
+ $model->is_system_wide = true;
+
+ $policy = new GithubAppPolicy;
+ expect($policy->delete($user, $model))->toBeTrue();
+});
+
+it('denies user without system access to delete system-wide github app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('canAccessSystemResources')->andReturn(false);
+
+ $model = Mockery::mock(GithubApp::class)->makePartial();
+ $model->team_id = 1;
+ $model->is_system_wide = true;
+
+ $policy = new GithubAppPolicy;
+ expect($policy->delete($user, $model))->toBeFalse();
+});
+
+it('allows team admin to delete non-system-wide github app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $model = Mockery::mock(GithubApp::class)->makePartial();
+ $model->team_id = 1;
+ $model->is_system_wide = false;
+
+ $policy = new GithubAppPolicy;
+ expect($policy->delete($user, $model))->toBeTrue();
+});
+
+it('denies team member to delete non-system-wide github app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $model = Mockery::mock(GithubApp::class)->makePartial();
+ $model->team_id = 1;
+ $model->is_system_wide = false;
+
+ $policy = new GithubAppPolicy;
+ expect($policy->delete($user, $model))->toBeFalse();
+});
+
+it('denies restore of github app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $model = Mockery::mock(GithubApp::class)->makePartial();
+ $model->team_id = 1;
+ $model->is_system_wide = false;
+
+ $policy = new GithubAppPolicy;
+ expect($policy->restore($user, $model))->toBeFalse();
+});
+
+it('denies force delete of github app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $model = Mockery::mock(GithubApp::class)->makePartial();
+ $model->team_id = 1;
+ $model->is_system_wide = false;
+
+ $policy = new GithubAppPolicy;
+ expect($policy->forceDelete($user, $model))->toBeFalse();
+});
diff --git a/tests/Unit/Policies/NotificationPolicyTest.php b/tests/Unit/Policies/NotificationPolicyTest.php
new file mode 100644
index 000000000..0843a71c3
--- /dev/null
+++ b/tests/Unit/Policies/NotificationPolicyTest.php
@@ -0,0 +1,175 @@
+ 1, 'pivot' => (object) ['role' => 'member']],
+ ]);
+
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
+
+ $notification = Mockery::mock(Model::class)->makePartial();
+ $notification->shouldReceive('getAttribute')->with('team')->andReturn((object) ['id' => 1]);
+
+ $policy = new NotificationPolicy;
+ expect($policy->view($user, $notification))->toBeTrue();
+});
+
+it('denies non-team member from viewing notification settings', function () {
+ $teams = collect([
+ (object) ['id' => 2, 'pivot' => (object) ['role' => 'member']],
+ ]);
+
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
+
+ $notification = Mockery::mock(Model::class)->makePartial();
+ $notification->shouldReceive('getAttribute')->with('team')->andReturn((object) ['id' => 1]);
+
+ $policy = new NotificationPolicy;
+ expect($policy->view($user, $notification))->toBeFalse();
+});
+
+it('denies viewing notification settings with no team', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $notification = Mockery::mock(Model::class)->makePartial();
+ $notification->shouldReceive('getAttribute')->with('team')->andReturn(null);
+
+ $policy = new NotificationPolicy;
+ expect($policy->view($user, $notification))->toBeFalse();
+});
+
+it('allows team admin to update notification settings', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $notification = Mockery::mock(Model::class)->makePartial();
+ $notification->shouldReceive('getAttribute')->with('team')->andReturn((object) ['id' => 1]);
+
+ $policy = new NotificationPolicy;
+ expect($policy->update($user, $notification))->toBeTrue();
+});
+
+it('denies team member from updating notification settings', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $notification = Mockery::mock(Model::class)->makePartial();
+ $notification->shouldReceive('getAttribute')->with('team')->andReturn((object) ['id' => 1]);
+
+ $policy = new NotificationPolicy;
+ expect($policy->update($user, $notification))->toBeFalse();
+});
+
+it('denies updating notification settings with no team', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $notification = Mockery::mock(Model::class)->makePartial();
+ $notification->shouldReceive('getAttribute')->with('team')->andReturn(null);
+
+ $policy = new NotificationPolicy;
+ expect($policy->update($user, $notification))->toBeFalse();
+});
+
+it('allows team admin to manage notification settings', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $notification = Mockery::mock(Model::class)->makePartial();
+ $notification->shouldReceive('getAttribute')->with('team')->andReturn((object) ['id' => 1]);
+
+ $policy = new NotificationPolicy;
+ expect($policy->manage($user, $notification))->toBeTrue();
+});
+
+it('denies team member from managing notification settings', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $notification = Mockery::mock(Model::class)->makePartial();
+ $notification->shouldReceive('getAttribute')->with('team')->andReturn((object) ['id' => 1]);
+
+ $policy = new NotificationPolicy;
+ expect($policy->manage($user, $notification))->toBeFalse();
+});
+
+it('denies managing notification settings with no team', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $notification = Mockery::mock(Model::class)->makePartial();
+ $notification->shouldReceive('getAttribute')->with('team')->andReturn(null);
+
+ $policy = new NotificationPolicy;
+ expect($policy->manage($user, $notification))->toBeFalse();
+});
+
+it('allows team admin to send test notification', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $notification = Mockery::mock(Model::class)->makePartial();
+ $notification->shouldReceive('getAttribute')->with('team')->andReturn((object) ['id' => 1]);
+
+ $policy = new NotificationPolicy;
+ expect($policy->sendTest($user, $notification))->toBeTrue();
+});
+
+it('denies team member from sending test notification', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $notification = Mockery::mock(Model::class)->makePartial();
+ $notification->shouldReceive('getAttribute')->with('team')->andReturn((object) ['id' => 1]);
+
+ $policy = new NotificationPolicy;
+ expect($policy->sendTest($user, $notification))->toBeFalse();
+});
+
+it('denies sending test notification with no team', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $notification = Mockery::mock(Model::class)->makePartial();
+ $notification->shouldReceive('getAttribute')->with('team')->andReturn(null);
+
+ $policy = new NotificationPolicy;
+ expect($policy->sendTest($user, $notification))->toBeFalse();
+});
+
+it('allows team member to view but not update notification settings', function () {
+ $teams = collect([
+ (object) ['id' => 1, 'pivot' => (object) ['role' => 'member']],
+ ]);
+
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $notification = Mockery::mock(Model::class)->makePartial();
+ $notification->shouldReceive('getAttribute')->with('team')->andReturn((object) ['id' => 1]);
+
+ $policy = new NotificationPolicy;
+ expect($policy->view($user, $notification))->toBeTrue();
+ expect($policy->update($user, $notification))->toBeFalse();
+});
+
+it('allows team admin to view and update notification settings', function () {
+ $teams = collect([
+ (object) ['id' => 1, 'pivot' => (object) ['role' => 'admin']],
+ ]);
+
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $notification = Mockery::mock(Model::class)->makePartial();
+ $notification->shouldReceive('getAttribute')->with('team')->andReturn((object) ['id' => 1]);
+
+ $policy = new NotificationPolicy;
+ expect($policy->view($user, $notification))->toBeTrue();
+ expect($policy->update($user, $notification))->toBeTrue();
+});
diff --git a/tests/Unit/Policies/PrivateKeyPolicyTest.php b/tests/Unit/Policies/PrivateKeyPolicyTest.php
index 6844d92f7..281fdd5f8 100644
--- a/tests/Unit/Policies/PrivateKeyPolicyTest.php
+++ b/tests/Unit/Policies/PrivateKeyPolicyTest.php
@@ -1,5 +1,6 @@
makePartial();
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
- $privateKey = new class
- {
- public $team_id = 0;
- };
+ $privateKey = Mockery::mock(PrivateKey::class)->makePartial();
+ $privateKey->team_id = 0;
$policy = new PrivateKeyPolicy;
expect($policy->view($user, $privateKey))->toBeTrue();
@@ -28,10 +27,8 @@ it('allows root team owner to view system private key', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
- $privateKey = new class
- {
- public $team_id = 0;
- };
+ $privateKey = Mockery::mock(PrivateKey::class)->makePartial();
+ $privateKey->team_id = 0;
$policy = new PrivateKeyPolicy;
expect($policy->view($user, $privateKey))->toBeTrue();
@@ -45,10 +42,8 @@ it('denies regular member of root team to view system private key', function ()
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
- $privateKey = new class
- {
- public $team_id = 0;
- };
+ $privateKey = Mockery::mock(PrivateKey::class)->makePartial();
+ $privateKey->team_id = 0;
$policy = new PrivateKeyPolicy;
expect($policy->view($user, $privateKey))->toBeFalse();
@@ -62,10 +57,8 @@ it('denies non-root team member to view system private key', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
- $privateKey = new class
- {
- public $team_id = 0;
- };
+ $privateKey = Mockery::mock(PrivateKey::class)->makePartial();
+ $privateKey->team_id = 0;
$policy = new PrivateKeyPolicy;
expect($policy->view($user, $privateKey))->toBeFalse();
@@ -79,10 +72,8 @@ it('allows team member to view their own team private key', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
- $privateKey = new class
- {
- public $team_id = 1;
- };
+ $privateKey = Mockery::mock(PrivateKey::class)->makePartial();
+ $privateKey->team_id = 1;
$policy = new PrivateKeyPolicy;
expect($policy->view($user, $privateKey))->toBeTrue();
@@ -96,10 +87,8 @@ it('denies team member to view another team private key', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
- $privateKey = new class
- {
- public $team_id = 2;
- };
+ $privateKey = Mockery::mock(PrivateKey::class)->makePartial();
+ $privateKey->team_id = 2;
$policy = new PrivateKeyPolicy;
expect($policy->view($user, $privateKey))->toBeFalse();
@@ -113,10 +102,8 @@ it('allows root team admin to update system private key', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
- $privateKey = new class
- {
- public $team_id = 0;
- };
+ $privateKey = Mockery::mock(PrivateKey::class)->makePartial();
+ $privateKey->team_id = 0;
$policy = new PrivateKeyPolicy;
expect($policy->update($user, $privateKey))->toBeTrue();
@@ -130,10 +117,8 @@ it('denies root team member to update system private key', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
- $privateKey = new class
- {
- public $team_id = 0;
- };
+ $privateKey = Mockery::mock(PrivateKey::class)->makePartial();
+ $privateKey->team_id = 0;
$policy = new PrivateKeyPolicy;
expect($policy->update($user, $privateKey))->toBeFalse();
@@ -147,10 +132,8 @@ it('allows team admin to update their own team private key', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
- $privateKey = new class
- {
- public $team_id = 1;
- };
+ $privateKey = Mockery::mock(PrivateKey::class)->makePartial();
+ $privateKey->team_id = 1;
$policy = new PrivateKeyPolicy;
expect($policy->update($user, $privateKey))->toBeTrue();
@@ -164,10 +147,8 @@ it('denies team member to update their own team private key', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
- $privateKey = new class
- {
- public $team_id = 1;
- };
+ $privateKey = Mockery::mock(PrivateKey::class)->makePartial();
+ $privateKey->team_id = 1;
$policy = new PrivateKeyPolicy;
expect($policy->update($user, $privateKey))->toBeFalse();
@@ -181,10 +162,8 @@ it('allows root team admin to delete system private key', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
- $privateKey = new class
- {
- public $team_id = 0;
- };
+ $privateKey = Mockery::mock(PrivateKey::class)->makePartial();
+ $privateKey->team_id = 0;
$policy = new PrivateKeyPolicy;
expect($policy->delete($user, $privateKey))->toBeTrue();
@@ -198,10 +177,8 @@ it('denies root team member to delete system private key', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
- $privateKey = new class
- {
- public $team_id = 0;
- };
+ $privateKey = Mockery::mock(PrivateKey::class)->makePartial();
+ $privateKey->team_id = 0;
$policy = new PrivateKeyPolicy;
expect($policy->delete($user, $privateKey))->toBeFalse();
diff --git a/tests/Unit/Policies/ProjectPolicyTest.php b/tests/Unit/Policies/ProjectPolicyTest.php
new file mode 100644
index 000000000..8b4a824f6
--- /dev/null
+++ b/tests/Unit/Policies/ProjectPolicyTest.php
@@ -0,0 +1,122 @@
+makePartial();
+
+ $policy = new ProjectPolicy;
+ expect($policy->viewAny($user))->toBeTrue();
+});
+
+it('allows team member to view their own team project', function () {
+ $teams = collect([
+ (object) ['id' => 1],
+ ]);
+
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
+
+ $project = Mockery::mock(Project::class)->makePartial();
+ $project->team_id = 1;
+
+ $policy = new ProjectPolicy;
+ expect($policy->view($user, $project))->toBeTrue();
+});
+
+it('denies non-member to view another team project', function () {
+ $teams = collect([
+ (object) ['id' => 1],
+ ]);
+
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
+
+ $project = Mockery::mock(Project::class)->makePartial();
+ $project->team_id = 2;
+
+ $policy = new ProjectPolicy;
+ expect($policy->view($user, $project))->toBeFalse();
+});
+
+it('allows admin to create a project', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(true);
+
+ $policy = new ProjectPolicy;
+ expect($policy->create($user))->toBeTrue();
+});
+
+it('denies member to create a project', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(false);
+
+ $policy = new ProjectPolicy;
+ expect($policy->create($user))->toBeFalse();
+});
+
+it('allows team admin to update their own team project', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $project = Mockery::mock(Project::class)->makePartial();
+ $project->team_id = 1;
+
+ $policy = new ProjectPolicy;
+ expect($policy->update($user, $project))->toBeTrue();
+});
+
+it('denies team member to update their own team project', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $project = Mockery::mock(Project::class)->makePartial();
+ $project->team_id = 1;
+
+ $policy = new ProjectPolicy;
+ expect($policy->update($user, $project))->toBeFalse();
+});
+
+it('allows team admin to delete their own team project', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $project = Mockery::mock(Project::class)->makePartial();
+ $project->team_id = 1;
+
+ $policy = new ProjectPolicy;
+ expect($policy->delete($user, $project))->toBeTrue();
+});
+
+it('denies team member to delete their own team project', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $project = Mockery::mock(Project::class)->makePartial();
+ $project->team_id = 1;
+
+ $policy = new ProjectPolicy;
+ expect($policy->delete($user, $project))->toBeFalse();
+});
+
+it('denies restore for any user', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $project = Mockery::mock(Project::class)->makePartial();
+ $project->team_id = 1;
+
+ $policy = new ProjectPolicy;
+ expect($policy->restore($user, $project))->toBeFalse();
+});
+
+it('denies force delete for any user', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $project = Mockery::mock(Project::class)->makePartial();
+ $project->team_id = 1;
+
+ $policy = new ProjectPolicy;
+ expect($policy->forceDelete($user, $project))->toBeFalse();
+});
diff --git a/tests/Unit/Policies/ResourceCreatePolicyTest.php b/tests/Unit/Policies/ResourceCreatePolicyTest.php
new file mode 100644
index 000000000..606535467
--- /dev/null
+++ b/tests/Unit/Policies/ResourceCreatePolicyTest.php
@@ -0,0 +1,61 @@
+makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(true);
+
+ $policy = new ResourceCreatePolicy;
+ expect($policy->createAny($user))->toBeTrue();
+});
+
+it('denies member from creating any resource', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(false);
+
+ $policy = new ResourceCreatePolicy;
+ expect($policy->createAny($user))->toBeFalse();
+});
+
+it('allows admin to create a valid resource class', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(true);
+
+ $policy = new ResourceCreatePolicy;
+ expect($policy->create($user, Application::class))->toBeTrue();
+});
+
+it('denies member from creating a valid resource class', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(false);
+
+ $policy = new ResourceCreatePolicy;
+ expect($policy->create($user, Application::class))->toBeFalse();
+});
+
+it('denies admin from creating an invalid resource class', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(true);
+
+ $policy = new ResourceCreatePolicy;
+ expect($policy->create($user, 'App\Models\NonExistent'))->toBeFalse();
+});
+
+it('allows admin to authorize all resource creation', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(true);
+
+ $policy = new ResourceCreatePolicy;
+ expect($policy->authorizeAllResourceCreation($user))->toBeTrue();
+});
+
+it('denies member from authorizing all resource creation', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(false);
+
+ $policy = new ResourceCreatePolicy;
+ expect($policy->authorizeAllResourceCreation($user))->toBeFalse();
+});
diff --git a/tests/Unit/Policies/ServerPolicyTest.php b/tests/Unit/Policies/ServerPolicyTest.php
new file mode 100644
index 000000000..afa64d090
--- /dev/null
+++ b/tests/Unit/Policies/ServerPolicyTest.php
@@ -0,0 +1,157 @@
+makePartial();
+
+ $policy = new ServerPolicy;
+ expect($policy->viewAny($user))->toBeTrue();
+});
+
+it('allows team member to view their own team server', function () {
+ $teams = collect([
+ (object) ['id' => 1],
+ ]);
+
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
+
+ $server = Mockery::mock(Server::class)->makePartial();
+ $server->team_id = 1;
+
+ $policy = new ServerPolicy;
+ expect($policy->view($user, $server))->toBeTrue();
+});
+
+it('denies non-member to view another team server', function () {
+ $teams = collect([
+ (object) ['id' => 1],
+ ]);
+
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
+
+ $server = Mockery::mock(Server::class)->makePartial();
+ $server->team_id = 2;
+
+ $policy = new ServerPolicy;
+ expect($policy->view($user, $server))->toBeFalse();
+});
+
+it('allows admin to create a server', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(true);
+
+ $policy = new ServerPolicy;
+ expect($policy->create($user))->toBeTrue();
+});
+
+it('denies member to create a server', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(false);
+
+ $policy = new ServerPolicy;
+ expect($policy->create($user))->toBeFalse();
+});
+
+it('allows team admin to update their own team server', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $server = Mockery::mock(Server::class)->makePartial();
+ $server->team_id = 1;
+
+ $policy = new ServerPolicy;
+ expect($policy->update($user, $server))->toBeTrue();
+});
+
+it('denies team member to update their own team server', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $server = Mockery::mock(Server::class)->makePartial();
+ $server->team_id = 1;
+
+ $policy = new ServerPolicy;
+ expect($policy->update($user, $server))->toBeFalse();
+});
+
+it('allows team admin to delete their own team server', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $server = Mockery::mock(Server::class)->makePartial();
+ $server->team_id = 1;
+
+ $policy = new ServerPolicy;
+ expect($policy->delete($user, $server))->toBeTrue();
+});
+
+it('denies team member to delete their own team server', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $server = Mockery::mock(Server::class)->makePartial();
+ $server->team_id = 1;
+
+ $policy = new ServerPolicy;
+ expect($policy->delete($user, $server))->toBeFalse();
+});
+
+it('denies restore for any user', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $server = Mockery::mock(Server::class)->makePartial();
+ $server->team_id = 1;
+
+ $policy = new ServerPolicy;
+ expect($policy->restore($user, $server))->toBeFalse();
+});
+
+it('denies force delete for any user', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $server = Mockery::mock(Server::class)->makePartial();
+ $server->team_id = 1;
+
+ $policy = new ServerPolicy;
+ expect($policy->forceDelete($user, $server))->toBeFalse();
+});
+
+it('allows team admin to manage proxy', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $server = Mockery::mock(Server::class)->makePartial();
+ $server->team_id = 1;
+
+ $policy = new ServerPolicy;
+ expect($policy->manageProxy($user, $server))->toBeTrue();
+});
+
+it('denies team member to manage proxy', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $server = Mockery::mock(Server::class)->makePartial();
+ $server->team_id = 1;
+
+ $policy = new ServerPolicy;
+ expect($policy->manageProxy($user, $server))->toBeFalse();
+});
+
+it('allows team admin to manage sentinel, ca certificate, and view security', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $server = Mockery::mock(Server::class)->makePartial();
+ $server->team_id = 1;
+
+ $policy = new ServerPolicy;
+ expect($policy->manageSentinel($user, $server))->toBeTrue();
+ expect($policy->manageCaCertificate($user, $server))->toBeTrue();
+ expect($policy->viewSecurity($user, $server))->toBeTrue();
+});
diff --git a/tests/Unit/Policies/ServiceApplicationPolicyTest.php b/tests/Unit/Policies/ServiceApplicationPolicyTest.php
new file mode 100644
index 000000000..37d68c73e
--- /dev/null
+++ b/tests/Unit/Policies/ServiceApplicationPolicyTest.php
@@ -0,0 +1,37 @@
+makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(true);
+
+ $policy = new ServiceApplicationPolicy;
+ expect($policy->create($user))->toBeTrue();
+});
+
+it('denies member from creating service application', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(false);
+
+ $policy = new ServiceApplicationPolicy;
+ expect($policy->create($user))->toBeFalse();
+});
+
+it('denies restore for service application', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $serviceApp = Mockery::mock(ServiceApplication::class)->makePartial();
+
+ $policy = new ServiceApplicationPolicy;
+ expect($policy->restore($user, $serviceApp))->toBeFalse();
+});
+
+it('denies force delete for service application', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $serviceApp = Mockery::mock(ServiceApplication::class)->makePartial();
+
+ $policy = new ServiceApplicationPolicy;
+ expect($policy->forceDelete($user, $serviceApp))->toBeFalse();
+});
diff --git a/tests/Unit/Policies/ServiceDatabasePolicyTest.php b/tests/Unit/Policies/ServiceDatabasePolicyTest.php
new file mode 100644
index 000000000..44ddd3942
--- /dev/null
+++ b/tests/Unit/Policies/ServiceDatabasePolicyTest.php
@@ -0,0 +1,37 @@
+makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(true);
+
+ $policy = new ServiceDatabasePolicy;
+ expect($policy->create($user))->toBeTrue();
+});
+
+it('denies member from creating service database', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(false);
+
+ $policy = new ServiceDatabasePolicy;
+ expect($policy->create($user))->toBeFalse();
+});
+
+it('denies restore for service database', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $serviceDb = Mockery::mock(ServiceDatabase::class)->makePartial();
+
+ $policy = new ServiceDatabasePolicy;
+ expect($policy->restore($user, $serviceDb))->toBeFalse();
+});
+
+it('denies force delete for service database', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $serviceDb = Mockery::mock(ServiceDatabase::class)->makePartial();
+
+ $policy = new ServiceDatabasePolicy;
+ expect($policy->forceDelete($user, $serviceDb))->toBeFalse();
+});
diff --git a/tests/Unit/Policies/ServicePolicyTest.php b/tests/Unit/Policies/ServicePolicyTest.php
new file mode 100644
index 000000000..166fbc617
--- /dev/null
+++ b/tests/Unit/Policies/ServicePolicyTest.php
@@ -0,0 +1,233 @@
+makePartial();
+
+ $policy = new ServicePolicy;
+ expect($policy->viewAny($user))->toBeTrue();
+});
+
+it('allows team member to view their own team service', function () {
+ $teams = collect([
+ (object) ['id' => 1],
+ ]);
+
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
+
+ $service = Mockery::mock(Service::class)->makePartial();
+ $service->shouldReceive('team')->andReturn((object) ['id' => 1]);
+
+ $policy = new ServicePolicy;
+ expect($policy->view($user, $service))->toBeTrue();
+});
+
+it('denies non-member to view another team service', function () {
+ $teams = collect([
+ (object) ['id' => 1],
+ ]);
+
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
+
+ $service = Mockery::mock(Service::class)->makePartial();
+ $service->shouldReceive('team')->andReturn((object) ['id' => 2]);
+
+ $policy = new ServicePolicy;
+ expect($policy->view($user, $service))->toBeFalse();
+});
+
+it('denies view when service has no team', function () {
+ $teams = collect([
+ (object) ['id' => 1],
+ ]);
+
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
+
+ $service = Mockery::mock(Service::class)->makePartial();
+ $service->shouldReceive('team')->andReturn(null);
+
+ $policy = new ServicePolicy;
+ expect($policy->view($user, $service))->toBeFalse();
+});
+
+it('allows admin to create a service', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(true);
+
+ $policy = new ServicePolicy;
+ expect($policy->create($user))->toBeTrue();
+});
+
+it('denies member to create a service', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(false);
+
+ $policy = new ServicePolicy;
+ expect($policy->create($user))->toBeFalse();
+});
+
+it('allows team admin to update their own team service', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $service = Mockery::mock(Service::class)->makePartial();
+ $service->shouldReceive('team')->andReturn((object) ['id' => 1]);
+
+ $policy = new ServicePolicy;
+ expect($policy->update($user, $service))->toBeTrue();
+});
+
+it('denies team member to update their own team service', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $service = Mockery::mock(Service::class)->makePartial();
+ $service->shouldReceive('team')->andReturn((object) ['id' => 1]);
+
+ $policy = new ServicePolicy;
+ expect($policy->update($user, $service))->toBeFalse();
+});
+
+it('allows team admin to delete their own team service', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $service = Mockery::mock(Service::class)->makePartial();
+ $service->shouldReceive('team')->andReturn((object) ['id' => 1]);
+
+ $policy = new ServicePolicy;
+ expect($policy->delete($user, $service))->toBeTrue();
+});
+
+it('denies team member to delete their own team service', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $service = Mockery::mock(Service::class)->makePartial();
+ $service->shouldReceive('team')->andReturn((object) ['id' => 1]);
+
+ $policy = new ServicePolicy;
+ expect($policy->delete($user, $service))->toBeFalse();
+});
+
+it('denies delete when service has no team', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $service = Mockery::mock(Service::class)->makePartial();
+ $service->shouldReceive('team')->andReturn(null);
+
+ $policy = new ServicePolicy;
+ expect($policy->delete($user, $service))->toBeFalse();
+});
+
+it('allows team admin to deploy their own team service', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $service = Mockery::mock(Service::class)->makePartial();
+ $service->shouldReceive('team')->andReturn((object) ['id' => 1]);
+
+ $policy = new ServicePolicy;
+ expect($policy->deploy($user, $service))->toBeTrue();
+});
+
+it('denies team member to deploy their own team service', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $service = Mockery::mock(Service::class)->makePartial();
+ $service->shouldReceive('team')->andReturn((object) ['id' => 1]);
+
+ $policy = new ServicePolicy;
+ expect($policy->deploy($user, $service))->toBeFalse();
+});
+
+it('allows team admin to stop their own team service', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $service = Mockery::mock(Service::class)->makePartial();
+ $service->shouldReceive('team')->andReturn((object) ['id' => 1]);
+
+ $policy = new ServicePolicy;
+ expect($policy->stop($user, $service))->toBeTrue();
+});
+
+it('denies team member to stop their own team service', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $service = Mockery::mock(Service::class)->makePartial();
+ $service->shouldReceive('team')->andReturn((object) ['id' => 1]);
+
+ $policy = new ServicePolicy;
+ expect($policy->stop($user, $service))->toBeFalse();
+});
+
+it('allows team admin to manage service environment variables', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $service = Mockery::mock(Service::class)->makePartial();
+ $service->shouldReceive('team')->andReturn((object) ['id' => 1]);
+
+ $policy = new ServicePolicy;
+ expect($policy->manageEnvironment($user, $service))->toBeTrue();
+});
+
+it('denies team member to manage service environment variables', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $service = Mockery::mock(Service::class)->makePartial();
+ $service->shouldReceive('team')->andReturn((object) ['id' => 1]);
+
+ $policy = new ServicePolicy;
+ expect($policy->manageEnvironment($user, $service))->toBeFalse();
+});
+
+it('allows team admin to access terminal', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $service = Mockery::mock(Service::class)->makePartial();
+ $service->shouldReceive('team')->andReturn((object) ['id' => 1]);
+
+ $policy = new ServicePolicy;
+ expect($policy->accessTerminal($user, $service))->toBeTrue();
+});
+
+it('denies team member to access terminal', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $service = Mockery::mock(Service::class)->makePartial();
+ $service->shouldReceive('team')->andReturn((object) ['id' => 1]);
+
+ $policy = new ServicePolicy;
+ expect($policy->accessTerminal($user, $service))->toBeFalse();
+});
+
+it('denies restore for any user', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $service = Mockery::mock(Service::class)->makePartial();
+
+ $policy = new ServicePolicy;
+ expect($policy->restore($user, $service))->toBeFalse();
+});
+
+it('denies force delete for any user', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $service = Mockery::mock(Service::class)->makePartial();
+
+ $policy = new ServicePolicy;
+ expect($policy->forceDelete($user, $service))->toBeFalse();
+});
diff --git a/tests/Unit/Policies/SharedEnvironmentVariablePolicyTest.php b/tests/Unit/Policies/SharedEnvironmentVariablePolicyTest.php
new file mode 100644
index 000000000..e82fbdd85
--- /dev/null
+++ b/tests/Unit/Policies/SharedEnvironmentVariablePolicyTest.php
@@ -0,0 +1,144 @@
+makePartial();
+
+ $policy = new SharedEnvironmentVariablePolicy;
+ expect($policy->viewAny($user))->toBeTrue();
+});
+
+it('allows team member to view their team shared environment variable', function () {
+ $teams = collect([
+ (object) ['id' => 1, 'pivot' => (object) ['role' => 'member']],
+ ]);
+
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
+
+ $model = Mockery::mock(SharedEnvironmentVariable::class)->makePartial();
+ $model->team_id = 1;
+
+ $policy = new SharedEnvironmentVariablePolicy;
+ expect($policy->view($user, $model))->toBeTrue();
+});
+
+it('denies non-team member to view shared environment variable', function () {
+ $teams = collect([
+ (object) ['id' => 1, 'pivot' => (object) ['role' => 'member']],
+ ]);
+
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
+
+ $model = Mockery::mock(SharedEnvironmentVariable::class)->makePartial();
+ $model->team_id = 2;
+
+ $policy = new SharedEnvironmentVariablePolicy;
+ expect($policy->view($user, $model))->toBeFalse();
+});
+
+it('allows admin to create shared environment variable', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(true);
+
+ $policy = new SharedEnvironmentVariablePolicy;
+ expect($policy->create($user))->toBeTrue();
+});
+
+it('denies non-admin to create shared environment variable', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(false);
+
+ $policy = new SharedEnvironmentVariablePolicy;
+ expect($policy->create($user))->toBeFalse();
+});
+
+it('allows team admin to update shared environment variable', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $model = Mockery::mock(SharedEnvironmentVariable::class)->makePartial();
+ $model->team_id = 1;
+
+ $policy = new SharedEnvironmentVariablePolicy;
+ expect($policy->update($user, $model))->toBeTrue();
+});
+
+it('denies team member to update shared environment variable', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $model = Mockery::mock(SharedEnvironmentVariable::class)->makePartial();
+ $model->team_id = 1;
+
+ $policy = new SharedEnvironmentVariablePolicy;
+ expect($policy->update($user, $model))->toBeFalse();
+});
+
+it('allows team admin to delete shared environment variable', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $model = Mockery::mock(SharedEnvironmentVariable::class)->makePartial();
+ $model->team_id = 1;
+
+ $policy = new SharedEnvironmentVariablePolicy;
+ expect($policy->delete($user, $model))->toBeTrue();
+});
+
+it('denies team member to delete shared environment variable', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $model = Mockery::mock(SharedEnvironmentVariable::class)->makePartial();
+ $model->team_id = 1;
+
+ $policy = new SharedEnvironmentVariablePolicy;
+ expect($policy->delete($user, $model))->toBeFalse();
+});
+
+it('denies restore of shared environment variable', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $model = Mockery::mock(SharedEnvironmentVariable::class)->makePartial();
+ $model->team_id = 1;
+
+ $policy = new SharedEnvironmentVariablePolicy;
+ expect($policy->restore($user, $model))->toBeFalse();
+});
+
+it('denies force delete of shared environment variable', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $model = Mockery::mock(SharedEnvironmentVariable::class)->makePartial();
+ $model->team_id = 1;
+
+ $policy = new SharedEnvironmentVariablePolicy;
+ expect($policy->forceDelete($user, $model))->toBeFalse();
+});
+
+it('allows team admin to manage environment', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $model = Mockery::mock(SharedEnvironmentVariable::class)->makePartial();
+ $model->team_id = 1;
+
+ $policy = new SharedEnvironmentVariablePolicy;
+ expect($policy->manageEnvironment($user, $model))->toBeTrue();
+});
+
+it('denies team member to manage environment', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $model = Mockery::mock(SharedEnvironmentVariable::class)->makePartial();
+ $model->team_id = 1;
+
+ $policy = new SharedEnvironmentVariablePolicy;
+ expect($policy->manageEnvironment($user, $model))->toBeFalse();
+});
diff --git a/tests/Unit/Policies/StandaloneDockerPolicyTest.php b/tests/Unit/Policies/StandaloneDockerPolicyTest.php
new file mode 100644
index 000000000..750aff6c2
--- /dev/null
+++ b/tests/Unit/Policies/StandaloneDockerPolicyTest.php
@@ -0,0 +1,122 @@
+makePartial();
+
+ $policy = new StandaloneDockerPolicy;
+ expect($policy->viewAny($user))->toBeTrue();
+});
+
+it('allows team member to view their team standalone docker', function () {
+ $teams = collect([
+ (object) ['id' => 1],
+ ]);
+
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
+
+ $standaloneDocker = Mockery::mock(StandaloneDocker::class)->makePartial();
+ $standaloneDocker->shouldReceive('getAttribute')->with('server')->andReturn((object) ['team_id' => 1]);
+
+ $policy = new StandaloneDockerPolicy;
+ expect($policy->view($user, $standaloneDocker))->toBeTrue();
+});
+
+it('denies user from viewing another team standalone docker', function () {
+ $teams = collect([
+ (object) ['id' => 1],
+ ]);
+
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
+
+ $standaloneDocker = Mockery::mock(StandaloneDocker::class)->makePartial();
+ $standaloneDocker->shouldReceive('getAttribute')->with('server')->andReturn((object) ['team_id' => 2]);
+
+ $policy = new StandaloneDockerPolicy;
+ expect($policy->view($user, $standaloneDocker))->toBeFalse();
+});
+
+it('allows admin to create standalone docker', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(true);
+
+ $policy = new StandaloneDockerPolicy;
+ expect($policy->create($user))->toBeTrue();
+});
+
+it('denies non-admin from creating standalone docker', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(false);
+
+ $policy = new StandaloneDockerPolicy;
+ expect($policy->create($user))->toBeFalse();
+});
+
+it('allows team admin to update standalone docker', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $standaloneDocker = Mockery::mock(StandaloneDocker::class)->makePartial();
+ $standaloneDocker->shouldReceive('getAttribute')->with('server')->andReturn((object) ['team_id' => 1]);
+
+ $policy = new StandaloneDockerPolicy;
+ expect($policy->update($user, $standaloneDocker))->toBeTrue();
+});
+
+it('denies non-admin from updating standalone docker', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $standaloneDocker = Mockery::mock(StandaloneDocker::class)->makePartial();
+ $standaloneDocker->shouldReceive('getAttribute')->with('server')->andReturn((object) ['team_id' => 1]);
+
+ $policy = new StandaloneDockerPolicy;
+ expect($policy->update($user, $standaloneDocker))->toBeFalse();
+});
+
+it('allows team admin to delete standalone docker', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $standaloneDocker = Mockery::mock(StandaloneDocker::class)->makePartial();
+ $standaloneDocker->shouldReceive('getAttribute')->with('server')->andReturn((object) ['team_id' => 1]);
+
+ $policy = new StandaloneDockerPolicy;
+ expect($policy->delete($user, $standaloneDocker))->toBeTrue();
+});
+
+it('denies non-admin from deleting standalone docker', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $standaloneDocker = Mockery::mock(StandaloneDocker::class)->makePartial();
+ $standaloneDocker->shouldReceive('getAttribute')->with('server')->andReturn((object) ['team_id' => 1]);
+
+ $policy = new StandaloneDockerPolicy;
+ expect($policy->delete($user, $standaloneDocker))->toBeFalse();
+});
+
+it('denies restore for standalone docker', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $standaloneDocker = Mockery::mock(StandaloneDocker::class)->makePartial();
+ $standaloneDocker->shouldReceive('getAttribute')->with('server')->andReturn((object) ['team_id' => 1]);
+
+ $policy = new StandaloneDockerPolicy;
+ expect($policy->restore($user, $standaloneDocker))->toBeFalse();
+});
+
+it('denies force delete for standalone docker', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $standaloneDocker = Mockery::mock(StandaloneDocker::class)->makePartial();
+ $standaloneDocker->shouldReceive('getAttribute')->with('server')->andReturn((object) ['team_id' => 1]);
+
+ $policy = new StandaloneDockerPolicy;
+ expect($policy->forceDelete($user, $standaloneDocker))->toBeFalse();
+});
diff --git a/tests/Unit/Policies/SwarmDockerPolicyTest.php b/tests/Unit/Policies/SwarmDockerPolicyTest.php
new file mode 100644
index 000000000..f1b060086
--- /dev/null
+++ b/tests/Unit/Policies/SwarmDockerPolicyTest.php
@@ -0,0 +1,122 @@
+makePartial();
+
+ $policy = new SwarmDockerPolicy;
+ expect($policy->viewAny($user))->toBeTrue();
+});
+
+it('allows team member to view their team swarm docker', function () {
+ $teams = collect([
+ (object) ['id' => 1],
+ ]);
+
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
+
+ $swarmDocker = Mockery::mock(SwarmDocker::class)->makePartial();
+ $swarmDocker->shouldReceive('getAttribute')->with('server')->andReturn((object) ['team_id' => 1]);
+
+ $policy = new SwarmDockerPolicy;
+ expect($policy->view($user, $swarmDocker))->toBeTrue();
+});
+
+it('denies user from viewing another team swarm docker', function () {
+ $teams = collect([
+ (object) ['id' => 1],
+ ]);
+
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
+
+ $swarmDocker = Mockery::mock(SwarmDocker::class)->makePartial();
+ $swarmDocker->shouldReceive('getAttribute')->with('server')->andReturn((object) ['team_id' => 2]);
+
+ $policy = new SwarmDockerPolicy;
+ expect($policy->view($user, $swarmDocker))->toBeFalse();
+});
+
+it('allows admin to create swarm docker', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(true);
+
+ $policy = new SwarmDockerPolicy;
+ expect($policy->create($user))->toBeTrue();
+});
+
+it('denies non-admin from creating swarm docker', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(false);
+
+ $policy = new SwarmDockerPolicy;
+ expect($policy->create($user))->toBeFalse();
+});
+
+it('allows team admin to update swarm docker', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $swarmDocker = Mockery::mock(SwarmDocker::class)->makePartial();
+ $swarmDocker->shouldReceive('getAttribute')->with('server')->andReturn((object) ['team_id' => 1]);
+
+ $policy = new SwarmDockerPolicy;
+ expect($policy->update($user, $swarmDocker))->toBeTrue();
+});
+
+it('denies non-admin from updating swarm docker', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $swarmDocker = Mockery::mock(SwarmDocker::class)->makePartial();
+ $swarmDocker->shouldReceive('getAttribute')->with('server')->andReturn((object) ['team_id' => 1]);
+
+ $policy = new SwarmDockerPolicy;
+ expect($policy->update($user, $swarmDocker))->toBeFalse();
+});
+
+it('allows team admin to delete swarm docker', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $swarmDocker = Mockery::mock(SwarmDocker::class)->makePartial();
+ $swarmDocker->shouldReceive('getAttribute')->with('server')->andReturn((object) ['team_id' => 1]);
+
+ $policy = new SwarmDockerPolicy;
+ expect($policy->delete($user, $swarmDocker))->toBeTrue();
+});
+
+it('denies non-admin from deleting swarm docker', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $swarmDocker = Mockery::mock(SwarmDocker::class)->makePartial();
+ $swarmDocker->shouldReceive('getAttribute')->with('server')->andReturn((object) ['team_id' => 1]);
+
+ $policy = new SwarmDockerPolicy;
+ expect($policy->delete($user, $swarmDocker))->toBeFalse();
+});
+
+it('denies restore for swarm docker', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $swarmDocker = Mockery::mock(SwarmDocker::class)->makePartial();
+ $swarmDocker->shouldReceive('getAttribute')->with('server')->andReturn((object) ['team_id' => 1]);
+
+ $policy = new SwarmDockerPolicy;
+ expect($policy->restore($user, $swarmDocker))->toBeFalse();
+});
+
+it('denies force delete for swarm docker', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $swarmDocker = Mockery::mock(SwarmDocker::class)->makePartial();
+ $swarmDocker->shouldReceive('getAttribute')->with('server')->andReturn((object) ['team_id' => 1]);
+
+ $policy = new SwarmDockerPolicy;
+ expect($policy->forceDelete($user, $swarmDocker))->toBeFalse();
+});
From fcc58ca08a29561be07ae132cced2d8d87ea47b7 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Wed, 25 Feb 2026 14:47:35 +0100
Subject: [PATCH 02/30] fix(auth): enforce dashboard authorization and improve
team deletion
Add authorization gates to Project and Server creation buttons in the dashboard to prevent non-admin users from accessing resource creation. Improve team deletion to clear cache before deletion and automatically switch to the user's next available team.
- Hide create buttons from non-admin users in dashboard
- Clear cache before team deletion to prevent stale session resolution
- Switch user session to next available team when current team is deleted
- Handle refreshSession when user has no remaining teams
- Add tests for dashboard authorization enforcement and team deletion flow
---
app/Livewire/Team/Index.php | 11 +-
bootstrap/helpers/shared.php | 8 ++
resources/views/livewire/dashboard.blade.php | 117 +++++++++++--------
tests/Feature/DashboardAuthorizationTest.php | 97 +++++++++++++++
tests/Feature/TeamDeletionTest.php | 54 +++++++++
5 files changed, 234 insertions(+), 53 deletions(-)
create mode 100644 tests/Feature/DashboardAuthorizationTest.php
create mode 100644 tests/Feature/TeamDeletionTest.php
diff --git a/app/Livewire/Team/Index.php b/app/Livewire/Team/Index.php
index e5ceb2cc9..140d9f5cc 100644
--- a/app/Livewire/Team/Index.php
+++ b/app/Livewire/Team/Index.php
@@ -7,6 +7,7 @@ use App\Models\TeamInvitation;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
+use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
@@ -98,8 +99,6 @@ class Index extends Component
try {
$currentTeam = currentTeam();
$this->authorize('delete', $currentTeam);
- $currentTeam->delete();
-
$currentTeam->members->each(function ($user) use ($currentTeam) {
if ($user->id === Auth::id()) {
return;
@@ -111,7 +110,13 @@ class Index extends Component
}
});
- refreshSession();
+ // Clear stale cache before deleting so refreshSession doesn't resolve the deleted team
+ Cache::forget('user:'.Auth::id().':team:'.$currentTeam->id);
+ $currentTeam->delete();
+
+ // Switch to the user's next available team
+ $newTeam = Auth::user()->teams()->first();
+ refreshSession($newTeam);
return redirect()->route('team.index');
} catch (\Throwable $e) {
diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php
index 4372ff955..8428b7586 100644
--- a/bootstrap/helpers/shared.php
+++ b/bootstrap/helpers/shared.php
@@ -186,6 +186,14 @@ function refreshSession(?Team $team = null): void
$team = User::find(Auth::id())->teams->first();
}
}
+
+ if (! $team) {
+ session()->forget('currentTeam');
+ Cache::forget('team:'.Auth::id());
+
+ return;
+ }
+
// Clear old cache key format for backwards compatibility
Cache::forget('team:'.Auth::id());
// Use new cache key format that includes team ID
diff --git a/resources/views/livewire/dashboard.blade.php b/resources/views/livewire/dashboard.blade.php
index a58ca0a00..7d1e932d6 100644
--- a/resources/views/livewire/dashboard.blade.php
+++ b/resources/views/livewire/dashboard.blade.php
@@ -17,20 +17,23 @@
Projects
- @if ($projects->count() > 0)
-
-
-
-
-
-
-
-
-
-
- @endif
+ @can('create', App\Models\Project::class)
+ @if ($projects->count() > 0)
+
+
+
+
+
+
+
+
+
+
+ @endif
+ @endcan
@if ($projects->count() > 0)
@@ -70,12 +73,15 @@
@else
No projects found.
-
-
-
- your first project or
- go to the
onboarding page.
-
+ @can('create', App\Models\Project::class)
+
+
+
+ your first project or
+ go to the
onboarding page.
+
+ @endcan
@endif
@@ -83,20 +89,23 @@
Servers
- @if ($servers->count() > 0 && $privateKeys->count() > 0)
-
-
-
-
-
-
-
-
-
-
- @endif
+ @can('create', App\Models\Server::class)
+ @if ($servers->count() > 0 && $privateKeys->count() > 0)
+
+
+
+
+
+
+
+
+
+
+ @endif
+ @endcan
@if ($servers->count() > 0)
@@ -133,26 +142,34 @@
@if ($privateKeys->count() === 0)
No private keys found.
-
Before you can add your server, first
-
- a private key
- or
- go to the
onboarding
- page.
-
+ @can('create', App\Models\Server::class)
+
Before you can add your server, first
+
+ a private key
+ or
+ go to the
onboarding
+ page.
+
+ @endcan
@else
No servers found.
-
-
-
- your first server
- or
- go to the
onboarding
- page.
-
+ @can('create', App\Models\Server::class)
+
+
+
+ your first server
+ or
+ go to the
onboarding
+ page.
+
+ @endcan
@endif
@endif
diff --git a/tests/Feature/DashboardAuthorizationTest.php b/tests/Feature/DashboardAuthorizationTest.php
new file mode 100644
index 000000000..26a39af0a
--- /dev/null
+++ b/tests/Feature/DashboardAuthorizationTest.php
@@ -0,0 +1,97 @@
+create();
+
+ $user = User::factory()->create();
+ $user->teams()->attach($team, ['role' => $role]);
+
+ return [$user, $team];
+}
+
+function createProjectForTeam(Team $team): void
+{
+ Project::create([
+ 'uuid' => (string) Str::uuid(),
+ 'name' => 'Test Project',
+ 'team_id' => $team->id,
+ ]);
+}
+
+function createServerWithKeyForTeam(Team $team): void
+{
+ $keyId = DB::table('private_keys')->insertGetId([
+ 'uuid' => (string) Str::uuid(),
+ 'name' => 'Test Key',
+ 'private_key' => 'test-key',
+ 'team_id' => $team->id,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ Server::factory()->create([
+ 'team_id' => $team->id,
+ 'private_key_id' => $keyId,
+ ]);
+}
+
+test('admin sees add project button on dashboard', function () {
+ [$user, $team] = setupDashboardUser('admin');
+
+ $this->actingAs($user);
+ session(['currentTeam' => $team]);
+
+ createProjectForTeam($team);
+
+ Livewire::test(Dashboard::class)
+ ->assertSee('New Project');
+});
+
+test('member does not see add project button on dashboard', function () {
+ [$user, $team] = setupDashboardUser('member');
+
+ $this->actingAs($user);
+ session(['currentTeam' => $team]);
+
+ createProjectForTeam($team);
+
+ Livewire::test(Dashboard::class)
+ ->assertDontSee('New Project');
+});
+
+test('admin sees add server button on dashboard', function () {
+ [$user, $team] = setupDashboardUser('admin');
+
+ $this->actingAs($user);
+ session(['currentTeam' => $team]);
+
+ createServerWithKeyForTeam($team);
+
+ Livewire::test(Dashboard::class)
+ ->assertSee('New Server');
+});
+
+test('member does not see add server button on dashboard', function () {
+ [$user, $team] = setupDashboardUser('member');
+
+ $this->actingAs($user);
+ session(['currentTeam' => $team]);
+
+ createServerWithKeyForTeam($team);
+
+ Livewire::test(Dashboard::class)
+ ->assertDontSee('New Server');
+});
diff --git a/tests/Feature/TeamDeletionTest.php b/tests/Feature/TeamDeletionTest.php
new file mode 100644
index 000000000..65d6c54f1
--- /dev/null
+++ b/tests/Feature/TeamDeletionTest.php
@@ -0,0 +1,54 @@
+ 0]);
+
+ $this->owner = User::factory()->create();
+
+ // The owner's personal team (created by factory)
+ $this->personalTeam = $this->owner->teams()->first();
+ $this->owner->teams()->updateExistingPivot($this->personalTeam->id, ['role' => 'owner']);
+
+ // A second team to delete
+ $this->teamToDelete = Team::create(['name' => 'Deletable Team', 'personal_team' => false]);
+ $this->teamToDelete->members()->attach($this->owner->id, ['role' => 'owner']);
+});
+
+test('deleting a team switches session to another team without error', function () {
+ $this->actingAs($this->owner);
+ session(['currentTeam' => $this->teamToDelete]);
+
+ Livewire::test(Index::class)
+ ->call('delete')
+ ->assertRedirect(route('team.index'));
+
+ // Team should be deleted from the database
+ expect(Team::find($this->teamToDelete->id))->toBeNull();
+
+ // Session should now have the personal team
+ $sessionTeam = session('currentTeam');
+ expect($sessionTeam)->not->toBeNull()
+ ->and($sessionTeam->id)->toBe($this->personalTeam->id);
+});
+
+test('refreshSession clears session when no team exists', function () {
+ $user = User::factory()->create();
+ // Detach all teams so user has none
+ $user->teams()->detach();
+ $this->actingAs($user);
+ session(['currentTeam' => null]);
+
+ // Should not throw when no team can be resolved
+ refreshSession(null);
+
+ expect(session('currentTeam'))->toBeNull();
+});
From 41e1248b6f6e55fa138d70e172672543e12bd374 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Wed, 25 Feb 2026 15:37:04 +0100
Subject: [PATCH 03/30] fix(auth): enforce proxy authorization checks in server
navbar
Add authorization gate using @can('manageProxy') directive to ensure only
authorized users can view and interact with proxy control buttons (restart,
stop, start) in the server navbar component. Refactor tests to validate that
members cannot see proxy buttons while admins can.
---
.../views/livewire/server/navbar.blade.php | 2 +
tests/Feature/Proxy/RestartProxyTest.php | 205 +++++++-----------
2 files changed, 80 insertions(+), 127 deletions(-)
diff --git a/resources/views/livewire/server/navbar.blade.php b/resources/views/livewire/server/navbar.blade.php
index 4e53cd80e..413f0ee57 100644
--- a/resources/views/livewire/server/navbar.blade.php
+++ b/resources/views/livewire/server/navbar.blade.php
@@ -105,6 +105,7 @@
@if ($server->proxySet())
+ @can('manageProxy', $server)
@if ($proxyStatus === 'running')
@@ -169,6 +170,7 @@
Start Proxy
@endif
+ @endcan
@endif
@script
diff --git a/resources/views/livewire/project/service/heading.blade.php b/resources/views/livewire/project/service/heading.blade.php
index af057813c..db21f3317 100644
--- a/resources/views/livewire/project/service/heading.blade.php
+++ b/resources/views/livewire/project/service/heading.blade.php
@@ -40,7 +40,7 @@
Restart
-
@@ -68,7 +68,7 @@
Restart
-
@@ -86,7 +86,7 @@
@elseif (str($service->status)->contains('exited'))
- user()->can('deploy', $service)) @click="$wire.dispatch('startEvent')" class="gap-2 button">
+
@@ -94,9 +94,9 @@
Deploy
-
+
@else
-
@@ -113,7 +113,7 @@
Stop
- user()->can('deploy', $service)) @click="$wire.dispatch('startEvent')" class="gap-2 button">
+
@@ -121,7 +121,7 @@
Deploy
-
+
@endif
@else
@@ -149,11 +149,9 @@
);
return;
}
- window.dispatchEvent(new CustomEvent('startservice'));
$wire.$call('start');
});
$wire.$on('forceDeployEvent', () => {
- window.dispatchEvent(new CustomEvent('startservice'));
$wire.$call('forceDeploy');
});
$wire.$on('restartEvent', async () => {
@@ -166,12 +164,10 @@
}
$wire.$dispatch('info',
'Gracefully stopping service.
It could take a while depending on the service.');
- window.dispatchEvent(new CustomEvent('startservice'));
$wire.$call('restart');
});
$wire.$on('pullAndRestartEvent', () => {
$wire.$dispatch('info', 'Pulling new images and restarting service.');
- window.dispatchEvent(new CustomEvent('startservice'));
$wire.$call('pullAndRestartEvent');
});
$wire.on('imagePulled', () => {
diff --git a/resources/views/livewire/project/shared/environment-variable/show.blade.php b/resources/views/livewire/project/shared/environment-variable/show.blade.php
index 68e1d7e7d..0bcb127ba 100644
--- a/resources/views/livewire/project/shared/environment-variable/show.blade.php
+++ b/resources/views/livewire/project/shared/environment-variable/show.blade.php
@@ -139,15 +139,22 @@
@else
-
- @if ($is_shared)
-
+ @if ($isValueHidden)
+
+
+
+ @else
+
+ @if ($is_shared)
+
+ @endif
@endif
@endcan
diff --git a/resources/views/livewire/project/shared/health-checks.blade.php b/resources/views/livewire/project/shared/health-checks.blade.php
index 47837b230..01f42dace 100644
--- a/resources/views/livewire/project/shared/health-checks.blade.php
+++ b/resources/views/livewire/project/shared/health-checks.blade.php
@@ -3,7 +3,7 @@
Healthchecks
Save
@if (!$healthCheckEnabled)
-
Scheduled Task
-
+
Save
@if ($resource->isRunning())
-
- Execute Now
-
+ @can('update', $resource)
+
+ Execute Now
+
+ @endcan
@endif
-
-
+ @can('update', $resource)
+
+ @endcan
-
+ @can('update', $resource)
+
+ @else
+
+ @endcan
-
-
-
-
+
+
+
@if ($type === 'application')
-
@elseif ($type === 'service')
-
@endif
diff --git a/resources/views/livewire/security/api-tokens.blade.php b/resources/views/livewire/security/api-tokens.blade.php
index 1a7b6eb79..bf04aad2a 100644
--- a/resources/views/livewire/security/api-tokens.blade.php
+++ b/resources/views/livewire/security/api-tokens.blade.php
@@ -18,21 +18,23 @@
Create
-
- Permissions
-
:
-
- @if ($permissions)
+
+
Permissions
+
+ @if ($permissions)
+
@foreach ($permissions as $permission)
-
{{ $permission }}
+
+ {{ $permission }}
+
@endforeach
- @endif
-
+
+ @endif
Token Permissions
-
+
@if ($canUseRootPermissions)
@@ -59,9 +61,14 @@
@endif
-
+ @if ($canUseSensitivePermissions)
+
+ @else
+
+ @endif
@endif
@if (in_array('root', $permissions))
@@ -70,44 +77,74 @@
@endcan
@if (session()->has('token'))
-
Please copy this token now. For your security, it won't be shown
- again.
+
+
Please copy this token now. For your security, it won't
+ be shown again.
+
+
+
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">
+
+
+
+
+
+
+
+
-
{{ session('token') }}
@endif
-
Issued Tokens
-
- @forelse ($tokens as $token)
-
-
Description: {{ $token->name }}
-
Last used: {{ $token->last_used_at ? $token->last_used_at->diffForHumans() : 'Never' }}
-
- @if ($token->abilities)
- Permissions:
- @foreach ($token->abilities as $ability)
-
{{ $ability }}
- @endforeach
+
+
+
Issued Tokens
+ @if ($tokens->count() > 1)
+
+ @endif
+
+
+ @forelse ($tokens as $token)
+
+
+
+ {{ $token->name }}
+ @if ($token->abilities)
+ @foreach ($token->abilities as $ability)
+
+ {{ $ability }}
+
+ @endforeach
+ @endif
+
+
+ Last used: {{ $token->last_used_at ? $token->last_used_at->diffForHumans() : 'Never' }}
+
+
+ @if (auth()->id() === $token->tokenable_id)
+
@endif
-
- @if (auth()->id() === $token->tokenable_id)
-
- @endif
-
- @empty
-
- @endforelse
+ @empty
+
No API tokens found.
+ @endforelse
+
@endif
diff --git a/tests/Feature/Authorization/EnvironmentVariableValueHidingTest.php b/tests/Feature/Authorization/EnvironmentVariableValueHidingTest.php
new file mode 100644
index 000000000..e30e2ad62
--- /dev/null
+++ b/tests/Feature/Authorization/EnvironmentVariableValueHidingTest.php
@@ -0,0 +1,274 @@
+ 0], ['is_api_enabled' => true]);
+
+ $this->team = Team::factory()->create();
+
+ $this->admin = User::factory()->create();
+ $this->admin->teams()->attach($this->team, ['role' => 'admin']);
+
+ $this->member = User::factory()->create();
+ $this->member->teams()->attach($this->team, ['role' => 'member']);
+
+ $keyId = DB::table('private_keys')->insertGetId([
+ 'uuid' => (string) Str::uuid(),
+ 'name' => 'Test Key',
+ 'private_key' => 'test-key',
+ 'team_id' => $this->team->id,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ $this->server = Server::factory()->create([
+ 'team_id' => $this->team->id,
+ 'private_key_id' => $keyId,
+ ]);
+
+ StandaloneDocker::withoutEvents(function () {
+ $this->destination = StandaloneDocker::firstOrCreate(
+ ['server_id' => $this->server->id, 'network' => 'coolify'],
+ ['uuid' => (string) Str::uuid(), 'name' => 'test-docker']
+ );
+ });
+
+ $this->project = Project::create([
+ 'uuid' => (string) Str::uuid(),
+ 'name' => 'Test Project',
+ 'team_id' => $this->team->id,
+ ]);
+
+ $this->environment = $this->project->environments()->first();
+
+ $this->application = Application::factory()->create([
+ 'uuid' => (string) Str::uuid(),
+ 'name' => 'Test App',
+ 'environment_id' => $this->environment->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ ]);
+
+ $this->unlockedEnv = EnvironmentVariable::create([
+ 'key' => 'UNLOCKED_VAR',
+ 'value' => 'secret-unlocked-value',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ 'is_preview' => false,
+ 'is_shown_once' => false,
+ 'is_multiline' => false,
+ 'is_literal' => false,
+ 'is_runtime' => true,
+ 'is_buildtime' => true,
+ ]);
+
+ $this->lockedEnv = EnvironmentVariable::create([
+ 'key' => 'LOCKED_VAR',
+ 'value' => 'secret-locked-value',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ 'is_preview' => false,
+ 'is_shown_once' => true,
+ 'is_multiline' => false,
+ 'is_literal' => false,
+ 'is_runtime' => true,
+ 'is_buildtime' => true,
+ ]);
+});
+
+// --- Livewire Show component: locked env values ---
+
+test('admin sees unlocked env value in Show component', function () {
+ $this->actingAs($this->admin);
+ session(['currentTeam' => $this->team]);
+
+ $component = Livewire::test(EnvironmentVariableShow::class, [
+ 'env' => $this->unlockedEnv,
+ 'type' => 'application',
+ ]);
+
+ expect($component->get('value'))->toBe('secret-unlocked-value');
+});
+
+test('admin cannot see locked env value in Show component', function () {
+ $this->actingAs($this->admin);
+ session(['currentTeam' => $this->team]);
+
+ $component = Livewire::test(EnvironmentVariableShow::class, [
+ 'env' => $this->lockedEnv,
+ 'type' => 'application',
+ ]);
+
+ expect($component->get('value'))->toBeNull();
+ expect($component->get('real_value'))->toBeNull();
+});
+
+test('member cannot see any env value in Show component', function () {
+ $this->actingAs($this->member);
+ session(['currentTeam' => $this->team]);
+
+ $component = Livewire::test(EnvironmentVariableShow::class, [
+ 'env' => $this->unlockedEnv,
+ 'type' => 'application',
+ ]);
+
+ expect($component->get('value'))->toBeNull();
+ expect($component->get('real_value'))->toBeNull();
+});
+
+test('member has isValueHidden flag set to true', function () {
+ $this->actingAs($this->member);
+ session(['currentTeam' => $this->team]);
+
+ $component = Livewire::test(EnvironmentVariableShow::class, [
+ 'env' => $this->unlockedEnv,
+ 'type' => 'application',
+ ]);
+
+ expect($component->get('isValueHidden'))->toBeTrue();
+});
+
+test('admin has isValueHidden flag set to false', function () {
+ $this->actingAs($this->admin);
+ session(['currentTeam' => $this->team]);
+
+ $component = Livewire::test(EnvironmentVariableShow::class, [
+ 'env' => $this->unlockedEnv,
+ 'type' => 'application',
+ ]);
+
+ expect($component->get('isValueHidden'))->toBeFalse();
+});
+
+// --- Livewire All component: dev view ---
+
+test('admin dev view shows unlocked env value', function () {
+ $this->actingAs($this->admin);
+ session(['currentTeam' => $this->team]);
+
+ $component = Livewire::test(EnvironmentVariableAll::class, [
+ 'resource' => $this->application,
+ ]);
+
+ expect($component->get('variables'))->toContain('UNLOCKED_VAR=secret-unlocked-value');
+});
+
+test('admin dev view hides locked env value', function () {
+ $this->actingAs($this->admin);
+ session(['currentTeam' => $this->team]);
+
+ $component = Livewire::test(EnvironmentVariableAll::class, [
+ 'resource' => $this->application,
+ ]);
+
+ expect($component->get('variables'))->toContain('LOCKED_VAR=(Locked Secret, delete and add again to change)');
+ expect($component->get('variables'))->not->toContain('secret-locked-value');
+});
+
+test('member dev view hides all env values', function () {
+ $this->actingAs($this->member);
+ session(['currentTeam' => $this->team]);
+
+ $component = Livewire::test(EnvironmentVariableAll::class, [
+ 'resource' => $this->application,
+ ]);
+
+ expect($component->get('variables'))->not->toContain('secret-unlocked-value');
+ expect($component->get('variables'))->not->toContain('secret-locked-value');
+ expect($component->get('variables'))->toContain('UNLOCKED_VAR=(Hidden');
+});
+
+// --- API: locked env values hidden ---
+
+test('API hides locked env value even with read:sensitive token', function () {
+ session(['currentTeam' => $this->team]);
+ $token = $this->admin->createToken('admin-sensitive', ['read', 'read:sensitive']);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$token->plainTextToken,
+ ])->getJson("/api/v1/applications/{$this->application->uuid}/envs");
+
+ $response->assertOk();
+
+ $envs = collect($response->json());
+ $locked = $envs->firstWhere('key', 'LOCKED_VAR');
+ $unlocked = $envs->firstWhere('key', 'UNLOCKED_VAR');
+
+ expect($locked)->not->toBeNull();
+ expect($locked)->not->toHaveKey('value');
+ expect($locked)->not->toHaveKey('real_value');
+
+ expect($unlocked)->not->toBeNull();
+ expect($unlocked)->toHaveKey('value');
+});
+
+test('API hides locked env value with root token', function () {
+ session(['currentTeam' => $this->team]);
+ $token = $this->admin->createToken('admin-root', ['root']);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$token->plainTextToken,
+ ])->getJson("/api/v1/applications/{$this->application->uuid}/envs");
+
+ $response->assertOk();
+
+ $envs = collect($response->json());
+ $locked = $envs->firstWhere('key', 'LOCKED_VAR');
+
+ expect($locked)->not->toBeNull();
+ expect($locked)->not->toHaveKey('value');
+ expect($locked)->not->toHaveKey('real_value');
+});
+
+// --- API: member role hides env values ---
+
+test('API hides env values for member even with read:sensitive token', function () {
+ session(['currentTeam' => $this->team]);
+ $token = $this->member->createToken('member-sensitive', ['read', 'read:sensitive']);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$token->plainTextToken,
+ ])->getJson("/api/v1/applications/{$this->application->uuid}/envs");
+
+ $response->assertOk();
+
+ $envs = collect($response->json());
+ $unlocked = $envs->firstWhere('key', 'UNLOCKED_VAR');
+
+ expect($unlocked)->not->toBeNull();
+ expect($unlocked)->not->toHaveKey('value');
+ expect($unlocked)->not->toHaveKey('real_value');
+});
+
+test('API shows env values for admin with read:sensitive token', function () {
+ session(['currentTeam' => $this->team]);
+ $token = $this->admin->createToken('admin-sensitive-2', ['read', 'read:sensitive']);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$token->plainTextToken,
+ ])->getJson("/api/v1/applications/{$this->application->uuid}/envs");
+
+ $response->assertOk();
+
+ $envs = collect($response->json());
+ $unlocked = $envs->firstWhere('key', 'UNLOCKED_VAR');
+
+ expect($unlocked)->not->toBeNull();
+ expect($unlocked)->toHaveKey('value');
+});
diff --git a/tests/Feature/Authorization/LegacyMemberTokenTest.php b/tests/Feature/Authorization/LegacyMemberTokenTest.php
new file mode 100644
index 000000000..273327985
--- /dev/null
+++ b/tests/Feature/Authorization/LegacyMemberTokenTest.php
@@ -0,0 +1,127 @@
+ 0, 'is_api_enabled' => true]);
+
+ $this->team = Team::factory()->create();
+ $this->member = User::factory()->create();
+ $this->admin = User::factory()->create();
+
+ $this->team->members()->attach($this->member->id, ['role' => 'member']);
+ $this->team->members()->attach($this->admin->id, ['role' => 'admin']);
+
+ session(['currentTeam' => $this->team]);
+});
+
+function apiRequest($test, string $token, string $method = 'get', string $url = '/api/v1/version')
+{
+ return $test->withHeaders([
+ 'Authorization' => 'Bearer '.$token,
+ 'Content-Type' => 'application/json',
+ ])->{$method.'Json'}($url);
+}
+
+describe('member with legacy elevated token is rejected', function () {
+ test('member with legacy write token gets 403 with descriptive message', function () {
+ $token = $this->member->createToken('legacy-write', ['read', 'write']);
+
+ $response = apiRequest($this, $token->plainTextToken);
+
+ $response->assertStatus(403);
+ $response->assertJsonFragment([
+ 'message' => 'This API token has permissions (write) that exceed your current role as a team member. Members are restricted to read-only API access. Please revoke this token and create a new one with only read permissions.',
+ ]);
+ });
+
+ test('member with legacy deploy token gets 403', function () {
+ $token = $this->member->createToken('legacy-deploy', ['read', 'deploy']);
+
+ $response = apiRequest($this, $token->plainTextToken);
+
+ $response->assertStatus(403);
+ $response->assertSee('deploy');
+ $response->assertSee('revoke this token');
+ });
+
+ test('member with legacy root token gets 403', function () {
+ $token = $this->member->createToken('legacy-root', ['root']);
+
+ $response = apiRequest($this, $token->plainTextToken);
+
+ $response->assertStatus(403);
+ $response->assertSee('root');
+ });
+
+ test('member with legacy read:sensitive token gets 403', function () {
+ $token = $this->member->createToken('legacy-sensitive', ['read', 'read:sensitive']);
+
+ $response = apiRequest($this, $token->plainTextToken);
+
+ $response->assertStatus(403);
+ $response->assertSee('read:sensitive');
+ });
+
+ test('member with legacy write:sensitive token gets 403', function () {
+ $token = $this->member->createToken('legacy-ws', ['read', 'write:sensitive']);
+
+ $response = apiRequest($this, $token->plainTextToken);
+
+ $response->assertStatus(403);
+ $response->assertSee('write:sensitive');
+ });
+
+ test('member with multiple disallowed abilities lists them all', function () {
+ $token = $this->member->createToken('legacy-multi', ['read', 'write', 'deploy', 'read:sensitive']);
+
+ $response = apiRequest($this, $token->plainTextToken);
+
+ $response->assertStatus(403);
+ $json = $response->json();
+ expect($json['message'])->toContain('write');
+ expect($json['message'])->toContain('deploy');
+ expect($json['message'])->toContain('read:sensitive');
+ });
+});
+
+describe('member with read-only token passes through', function () {
+ test('member with read token can access read endpoints', function () {
+ $token = $this->member->createToken('read-only', ['read']);
+
+ $response = apiRequest($this, $token->plainTextToken);
+
+ $response->assertStatus(200);
+ });
+});
+
+describe('admin with elevated token passes through', function () {
+ test('admin with write token is not blocked', function () {
+ $token = $this->admin->createToken('admin-write', ['read', 'write']);
+
+ $response = apiRequest($this, $token->plainTextToken);
+
+ $response->assertStatus(200);
+ });
+
+ test('admin with root token is not blocked', function () {
+ $token = $this->admin->createToken('admin-root', ['root']);
+
+ $response = apiRequest($this, $token->plainTextToken);
+
+ $response->assertStatus(200);
+ });
+
+ test('admin with deploy token is not blocked', function () {
+ $token = $this->admin->createToken('admin-deploy', ['read', 'deploy']);
+
+ $response = apiRequest($this, $token->plainTextToken);
+
+ $response->assertStatus(200);
+ });
+});
diff --git a/tests/Unit/Policies/ApiTokenPolicyTest.php b/tests/Unit/Policies/ApiTokenPolicyTest.php
index 98b59319a..98c60aae8 100644
--- a/tests/Unit/Policies/ApiTokenPolicyTest.php
+++ b/tests/Unit/Policies/ApiTokenPolicyTest.php
@@ -165,3 +165,29 @@ it('denies member from using deploy permissions', function () {
$policy = new ApiTokenPolicy;
expect($policy->useDeployPermissions($user))->toBeFalse();
});
+
+it('allows admin to use sensitive permissions', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(true);
+
+ $policy = new ApiTokenPolicy;
+ expect($policy->useSensitivePermissions($user))->toBeTrue();
+});
+
+it('allows owner to use sensitive permissions', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(false);
+ $user->shouldReceive('isOwner')->andReturn(true);
+
+ $policy = new ApiTokenPolicy;
+ expect($policy->useSensitivePermissions($user))->toBeTrue();
+});
+
+it('denies member from using sensitive permissions', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(false);
+ $user->shouldReceive('isOwner')->andReturn(false);
+
+ $policy = new ApiTokenPolicy;
+ expect($policy->useSensitivePermissions($user))->toBeFalse();
+});
diff --git a/tests/Unit/Policies/S3StoragePolicyTest.php b/tests/Unit/Policies/S3StoragePolicyTest.php
index 4ea580d0f..70ffdf718 100644
--- a/tests/Unit/Policies/S3StoragePolicyTest.php
+++ b/tests/Unit/Policies/S3StoragePolicyTest.php
@@ -52,7 +52,23 @@ it('allows team admin to update S3 storage from their team', function () {
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 @@ it('denies team member to update S3 storage from another team', function () {
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 @@ it('allows team member to delete S3 storage from their team', function () {
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 @@ it('denies non-admin to create S3 storage', function () {
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 @@ it('allows team member to validate connection of S3 storage from their team', fu
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']],
]);
From dbbc77830e8ec027022cabc8038ed96526c55eb2 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Fri, 27 Feb 2026 11:48:48 +0100
Subject: [PATCH 13/30] fix(storage): add error handling for S3 connection
error notifications
Wrap email notification logic in try-catch to prevent email sending failures from breaking the connection test. If notification fails, log a warning and continue instead of letting the exception propagate.
---
app/Models/S3Storage.php | 28 ++++++++++++++++------------
1 file changed, 16 insertions(+), 12 deletions(-)
diff --git a/app/Models/S3Storage.php b/app/Models/S3Storage.php
index 3aae55966..5d1c3b8b5 100644
--- a/app/Models/S3Storage.php
+++ b/app/Models/S3Storage.php
@@ -127,21 +127,25 @@ class S3Storage extends BaseModel
} catch (\Throwable $e) {
$this->is_usable = false;
if ($this->unusable_email_sent === false && is_transactional_emails_enabled()) {
- $mail = new MailMessage;
- $mail->subject('Coolify: S3 Storage Connection Error');
- $mail->view('emails.s3-connection-error', ['name' => $this->name, 'reason' => $e->getMessage(), 'url' => route('storage.show', ['storage_uuid' => $this->uuid])]);
+ try {
+ $mail = new MailMessage;
+ $mail->subject('Coolify: S3 Storage Connection Error');
+ $mail->view('emails.s3-connection-error', ['name' => $this->name, 'reason' => $e->getMessage(), 'url' => route('storage.show', ['storage_uuid' => $this->uuid])]);
- // Load the team with its members and their roles explicitly
- $team = $this->team()->with(['members' => function ($query) {
- $query->withPivot('role');
- }])->first();
+ // Load the team with its members and their roles explicitly
+ $team = $this->team()->with(['members' => function ($query) {
+ $query->withPivot('role');
+ }])->first();
- // Get admins directly from the pivot relationship for this specific team
- $users = $team->members()->wherePivotIn('role', ['admin', 'owner'])->get(['users.id', 'users.email']);
- foreach ($users as $user) {
- send_user_an_email($mail, $user->email);
+ // Get admins directly from the pivot relationship for this specific team
+ $users = $team->members()->wherePivotIn('role', ['admin', 'owner'])->get(['users.id', 'users.email']);
+ foreach ($users as $user) {
+ send_user_an_email($mail, $user->email);
+ }
+ $this->unusable_email_sent = true;
+ } catch (\Throwable $emailException) {
+ \Log::warning('Failed to send S3 connection error notification: '.$emailException->getMessage());
}
- $this->unusable_email_sent = true;
}
throw $e;
From cebef8e2583d69a50d6483c227ab04fe1a8652df Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Fri, 27 Feb 2026 11:54:22 +0100
Subject: [PATCH 14/30] fix(policies): ensure instance-level databases use root
team
Instance-level databases like coolify-db (with id = 0) should always
be assigned to the root team (id = 0) rather than attempting to resolve
their team from the database object itself.
---
app/Policies/DatabasePolicy.php | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/app/Policies/DatabasePolicy.php b/app/Policies/DatabasePolicy.php
index 6a5348224..f62ffdde2 100644
--- a/app/Policies/DatabasePolicy.php
+++ b/app/Policies/DatabasePolicy.php
@@ -109,6 +109,11 @@ class DatabasePolicy
private function getTeamId($database): ?int
{
+ // Instance-level databases (e.g., coolify-db) belong to root team
+ if (isset($database->id) && $database->id === 0) {
+ return 0;
+ }
+
if (method_exists($database, 'team')) {
return $database->team()?->id;
}
From 68f81df0bb13d0715e3f49350e8955cbf058b02f Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Fri, 27 Feb 2026 11:59:26 +0100
Subject: [PATCH 15/30] refactor(auth): enforce authorization checks across
livewire components
Add authorization checks to multiple Livewire components to ensure users
have proper permissions before performing sensitive operations. This includes:
- Adding AuthorizesRequests trait to components handling deployments, backups,
services, and configuration uploads
- Enforcing 'deploy', 'update', and 'manageBackups' authorization checks
- Adding instance admin check for system upgrade operations
- Improving database queries with team ownership scope
- Moving backup trigger from component to button with new backupNow() method
---
app/Livewire/Admin/Index.php | 6 +++
.../Project/Application/DeploymentNavbar.php | 6 +++
app/Livewire/Project/Application/Swarm.php | 5 +++
app/Livewire/Project/Database/BackupEdit.php | 12 ++++++
.../Project/Database/BackupExecutions.php | 43 +++++++++++++------
app/Livewire/Project/Service/EditCompose.php | 31 +++++++++----
app/Livewire/Project/Service/StackForm.php | 15 +++++--
app/Livewire/Project/Shared/UploadConfig.php | 12 +++---
app/Livewire/Upgrade.php | 3 ++
.../project/database/backup-edit.blade.php | 2 +-
10 files changed, 105 insertions(+), 30 deletions(-)
diff --git a/app/Livewire/Admin/Index.php b/app/Livewire/Admin/Index.php
index b5f6d2929..c68bba3e8 100644
--- a/app/Livewire/Admin/Index.php
+++ b/app/Livewire/Admin/Index.php
@@ -45,6 +45,9 @@ class Index extends Component
public function submitSearch()
{
+ if (Auth::id() !== 0 && ! session('impersonating')) {
+ return redirect()->route('dashboard');
+ }
if ($this->search !== '') {
$this->foundUsers = User::where(function ($query) {
$query->where('name', 'like', "%{$this->search}%")
@@ -55,6 +58,9 @@ class Index extends Component
public function getSubscribers()
{
+ if (Auth::id() !== 0 && ! session('impersonating')) {
+ return redirect()->route('dashboard');
+ }
$this->inactiveSubscribers = Team::whereRelation('subscription', 'stripe_invoice_paid', false)->count();
$this->activeSubscribers = Team::whereRelation('subscription', 'stripe_invoice_paid', true)->count();
}
diff --git a/app/Livewire/Project/Application/DeploymentNavbar.php b/app/Livewire/Project/Application/DeploymentNavbar.php
index f71b7f753..fcd0d4cf4 100644
--- a/app/Livewire/Project/Application/DeploymentNavbar.php
+++ b/app/Livewire/Project/Application/DeploymentNavbar.php
@@ -52,6 +52,7 @@ class DeploymentNavbar extends Component
public function force_start()
{
try {
+ $this->authorize('deploy', $this->application);
force_start_deployment($this->application_deployment_queue);
} catch (\Throwable $e) {
return handleError($e, $this);
@@ -82,6 +83,11 @@ class DeploymentNavbar extends Component
public function cancel()
{
+ try {
+ $this->authorize('deploy', $this->application);
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
$deployment_uuid = $this->application_deployment_queue->deployment_uuid;
$kill_command = "docker rm -f {$deployment_uuid}";
$build_server_id = $this->application_deployment_queue->build_server_id ?? $this->application->destination->server_id;
diff --git a/app/Livewire/Project/Application/Swarm.php b/app/Livewire/Project/Application/Swarm.php
index 197dc41ed..94d627e67 100644
--- a/app/Livewire/Project/Application/Swarm.php
+++ b/app/Livewire/Project/Application/Swarm.php
@@ -3,11 +3,14 @@
namespace App\Livewire\Project\Application;
use App\Models\Application;
+use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Validate;
use Livewire\Component;
class Swarm extends Component
{
+ use AuthorizesRequests;
+
public Application $application;
#[Validate('required')]
@@ -51,6 +54,7 @@ class Swarm extends Component
public function instantSave()
{
try {
+ $this->authorize('update', $this->application);
$this->syncData(true);
$this->dispatch('success', 'Swarm settings updated.');
} catch (\Throwable $e) {
@@ -61,6 +65,7 @@ class Swarm extends Component
public function submit()
{
try {
+ $this->authorize('update', $this->application);
$this->syncData(true);
$this->dispatch('success', 'Swarm settings updated.');
} catch (\Throwable $e) {
diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php
index 35262d7b0..8b67105cc 100644
--- a/app/Livewire/Project/Database/BackupEdit.php
+++ b/app/Livewire/Project/Database/BackupEdit.php
@@ -201,6 +201,18 @@ class BackupEdit extends Component
}
}
+ public function backupNow()
+ {
+ try {
+ $this->authorize('manageBackups', $this->backup->database);
+
+ \App\Jobs\DatabaseBackupJob::dispatch($this->backup);
+ $this->dispatch('success', 'Backup queued. It will be available in a few minutes.');
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
+ }
+
public function instantSave()
{
try {
diff --git a/app/Livewire/Project/Database/BackupExecutions.php b/app/Livewire/Project/Database/BackupExecutions.php
index 44f903fcc..5ffe6f509 100644
--- a/app/Livewire/Project/Database/BackupExecutions.php
+++ b/app/Livewire/Project/Database/BackupExecutions.php
@@ -3,12 +3,15 @@
namespace App\Livewire\Project\Database;
use App\Models\ScheduledDatabaseBackup;
+use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class BackupExecutions extends Component
{
+ use AuthorizesRequests;
+
public ?ScheduledDatabaseBackup $backup = null;
public $database;
@@ -44,29 +47,45 @@ class BackupExecutions extends Component
public function cleanupFailed()
{
- if ($this->backup) {
- $this->backup->executions()->where('status', 'failed')->delete();
- $this->refreshBackupExecutions();
- $this->dispatch('success', 'Failed backups cleaned up.');
+ try {
+ $this->authorize('manageBackups', $this->database);
+ if ($this->backup) {
+ $this->backup->executions()->where('status', 'failed')->delete();
+ $this->refreshBackupExecutions();
+ $this->dispatch('success', 'Failed backups cleaned up.');
+ }
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
}
}
public function cleanupDeleted()
{
- if ($this->backup) {
- $deletedCount = $this->backup->executions()->where('local_storage_deleted', true)->count();
- if ($deletedCount > 0) {
- $this->backup->executions()->where('local_storage_deleted', true)->delete();
- $this->refreshBackupExecutions();
- $this->dispatch('success', "Cleaned up {$deletedCount} backup entries deleted from local storage.");
- } else {
- $this->dispatch('info', 'No backup entries found that are deleted from local storage.');
+ try {
+ $this->authorize('manageBackups', $this->database);
+ if ($this->backup) {
+ $deletedCount = $this->backup->executions()->where('local_storage_deleted', true)->count();
+ if ($deletedCount > 0) {
+ $this->backup->executions()->where('local_storage_deleted', true)->delete();
+ $this->refreshBackupExecutions();
+ $this->dispatch('success', "Cleaned up {$deletedCount} backup entries deleted from local storage.");
+ } else {
+ $this->dispatch('info', 'No backup entries found that are deleted from local storage.');
+ }
}
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
}
}
public function deleteBackup($executionId, $password)
{
+ try {
+ $this->authorize('manageBackups', $this->database);
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
+
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
diff --git a/app/Livewire/Project/Service/EditCompose.php b/app/Livewire/Project/Service/EditCompose.php
index 32cf72067..0f5c739b1 100644
--- a/app/Livewire/Project/Service/EditCompose.php
+++ b/app/Livewire/Project/Service/EditCompose.php
@@ -3,10 +3,13 @@
namespace App\Livewire\Project\Service;
use App\Models\Service;
+use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class EditCompose extends Component
{
+ use AuthorizesRequests;
+
public Service $service;
public $serviceId;
@@ -72,19 +75,29 @@ class EditCompose extends Component
public function saveEditedCompose()
{
- $this->dispatch('info', 'Saving new docker compose...');
- $this->dispatch('saveCompose', $this->dockerComposeRaw);
- $this->dispatch('refreshStorages');
+ try {
+ $this->authorize('update', $this->service);
+ $this->dispatch('info', 'Saving new docker compose...');
+ $this->dispatch('saveCompose', $this->dockerComposeRaw);
+ $this->dispatch('refreshStorages');
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
}
public function instantSave()
{
- $this->validate([
- 'isContainerLabelEscapeEnabled' => 'required',
- ]);
- $this->syncData(true);
- $this->service->save(['is_container_label_escape_enabled' => $this->isContainerLabelEscapeEnabled]);
- $this->dispatch('success', 'Service updated successfully');
+ try {
+ $this->authorize('update', $this->service);
+ $this->validate([
+ 'isContainerLabelEscapeEnabled' => 'required',
+ ]);
+ $this->syncData(true);
+ $this->service->save(['is_container_label_escape_enabled' => $this->isContainerLabelEscapeEnabled]);
+ $this->dispatch('success', 'Service updated successfully');
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
}
public function render()
diff --git a/app/Livewire/Project/Service/StackForm.php b/app/Livewire/Project/Service/StackForm.php
index 64a7d8d8b..678db17e8 100644
--- a/app/Livewire/Project/Service/StackForm.php
+++ b/app/Livewire/Project/Service/StackForm.php
@@ -4,12 +4,15 @@ namespace App\Livewire\Project\Service;
use App\Models\Service;
use App\Support\ValidationPatterns;
+use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
class StackForm extends Component
{
+ use AuthorizesRequests;
+
public Service $service;
public Collection $fields;
@@ -128,14 +131,20 @@ class StackForm extends Component
public function instantSave()
{
- $this->syncData(true);
- $this->service->save();
- $this->dispatch('success', 'Service settings saved.');
+ try {
+ $this->authorize('update', $this->service);
+ $this->syncData(true);
+ $this->service->save();
+ $this->dispatch('success', 'Service settings saved.');
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
}
public function submit($notify = true)
{
try {
+ $this->authorize('update', $this->service);
$this->validate();
$this->syncData(true);
diff --git a/app/Livewire/Project/Shared/UploadConfig.php b/app/Livewire/Project/Shared/UploadConfig.php
index 1b10f588b..0f0894687 100644
--- a/app/Livewire/Project/Shared/UploadConfig.php
+++ b/app/Livewire/Project/Shared/UploadConfig.php
@@ -3,10 +3,13 @@
namespace App\Livewire\Project\Shared;
use App\Models\Application;
+use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class UploadConfig extends Component
{
+ use AuthorizesRequests;
+
public $config;
public $applicationId;
@@ -29,13 +32,12 @@ class UploadConfig extends Component
public function uploadConfig()
{
try {
- $application = Application::findOrFail($this->applicationId);
+ $application = Application::ownedByCurrentTeam()->findOrFail($this->applicationId);
+ $this->authorize('update', $application);
$application->setConfig($this->config);
$this->dispatch('success', 'Application settings updated');
- } catch (\Exception $e) {
- $this->dispatch('error', $e->getMessage());
-
- return;
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
}
}
diff --git a/app/Livewire/Upgrade.php b/app/Livewire/Upgrade.php
index 7948ad6a9..be4ea1cf0 100644
--- a/app/Livewire/Upgrade.php
+++ b/app/Livewire/Upgrade.php
@@ -44,6 +44,9 @@ class Upgrade extends Component
public function upgrade()
{
try {
+ if (! isInstanceAdmin()) {
+ abort(403);
+ }
if ($this->updateInProgress) {
return;
}
diff --git a/resources/views/livewire/project/database/backup-edit.blade.php b/resources/views/livewire/project/database/backup-edit.blade.php
index bb5dcfc4d..b818e8f75 100644
--- a/resources/views/livewire/project/database/backup-edit.blade.php
+++ b/resources/views/livewire/project/database/backup-edit.blade.php
@@ -5,7 +5,7 @@
Save
@if (str($status)->startsWith('running'))
-
+
Backup Now
@endif
@if ($backup->database_id !== 0)
Date: Thu, 26 Feb 2026 16:27:02 +0100
Subject: [PATCH 16/30] chore: prepare for PR
---
app/Models/PrivateKey.php | 2 +-
scripts/upgrade.sh | 9 +++++++++
tests/Unit/PrivateKeyStorageTest.php | 2 +-
3 files changed, 11 insertions(+), 2 deletions(-)
diff --git a/app/Models/PrivateKey.php b/app/Models/PrivateKey.php
index bb76d5ed6..7163ae7b5 100644
--- a/app/Models/PrivateKey.php
+++ b/app/Models/PrivateKey.php
@@ -237,7 +237,7 @@ class PrivateKey extends BaseModel
$testSuccess = $disk->put($testFilename, 'test');
if (! $testSuccess) {
- throw new \Exception('SSH keys storage directory is not writable');
+ throw new \Exception('SSH keys storage directory is not writable. Run on the host: sudo chown -R 9999 /data/coolify/ssh && sudo chmod -R 700 /data/coolify/ssh && docker restart coolify');
}
// Clean up test file
diff --git a/scripts/upgrade.sh b/scripts/upgrade.sh
index 648849d5c..f32db9b8d 100644
--- a/scripts/upgrade.sh
+++ b/scripts/upgrade.sh
@@ -141,6 +141,15 @@ else
log "Network 'coolify' already exists"
fi
+# Fix SSH directory ownership if not owned by container user UID 9999 (fixes #6621)
+# Only changes owner — preserves existing group to respect custom setups
+SSH_OWNER=$(stat -c '%u' /data/coolify/ssh 2>/dev/null || echo "unknown")
+if [ "$SSH_OWNER" != "9999" ]; then
+ log "Fixing SSH directory ownership (was owned by UID $SSH_OWNER)"
+ chown -R 9999 /data/coolify/ssh
+ chmod -R 700 /data/coolify/ssh
+fi
+
# Check if Docker config file exists
DOCKER_CONFIG_MOUNT=""
if [ -f /root/.docker/config.json ]; then
diff --git a/tests/Unit/PrivateKeyStorageTest.php b/tests/Unit/PrivateKeyStorageTest.php
index 00f39e3df..09472604b 100644
--- a/tests/Unit/PrivateKeyStorageTest.php
+++ b/tests/Unit/PrivateKeyStorageTest.php
@@ -112,7 +112,7 @@ uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
);
$this->expectException(\Exception::class);
- $this->expectExceptionMessage('SSH keys storage directory is not writable');
+ $this->expectExceptionMessage('SSH keys storage directory is not writable. Run on the host: sudo chown -R 9999 /data/coolify/ssh && sudo chmod -R 700 /data/coolify/ssh && docker restart coolify');
PrivateKey::createAndStore([
'name' => 'Test Key',
From c9246559994f5006390c7665825e3bbd60370bb6 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Fri, 27 Feb 2026 22:42:48 +0100
Subject: [PATCH 17/30] feat(auth): restrict sensitive data visibility for team
members
Hide database passwords, connection URLs, and debug logs from team members:
- Database components: hide passwords and connection URLs for members
- Deployment UI: gate debug log toggle behind update permission
- Debug logs: prevent members from viewing debug output
- Storage/services: hide sensitive credentials from members
Members can still view non-sensitive configuration while admins retain full access to all data.
---
app/Http/Middleware/ApiAbility.php | 4 +-
.../Project/Application/Deployment/Show.php | 4 +-
.../Project/Application/DeploymentNavbar.php | 9 ++-
.../Project/Database/Clickhouse/General.php | 9 +++
.../Project/Database/Dragonfly/General.php | 9 +++
.../Project/Database/Keydb/General.php | 9 +++
.../Project/Database/Mariadb/General.php | 10 ++++
.../Project/Database/Mongodb/General.php | 9 +++
.../Project/Database/Mysql/General.php | 10 ++++
.../Project/Database/Postgresql/General.php | 9 +++
.../Project/Database/Redis/General.php | 9 +++
app/Livewire/Project/Service/FileStorage.php | 3 +-
app/Livewire/Project/Service/StackForm.php | 13 +++++
app/Livewire/Storage/Form.php | 8 +++
bootstrap/helpers/remoteProcess.php | 5 ++
.../application/deployment/show.blade.php | 2 +
.../database/clickhouse/general.blade.php | 42 +++++++++-----
.../database/dragonfly/general.blade.php | 44 ++++++++++-----
.../project/database/keydb/general.blade.php | 42 +++++++++-----
.../database/mariadb/general.blade.php | 56 +++++++++++++------
.../database/mongodb/general.blade.php | 40 +++++++++----
.../project/database/mysql/general.blade.php | 52 ++++++++++++-----
.../database/postgresql/general.blade.php | 38 +++++++++----
.../project/database/redis/general.blade.php | 38 +++++++++----
.../project/service/file-storage.blade.php | 2 +-
.../project/service/stack-form.blade.php | 12 ++--
.../views/livewire/storage/form.blade.php | 13 +++--
27 files changed, 382 insertions(+), 119 deletions(-)
diff --git a/app/Http/Middleware/ApiAbility.php b/app/Http/Middleware/ApiAbility.php
index d42e09136..874e63825 100644
--- a/app/Http/Middleware/ApiAbility.php
+++ b/app/Http/Middleware/ApiAbility.php
@@ -21,9 +21,9 @@ class ApiAbility extends CheckForAnyAbility
{
try {
$token = $request->user()->currentAccessToken();
- $teamId = (int) data_get($token, 'team_id');
+ $teamId = data_get($token, 'team_id');
- if ($teamId && ! $request->user()->isAdminOfTeam($teamId)) {
+ if ($teamId !== null && ! $request->user()->isAdminOfTeam((int) $teamId)) {
$tokenAbilities = $token->abilities ?? [];
$disallowed = array_intersect($tokenAbilities, self::MEMBER_DISALLOWED_ABILITIES);
diff --git a/app/Livewire/Project/Application/Deployment/Show.php b/app/Livewire/Project/Application/Deployment/Show.php
index 189dcca9b..b8f5e891a 100644
--- a/app/Livewire/Project/Application/Deployment/Show.php
+++ b/app/Livewire/Project/Application/Deployment/Show.php
@@ -59,7 +59,9 @@ class Show extends Component
$this->application_deployment_queue = $application_deployment_queue;
$this->horizon_job_status = $this->application_deployment_queue->getHorizonJobStatus();
$this->deployment_uuid = $deploymentUuid;
- $this->is_debug_enabled = $this->application->settings->is_debug_enabled;
+ $this->is_debug_enabled = auth()->user()->isMember()
+ ? false
+ : $this->application->settings->is_debug_enabled;
$this->isKeepAliveOn();
}
diff --git a/app/Livewire/Project/Application/DeploymentNavbar.php b/app/Livewire/Project/Application/DeploymentNavbar.php
index fcd0d4cf4..b60f543ba 100644
--- a/app/Livewire/Project/Application/DeploymentNavbar.php
+++ b/app/Livewire/Project/Application/DeploymentNavbar.php
@@ -28,7 +28,9 @@ class DeploymentNavbar extends Component
{
$this->application = Application::ownedByCurrentTeam()->find($this->application_deployment_queue->application_id);
$this->server = $this->application->destination->server;
- $this->is_debug_enabled = $this->application->settings->is_debug_enabled;
+ $this->is_debug_enabled = auth()->user()->isMember()
+ ? false
+ : $this->application->settings->is_debug_enabled;
}
public function deploymentFinished()
@@ -67,10 +69,15 @@ class DeploymentNavbar extends Component
return '';
}
+ $isMember = auth()->user()->isMember();
+
$markdown = "# Deployment Logs\n\n";
$markdown .= "```\n";
foreach ($logs as $log) {
+ if ($isMember && ! empty($log['hidden'])) {
+ continue;
+ }
if (isset($log['output'])) {
$markdown .= $log['output']."\n";
}
diff --git a/app/Livewire/Project/Database/Clickhouse/General.php b/app/Livewire/Project/Database/Clickhouse/General.php
index 7ad453fd5..e790f2a40 100644
--- a/app/Livewire/Project/Database/Clickhouse/General.php
+++ b/app/Livewire/Project/Database/Clickhouse/General.php
@@ -44,6 +44,8 @@ class General extends Component
public bool $isLogDrainEnabled = false;
+ public bool $isPasswordHiddenForMember = false;
+
public function getListeners()
{
$teamId = Auth::user()->currentTeam()->id;
@@ -67,6 +69,13 @@ class General extends Component
} catch (\Throwable $e) {
return handleError($e, $this);
}
+
+ $this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false;
+ if ($this->isPasswordHiddenForMember) {
+ $this->clickhouseAdminPassword = '';
+ $this->dbUrl = null;
+ $this->dbUrlPublic = null;
+ }
}
protected function rules(): array
diff --git a/app/Livewire/Project/Database/Dragonfly/General.php b/app/Livewire/Project/Database/Dragonfly/General.php
index 4e325b9ee..4d74dbe22 100644
--- a/app/Livewire/Project/Database/Dragonfly/General.php
+++ b/app/Livewire/Project/Database/Dragonfly/General.php
@@ -48,6 +48,8 @@ class General extends Component
public bool $enable_ssl = false;
+ public bool $isPasswordHiddenForMember = false;
+
public function getListeners()
{
$userId = Auth::id();
@@ -79,6 +81,13 @@ class General extends Component
} catch (\Throwable $e) {
return handleError($e, $this);
}
+
+ $this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false;
+ if ($this->isPasswordHiddenForMember) {
+ $this->dragonflyPassword = '';
+ $this->dbUrl = null;
+ $this->dbUrlPublic = null;
+ }
}
protected function rules(): array
diff --git a/app/Livewire/Project/Database/Keydb/General.php b/app/Livewire/Project/Database/Keydb/General.php
index f02aa6674..1877f0cb8 100644
--- a/app/Livewire/Project/Database/Keydb/General.php
+++ b/app/Livewire/Project/Database/Keydb/General.php
@@ -50,6 +50,8 @@ class General extends Component
public bool $enable_ssl = false;
+ public bool $isPasswordHiddenForMember = false;
+
public function getListeners()
{
$userId = Auth::id();
@@ -81,6 +83,13 @@ class General extends Component
} catch (\Throwable $e) {
return handleError($e, $this);
}
+
+ $this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false;
+ if ($this->isPasswordHiddenForMember) {
+ $this->keydbPassword = '';
+ $this->dbUrl = null;
+ $this->dbUrlPublic = null;
+ }
}
protected function rules(): array
diff --git a/app/Livewire/Project/Database/Mariadb/General.php b/app/Livewire/Project/Database/Mariadb/General.php
index 74658e2a4..ccf7a440d 100644
--- a/app/Livewire/Project/Database/Mariadb/General.php
+++ b/app/Livewire/Project/Database/Mariadb/General.php
@@ -56,6 +56,8 @@ class General extends Component
public ?Carbon $certificateValidUntil = null;
+ public bool $isPasswordHiddenForMember = false;
+
public function getListeners()
{
$userId = Auth::id();
@@ -137,6 +139,14 @@ class General extends Component
} catch (Exception $e) {
return handleError($e, $this);
}
+
+ $this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false;
+ if ($this->isPasswordHiddenForMember) {
+ $this->mariadbRootPassword = '';
+ $this->mariadbPassword = '';
+ $this->db_url = null;
+ $this->db_url_public = null;
+ }
}
public function syncData(bool $toModel = false)
diff --git a/app/Livewire/Project/Database/Mongodb/General.php b/app/Livewire/Project/Database/Mongodb/General.php
index 9f34b73d5..20490c4e1 100644
--- a/app/Livewire/Project/Database/Mongodb/General.php
+++ b/app/Livewire/Project/Database/Mongodb/General.php
@@ -56,6 +56,8 @@ class General extends Component
public ?Carbon $certificateValidUntil = null;
+ public bool $isPasswordHiddenForMember = false;
+
public function getListeners()
{
$userId = Auth::id();
@@ -137,6 +139,13 @@ class General extends Component
} catch (Exception $e) {
return handleError($e, $this);
}
+
+ $this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false;
+ if ($this->isPasswordHiddenForMember) {
+ $this->mongoInitdbRootPassword = '';
+ $this->db_url = null;
+ $this->db_url_public = null;
+ }
}
public function syncData(bool $toModel = false)
diff --git a/app/Livewire/Project/Database/Mysql/General.php b/app/Livewire/Project/Database/Mysql/General.php
index 86b109251..3a82af581 100644
--- a/app/Livewire/Project/Database/Mysql/General.php
+++ b/app/Livewire/Project/Database/Mysql/General.php
@@ -58,6 +58,8 @@ class General extends Component
public ?Carbon $certificateValidUntil = null;
+ public bool $isPasswordHiddenForMember = false;
+
public function getListeners()
{
$userId = Auth::id();
@@ -142,6 +144,14 @@ class General extends Component
} catch (Exception $e) {
return handleError($e, $this);
}
+
+ $this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false;
+ if ($this->isPasswordHiddenForMember) {
+ $this->mysqlRootPassword = '';
+ $this->mysqlPassword = '';
+ $this->db_url = null;
+ $this->db_url_public = null;
+ }
}
public function syncData(bool $toModel = false)
diff --git a/app/Livewire/Project/Database/Postgresql/General.php b/app/Livewire/Project/Database/Postgresql/General.php
index e24674315..fbea4506a 100644
--- a/app/Livewire/Project/Database/Postgresql/General.php
+++ b/app/Livewire/Project/Database/Postgresql/General.php
@@ -66,6 +66,8 @@ class General extends Component
public ?Carbon $certificateValidUntil = null;
+ public bool $isPasswordHiddenForMember = false;
+
public function getListeners()
{
$userId = Auth::id();
@@ -155,6 +157,13 @@ class General extends Component
} catch (Exception $e) {
return handleError($e, $this);
}
+
+ $this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false;
+ if ($this->isPasswordHiddenForMember) {
+ $this->postgresPassword = '';
+ $this->db_url = null;
+ $this->db_url_public = null;
+ }
}
public function syncData(bool $toModel = false)
diff --git a/app/Livewire/Project/Database/Redis/General.php b/app/Livewire/Project/Database/Redis/General.php
index 08bcdc343..b777768ea 100644
--- a/app/Livewire/Project/Database/Redis/General.php
+++ b/app/Livewire/Project/Database/Redis/General.php
@@ -54,6 +54,8 @@ class General extends Component
public ?Carbon $certificateValidUntil = null;
+ public bool $isPasswordHiddenForMember = false;
+
public function getListeners()
{
$userId = Auth::id();
@@ -130,6 +132,13 @@ class General extends Component
} catch (\Throwable $e) {
return handleError($e, $this);
}
+
+ $this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false;
+ if ($this->isPasswordHiddenForMember) {
+ $this->redisPassword = '';
+ $this->dbUrl = null;
+ $this->dbUrlPublic = null;
+ }
}
public function syncData(bool $toModel = false)
diff --git a/app/Livewire/Project/Service/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php
index 079115bb6..596a2646c 100644
--- a/app/Livewire/Project/Service/FileStorage.php
+++ b/app/Livewire/Project/Service/FileStorage.php
@@ -101,8 +101,7 @@ class FileStorage extends Component
public function loadStorageOnServer()
{
try {
- // Loading content is a read operation, so we use 'view' permission
- $this->authorize('view', $this->resource);
+ $this->authorize('update', $this->resource);
$this->fileStorage->loadStorageOnServer();
$this->syncData();
diff --git a/app/Livewire/Project/Service/StackForm.php b/app/Livewire/Project/Service/StackForm.php
index 678db17e8..86d5a57c1 100644
--- a/app/Livewire/Project/Service/StackForm.php
+++ b/app/Livewire/Project/Service/StackForm.php
@@ -17,6 +17,8 @@ class StackForm extends Component
public Collection $fields;
+ public bool $isPasswordHiddenForMember = false;
+
protected $listeners = ['saveCompose'];
// Explicit properties
@@ -121,6 +123,17 @@ class StackForm extends Component
})->flatMap(function ($group) {
return $group;
});
+
+ $this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false;
+ if ($this->isPasswordHiddenForMember) {
+ $this->fields = $this->fields->map(function ($field) {
+ if (data_get($field, 'isPassword')) {
+ $field['value'] = null;
+ }
+
+ return $field;
+ });
+ }
}
public function saveCompose($raw)
diff --git a/app/Livewire/Storage/Form.php b/app/Livewire/Storage/Form.php
index 4dc0b6ae2..bc1726f10 100644
--- a/app/Livewire/Storage/Form.php
+++ b/app/Livewire/Storage/Form.php
@@ -31,6 +31,8 @@ class Form extends Component
public ?bool $isUsable = null;
+ public bool $isPasswordHiddenForMember = false;
+
protected function rules(): array
{
return [
@@ -109,6 +111,12 @@ class Form extends Component
public function mount()
{
$this->syncData(false);
+
+ $this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false;
+ if ($this->isPasswordHiddenForMember) {
+ $this->key = '';
+ $this->secret = '';
+ }
}
public function testConnection()
diff --git a/bootstrap/helpers/remoteProcess.php b/bootstrap/helpers/remoteProcess.php
index 217c82929..cb9f3bcab 100644
--- a/bootstrap/helpers/remoteProcess.php
+++ b/bootstrap/helpers/remoteProcess.php
@@ -183,6 +183,11 @@ function decode_remote_command_output(?ApplicationDeploymentQueue $application_d
$application = Application::find(data_get($application_deployment_queue, 'application_id'));
$is_debug_enabled = data_get($application, 'settings.is_debug_enabled');
+ // Members should never see debug logs, even if an admin enabled debug mode
+ if ($is_debug_enabled && auth()->check() && auth()->user()->isMember()) {
+ $is_debug_enabled = false;
+ }
+
$logs = data_get($application_deployment_queue, 'logs');
if (empty($logs)) {
return collect([]);
diff --git a/resources/views/livewire/project/application/deployment/show.blade.php b/resources/views/livewire/project/application/deployment/show.blade.php
index 5861cef30..4ea4a65bc 100644
--- a/resources/views/livewire/project/application/deployment/show.blade.php
+++ b/resources/views/livewire/project/application/deployment/show.blade.php
@@ -290,6 +290,7 @@
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
+ @can('update', $application)
@@ -299,6 +300,7 @@
d="M12 12.75c1.148 0 2.278.08 3.383.237 1.037.146 1.866.966 1.866 2.013 0 3.728-2.35 6.75-5.25 6.75S6.75 18.728 6.75 15c0-1.046.83-1.867 1.866-2.013A24.204 24.204 0 0 1 12 12.75Zm0 0c2.883 0 5.647.508 8.207 1.44a23.91 23.91 0 0 1-1.152 6.06M12 12.75c-2.883 0-5.647.508-8.208 1.44.125 2.104.52 4.136 1.153 6.06M12 12.75a2.25 2.25 0 0 0 2.248-2.354M12 12.75a2.25 2.25 0 0 1-2.248-2.354M12 8.25c.995 0 1.971-.08 2.922-.236.403-.066.74-.358.795-.762a3.778 3.778 0 0 0-.399-2.25M12 8.25c-.995 0-1.97-.08-2.922-.236-.402-.066-.74-.358-.795-.762a3.734 3.734 0 0 1 .4-2.253M12 8.25a2.25 2.25 0 0 0-2.248 2.146M12 8.25a2.25 2.25 0 0 1 2.248 2.146M8.683 5a6.032 6.032 0 0 1-1.155-1.002c.07-.63.27-1.222.574-1.747m.581 2.749A3.75 3.75 0 0 1 15.318 5m0 0c.427-.283.815-.62 1.155-.999a4.471 4.471 0 0 0-.575-1.752M4.921 6a24.048 24.048 0 0 0-.392 3.314c1.668.546 3.416.914 5.223 1.082M19.08 6c.205 1.08.337 2.187.392 3.314a23.882 23.882 0 0 1-5.223 1.082" />
+ @endcan
diff --git a/resources/views/livewire/project/database/clickhouse/general.blade.php b/resources/views/livewire/project/database/clickhouse/general.blade.php
index 2010e0afc..2ba7c59af 100644
--- a/resources/views/livewire/project/database/clickhouse/general.blade.php
+++ b/resources/views/livewire/project/database/clickhouse/general.blade.php
@@ -17,8 +17,12 @@
-
+ @if ($isPasswordHiddenForMember)
+
+ @else
+
+ @endif
@else
Please verify these values. You can only modify them before the initial
@@ -26,8 +30,12 @@
-
+ @if ($isPasswordHiddenForMember)
+
+ @else
+
+ @endif
@endif
-
- @if ($dbUrlPublic)
-
+ @if ($isPasswordHiddenForMember)
+
@else
-
+ type="password" readonly wire:model="dbUrl" canGate="update" :canResource="$database" />
+ @endif
+ @if ($isPasswordHiddenForMember)
+
+ @else
+ @if ($dbUrlPublic)
+
+ @else
+
+ @endif
@endif
diff --git a/resources/views/livewire/project/database/dragonfly/general.blade.php b/resources/views/livewire/project/database/dragonfly/general.blade.php
index 2b2e5d355..85f2a505a 100644
--- a/resources/views/livewire/project/database/dragonfly/general.blade.php
+++ b/resources/views/livewire/project/database/dragonfly/general.blade.php
@@ -18,16 +18,24 @@
@if ($database->started_at)
-
+ @if ($isPasswordHiddenForMember)
+
+ @else
+
+ @endif
@else
Please verify these values. You can only modify them before the initial
start. After that, you need to modify it in the database.
-
+ @if ($isPasswordHiddenForMember)
+
+ @else
+
+ @endif
@endif
@@ -37,18 +45,26 @@
helper="A comma separated list of ports you would like to map to the host system.Example 3000:5432,3002:5433"
canGate="update" :canResource="$database" />
-
-
- @if ($dbUrlPublic)
-
+ @if ($isPasswordHiddenForMember)
+
@else
-
+ type="password" readonly wire:model="dbUrl" canGate="update" :canResource="$database" />
+ @endif
+
+ @if ($isPasswordHiddenForMember)
+
+ @else
+ @if ($dbUrlPublic)
+
+ @else
+
+ @endif
@endif
diff --git a/resources/views/livewire/project/database/keydb/general.blade.php b/resources/views/livewire/project/database/keydb/general.blade.php
index 00c30edff..a0d60f6db 100644
--- a/resources/views/livewire/project/database/keydb/general.blade.php
+++ b/resources/views/livewire/project/database/keydb/general.blade.php
@@ -15,16 +15,24 @@
@if ($database->started_at)
-
+ @if ($isPasswordHiddenForMember)
+
+ @else
+
+ @endif
@else
Please verify these values. You can only modify them before the initial
start. After that, you need to modify it in the database.
-
+ @if ($isPasswordHiddenForMember)
+
+ @else
+
+ @endif
@endif
-
- @if ($dbUrlPublic)
-
+ @if ($isPasswordHiddenForMember)
+
@else
-
+ type="password" readonly wire:model="dbUrl" canGate="update" :canResource="$database" />
+ @endif
+ @if ($isPasswordHiddenForMember)
+
+ @else
+ @if ($dbUrlPublic)
+
+ @else
+
+ @endif
@endif
diff --git a/resources/views/livewire/project/database/mariadb/general.blade.php b/resources/views/livewire/project/database/mariadb/general.blade.php
index f51f077c0..b9f6b0da5 100644
--- a/resources/views/livewire/project/database/mariadb/general.blade.php
+++ b/resources/views/livewire/project/database/mariadb/general.blade.php
@@ -17,15 +17,23 @@
@if ($database->started_at)
-
+ @if ($isPasswordHiddenForMember)
+
+ @else
+
+ @endif
-
+ @if ($isPasswordHiddenForMember)
+
+ @else
+
+ @endif
@else
-
+ @if ($isPasswordHiddenForMember)
+
+ @else
+
+ @endif
-
+ @if ($isPasswordHiddenForMember)
+
+ @else
+
+ @endif
-
- @if ($db_url_public)
-
+ @else
+
+ type="password" readonly wire:model="db_url" canGate="update" :canResource="$database" />
+ @endif
+ @if ($isPasswordHiddenForMember)
+
+ @else
+ @if ($db_url_public)
+
+ @endif
@endif
diff --git a/resources/views/livewire/project/database/mongodb/general.blade.php b/resources/views/livewire/project/database/mongodb/general.blade.php
index a474153f1..1b6f98be2 100644
--- a/resources/views/livewire/project/database/mongodb/general.blade.php
+++ b/resources/views/livewire/project/database/mongodb/general.blade.php
@@ -21,10 +21,14 @@
placeholder="If empty: postgres"
helper="If you change this in the database, please sync it here, otherwise automations (like backups) won't work."
canGate="update" :canResource="$database" />
-
+ @if ($isPasswordHiddenForMember)
+
+ @else
+
+ @endif
@@ -33,8 +37,12 @@
-
+ @if ($isPasswordHiddenForMember)
+
+ @else
+
+ @endif
@@ -50,13 +58,21 @@
helper="A comma separated list of ports you would like to map to the host system.
Example 3000:5432,3002:5433"
canGate="update" :canResource="$database" />
-
- @if ($db_url_public)
-
+ @else
+
+ type="password" readonly wire:model="db_url" canGate="update" :canResource="$database" />
+ @endif
+ @if ($isPasswordHiddenForMember)
+
+ @else
+ @if ($db_url_public)
+
+ @endif
@endif
diff --git a/resources/views/livewire/project/database/mysql/general.blade.php b/resources/views/livewire/project/database/mysql/general.blade.php
index 8187878e4..d065fccb0 100644
--- a/resources/views/livewire/project/database/mysql/general.blade.php
+++ b/resources/views/livewire/project/database/mysql/general.blade.php
@@ -17,12 +17,20 @@
@if ($database->started_at)
-
+ @if ($isPasswordHiddenForMember)
+
+ @else
+
+ @endif
-
+ @if ($isPasswordHiddenForMember)
+
+ @else
+
+ @endif
@else
-
+ @if ($isPasswordHiddenForMember)
+
+ @else
+
+ @endif
-
+ @if ($isPasswordHiddenForMember)
+
+ @else
+
+ @endif
-
- @if ($db_url_public)
-
+ @else
+
+ type="password" readonly wire:model="db_url" />
+ @endif
+ @if ($isPasswordHiddenForMember)
+
+ @else
+ @if ($db_url_public)
+
+ @endif
@endif
diff --git a/resources/views/livewire/project/database/postgresql/general.blade.php b/resources/views/livewire/project/database/postgresql/general.blade.php
index 7300b913a..b87ad7412 100644
--- a/resources/views/livewire/project/database/postgresql/general.blade.php
+++ b/resources/views/livewire/project/database/postgresql/general.blade.php
@@ -34,9 +34,13 @@
-
+ @if ($isPasswordHiddenForMember)
+
+ @else
+
+ @endif
@@ -45,8 +49,12 @@
-
+ @if ($isPasswordHiddenForMember)
+
+ @else
+
+ @endif
@@ -69,13 +77,21 @@
canGate="update" :canResource="$database" />
-
- @if ($db_url_public)
-
+ @else
+
+ type="password" readonly wire:model="db_url" />
+ @endif
+ @if ($isPasswordHiddenForMember)
+
+ @else
+ @if ($db_url_public)
+
+ @endif
@endif
diff --git a/resources/views/livewire/project/database/redis/general.blade.php b/resources/views/livewire/project/database/redis/general.blade.php
index f37674186..8b72ce934 100644
--- a/resources/views/livewire/project/database/redis/general.blade.php
+++ b/resources/views/livewire/project/database/redis/general.blade.php
@@ -23,8 +23,12 @@
@endif
-
+ @if ($isPasswordHiddenForMember)
+
+ @else
+
+ @endif
@else
You can only change the username and password in the database after
@@ -39,13 +43,17 @@
Note: If the environment variable REDIS_USERNAME is set as a shared variable (environment, project, or team-based), this input field will become read-only."
:disabled="$this->isSharedVariable('REDIS_USERNAME')" canGate="update" :canResource="$database" />
@endif
-
+ @else
+
+ :disabled="$this->isSharedVariable('REDIS_PASSWORD')" canGate="update" :canResource="$database" />
+ @endif
@endif
@@ -60,13 +68,21 @@
helper="A comma separated list of ports you would like to map to the host system.Example 3000:5432,3002:5433"
canGate="update" :canResource="$database" />
-
- @if ($dbUrlPublic)
-
+ @else
+
+ type="password" readonly wire:model="dbUrl" canGate="update" :canResource="$database" />
+ @endif
+ @if ($isPasswordHiddenForMember)
+
+ @else
+ @if ($dbUrlPublic)
+
+ @endif
@endif
diff --git a/resources/views/livewire/project/service/file-storage.blade.php b/resources/views/livewire/project/service/file-storage.blade.php
index 1dd58fe17..472398db8 100644
--- a/resources/views/livewire/project/service/file-storage.blade.php
+++ b/resources/views/livewire/project/service/file-storage.blade.php
@@ -87,7 +87,7 @@
@else
{{-- Read-only view --}}
@if (!$fileStorage->is_directory)
- @can('view', $resource)
+ @can('update', $resource)
Load from
server
diff --git a/resources/views/livewire/project/service/stack-form.blade.php b/resources/views/livewire/project/service/stack-form.blade.php
index 8972345bc..2cdf57266 100644
--- a/resources/views/livewire/project/service/stack-form.blade.php
+++ b/resources/views/livewire/project/service/stack-form.blade.php
@@ -39,10 +39,14 @@
@endif
-
+ @if ($isPasswordHiddenForMember && data_get($field, 'isPassword'))
+
+ @else
+
+ @endif
@endforeach
@endif
diff --git a/resources/views/livewire/storage/form.blade.php b/resources/views/livewire/storage/form.blade.php
index 850d7735f..432193593 100644
--- a/resources/views/livewire/storage/form.blade.php
+++ b/resources/views/livewire/storage/form.blade.php
@@ -41,10 +41,15 @@
-
-
+ @if ($isPasswordHiddenForMember)
+
+
+ @else
+
+
+ @endif
@can('validateConnection', $storage)
From 66dc1515d43781347fd79ca7f713eeaded4b0150 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Fri, 27 Feb 2026 22:58:44 +0100
Subject: [PATCH 18/30] fix(security): prevent snapshot replay in API token
permission checks
Never trust Livewire component properties for authorization decisions, as
snapshots can be replayed from another user's session. Re-evaluate all
permission checks fresh using auth()->user()->can() against current policies
to ensure the authenticated user is being authorized, not a replayed copy.
- Replace cached canUse* booleans with fresh policy evaluation
- Add comprehensive security tests for token creation permissions
- Update API authorization tests to verify middleware blocking behavior
---
app/Livewire/Security/ApiTokens.php | 24 +--
.../Authorization/ApiAuthorizationTest.php | 4 +-
.../Authorization/ApiTokenPermissionTest.php | 3 +
.../Security/ApiTokenCreationSecurityTest.php | 166 ++++++++++++++++++
4 files changed, 183 insertions(+), 14 deletions(-)
create mode 100644 tests/Feature/Security/ApiTokenCreationSecurityTest.php
diff --git a/app/Livewire/Security/ApiTokens.php b/app/Livewire/Security/ApiTokens.php
index af2ae189a..ffd8f28cf 100644
--- a/app/Livewire/Security/ApiTokens.php
+++ b/app/Livewire/Security/ApiTokens.php
@@ -50,31 +50,29 @@ class ApiTokens extends Component
public function updatedPermissions($permissionToUpdate)
{
- // Check if user is trying to use restricted permissions
- if ($permissionToUpdate == 'root' && ! $this->canUseRootPermissions) {
+ // Re-evaluate policies fresh — never trust stored snapshot booleans
+ if ($permissionToUpdate == 'root' && ! auth()->user()->can('useRootPermissions', PersonalAccessToken::class)) {
$this->dispatch('error', 'You do not have permission to use root permissions.');
- // Remove root from permissions if it was somehow added
$this->permissions = array_diff($this->permissions, ['root']);
return;
}
- if (in_array($permissionToUpdate, ['write', 'write:sensitive']) && ! $this->canUseWritePermissions) {
+ if (in_array($permissionToUpdate, ['write', 'write:sensitive']) && ! auth()->user()->can('useWritePermissions', PersonalAccessToken::class)) {
$this->dispatch('error', 'You do not have permission to use write permissions.');
- // Remove write permissions if they were somehow added
$this->permissions = array_diff($this->permissions, ['write', 'write:sensitive']);
return;
}
- if ($permissionToUpdate == 'deploy' && ! $this->canUseDeployPermissions) {
+ if ($permissionToUpdate == 'deploy' && ! auth()->user()->can('useDeployPermissions', PersonalAccessToken::class)) {
$this->dispatch('error', 'You do not have permission to use deploy permissions.');
$this->permissions = array_diff($this->permissions, ['deploy']);
return;
}
- if ($permissionToUpdate == 'read:sensitive' && ! $this->canUseSensitivePermissions) {
+ if ($permissionToUpdate == 'read:sensitive' && ! auth()->user()->can('useSensitivePermissions', PersonalAccessToken::class)) {
$this->dispatch('error', 'You do not have permission to use read:sensitive permissions.');
$this->permissions = array_diff($this->permissions, ['read:sensitive']);
@@ -100,20 +98,22 @@ class ApiTokens extends Component
try {
$this->authorize('create', PersonalAccessToken::class);
- // Validate permissions based on user role
- if (in_array('root', $this->permissions) && ! $this->canUseRootPermissions) {
+ // Re-evaluate policies fresh against the current authenticated user.
+ // Never trust $this->canUse* booleans — they come from the Livewire
+ // snapshot which can be replayed from another user's session.
+ if (in_array('root', $this->permissions) && ! auth()->user()->can('useRootPermissions', PersonalAccessToken::class)) {
throw new \Exception('You do not have permission to create tokens with root permissions.');
}
- if (array_intersect(['write', 'write:sensitive'], $this->permissions) && ! $this->canUseWritePermissions) {
+ if (array_intersect(['write', 'write:sensitive'], $this->permissions) && ! auth()->user()->can('useWritePermissions', PersonalAccessToken::class)) {
throw new \Exception('You do not have permission to create tokens with write permissions.');
}
- if (in_array('deploy', $this->permissions) && ! $this->canUseDeployPermissions) {
+ if (in_array('deploy', $this->permissions) && ! auth()->user()->can('useDeployPermissions', PersonalAccessToken::class)) {
throw new \Exception('You do not have permission to create tokens with deploy permissions.');
}
- if (in_array('read:sensitive', $this->permissions) && ! $this->canUseSensitivePermissions) {
+ if (in_array('read:sensitive', $this->permissions) && ! auth()->user()->can('useSensitivePermissions', PersonalAccessToken::class)) {
throw new \Exception('You do not have permission to create tokens with read:sensitive permissions.');
}
diff --git a/tests/Feature/Authorization/ApiAuthorizationTest.php b/tests/Feature/Authorization/ApiAuthorizationTest.php
index 59bfd9659..66a6900a3 100644
--- a/tests/Feature/Authorization/ApiAuthorizationTest.php
+++ b/tests/Feature/Authorization/ApiAuthorizationTest.php
@@ -123,10 +123,10 @@ test('admin with root token can view database', function () {
// --- Member with root token (policy should deny mutations) ---
-test('member with root token can view project', function () {
+test('member with root token is blocked by middleware', function () {
$this->withToken($this->memberRootToken->plainTextToken)
->getJson("/api/v1/projects/{$this->project->uuid}")
- ->assertSuccessful();
+ ->assertStatus(403);
});
test('member with root token cannot delete project', function () {
diff --git a/tests/Feature/Authorization/ApiTokenPermissionTest.php b/tests/Feature/Authorization/ApiTokenPermissionTest.php
index 44efb7e06..b10afb58e 100644
--- a/tests/Feature/Authorization/ApiTokenPermissionTest.php
+++ b/tests/Feature/Authorization/ApiTokenPermissionTest.php
@@ -1,5 +1,6 @@
0], ['is_api_enabled' => true]);
+
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
diff --git a/tests/Feature/Security/ApiTokenCreationSecurityTest.php b/tests/Feature/Security/ApiTokenCreationSecurityTest.php
new file mode 100644
index 000000000..0e5d363de
--- /dev/null
+++ b/tests/Feature/Security/ApiTokenCreationSecurityTest.php
@@ -0,0 +1,166 @@
+ 0], ['is_api_enabled' => true]);
+
+ $this->team = Team::factory()->create();
+
+ $this->owner = User::factory()->create();
+ $this->owner->teams()->attach($this->team, ['role' => 'owner']);
+
+ $this->member = User::factory()->create();
+ $this->member->teams()->attach($this->team, ['role' => 'member']);
+});
+
+describe('Livewire ApiTokens — member cannot create elevated tokens', function () {
+ test('member cannot create token with root permissions', function () {
+ $this->actingAs($this->member);
+ session(['currentTeam' => $this->team]);
+
+ Livewire::test(ApiTokens::class)
+ ->set('description', 'my-root-token')
+ ->set('permissions', ['root'])
+ ->call('addNewToken')
+ ->assertDispatched('error');
+
+ expect($this->member->tokens()->count())->toBe(0);
+ });
+
+ test('member cannot create token with write permissions', function () {
+ $this->actingAs($this->member);
+ session(['currentTeam' => $this->team]);
+
+ Livewire::test(ApiTokens::class)
+ ->set('description', 'my-write-token')
+ ->set('permissions', ['write'])
+ ->call('addNewToken')
+ ->assertDispatched('error');
+
+ expect($this->member->tokens()->count())->toBe(0);
+ });
+
+ test('member cannot create token with deploy permissions', function () {
+ $this->actingAs($this->member);
+ session(['currentTeam' => $this->team]);
+
+ Livewire::test(ApiTokens::class)
+ ->set('description', 'my-deploy-token')
+ ->set('permissions', ['deploy'])
+ ->call('addNewToken')
+ ->assertDispatched('error');
+
+ expect($this->member->tokens()->count())->toBe(0);
+ });
+
+ test('member cannot create token with read:sensitive permissions', function () {
+ $this->actingAs($this->member);
+ session(['currentTeam' => $this->team]);
+
+ Livewire::test(ApiTokens::class)
+ ->set('description', 'my-sensitive-token')
+ ->set('permissions', ['read', 'read:sensitive'])
+ ->call('addNewToken')
+ ->assertDispatched('error');
+
+ expect($this->member->tokens()->count())->toBe(0);
+ });
+
+ test('member cannot bypass by setting canUseRootPermissions property', function () {
+ $this->actingAs($this->member);
+ session(['currentTeam' => $this->team]);
+
+ // Simulate snapshot replay: force the boolean to true
+ Livewire::test(ApiTokens::class)
+ ->set('canUseRootPermissions', true)
+ ->set('description', 'sneaky-root-token')
+ ->set('permissions', ['root'])
+ ->call('addNewToken')
+ ->assertDispatched('error');
+
+ expect($this->member->tokens()->count())->toBe(0);
+ });
+
+ test('member can create token with read permissions', function () {
+ $this->actingAs($this->member);
+ session(['currentTeam' => $this->team]);
+
+ Livewire::test(ApiTokens::class)
+ ->set('description', 'my-read-token')
+ ->set('permissions', ['read'])
+ ->call('addNewToken')
+ ->assertNotDispatched('error');
+
+ expect($this->member->tokens()->count())->toBe(1);
+ expect($this->member->tokens()->first()->abilities)->toBe(['read']);
+ });
+
+ test('owner can create token with root permissions', function () {
+ $this->actingAs($this->owner);
+ session(['currentTeam' => $this->team]);
+
+ Livewire::test(ApiTokens::class)
+ ->set('description', 'my-root-token')
+ ->set('permissions', ['root'])
+ ->call('addNewToken')
+ ->assertNotDispatched('error');
+
+ expect($this->owner->tokens()->count())->toBe(1);
+ expect($this->owner->tokens()->first()->abilities)->toBe(['root']);
+ });
+});
+
+describe('ApiAbility middleware — member with elevated token blocked', function () {
+ test('member root token is blocked on team_id=0 (root team)', function () {
+ // Create root team with id=0
+ $rootTeam = Team::factory()->create(['id' => 0]);
+ $member = User::factory()->create();
+ $rootTeam->members()->attach($member->id, ['role' => 'member']);
+
+ session(['currentTeam' => $rootTeam]);
+ $token = $member->createToken('root-token', ['root']);
+
+ $this->withToken($token->plainTextToken)
+ ->getJson('/api/v1/projects')
+ ->assertStatus(403);
+ });
+
+ test('admin root token passes on team_id=0 (root team)', function () {
+ $rootTeam = Team::factory()->create(['id' => 0]);
+ $admin = User::factory()->create();
+ $rootTeam->members()->attach($admin->id, ['role' => 'admin']);
+
+ session(['currentTeam' => $rootTeam]);
+ $token = $admin->createToken('root-token', ['root']);
+
+ $this->withToken($token->plainTextToken)
+ ->getJson('/api/v1/projects')
+ ->assertSuccessful();
+ });
+
+ test('member root token is blocked on non-zero team', function () {
+ session(['currentTeam' => $this->team]);
+ $token = $this->member->createToken('root-token', ['root']);
+
+ $this->withToken($token->plainTextToken)
+ ->getJson('/api/v1/projects')
+ ->assertStatus(403);
+ });
+
+ test('member read token passes on non-zero team', function () {
+ session(['currentTeam' => $this->team]);
+ $token = $this->member->createToken('read-token', ['read']);
+
+ $this->withToken($token->plainTextToken)
+ ->getJson('/api/v1/projects')
+ ->assertSuccessful();
+ });
+});
From b2f09f4df068f8b7bab2dda93938ca88501266e2 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Fri, 27 Feb 2026 23:11:03 +0100
Subject: [PATCH 19/30] fix(auth): resolve current team from Sanctum token for
API requests
Add fallback to resolve team from Sanctum access token when session team
is unavailable, enabling proper team context for stateless API requests.
---
app/Models/User.php | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/app/Models/User.php b/app/Models/User.php
index 4561cddb2..68ecc6b31 100644
--- a/app/Models/User.php
+++ b/app/Models/User.php
@@ -316,6 +316,11 @@ class User extends Authenticatable implements SendsEmail
{
$sessionTeamId = data_get(session('currentTeam'), 'id');
+ // Fallback for stateless API requests: resolve team from Sanctum token
+ if (is_null($sessionTeamId) && $this->currentAccessToken()) {
+ $sessionTeamId = data_get($this->currentAccessToken(), 'team_id');
+ }
+
if (is_null($sessionTeamId)) {
return null;
}
From b38ce26e34d185c283f40fbe76919f6540f6e15f Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Sun, 1 Mar 2026 14:08:29 +0100
Subject: [PATCH 20/30] fix(auth): preserve Sanctum token prefix for lookups
Sanctum uses the numeric prefix (e.g. "69|...") in plaintext tokens
to index and look up tokens. Stripping this prefix breaks token
resolution.
---
app/Livewire/Security/ApiTokens.php | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/app/Livewire/Security/ApiTokens.php b/app/Livewire/Security/ApiTokens.php
index ffd8f28cf..97d2bfb44 100644
--- a/app/Livewire/Security/ApiTokens.php
+++ b/app/Livewire/Security/ApiTokens.php
@@ -4,7 +4,6 @@ namespace App\Livewire\Security;
use App\Models\InstanceSettings;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
-use Illuminate\Support\Str;
use Laravel\Sanctum\PersonalAccessToken;
use Livewire\Component;
@@ -122,7 +121,8 @@ class ApiTokens extends Component
]);
$token = auth()->user()->createToken($this->description, array_values($this->permissions));
$this->getTokens();
- session()->flash('token', Str::after($token->plainTextToken, '|'));
+ // Do NOT strip the numeric prefix (e.g. "69|...") — Sanctum uses it to index and look up tokens.
+ session()->flash('token', $token->plainTextToken);
} catch (\Exception $e) {
return handleError($e, $this);
}
From 8a715489cbc27693b5e00d3eec0f3acb30d48bc9 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Sun, 19 Apr 2026 17:42:17 +0200
Subject: [PATCH 21/30] Delete lessons.md
---
.ai/lessons.md | 20 --------------------
1 file changed, 20 deletions(-)
delete mode 100644 .ai/lessons.md
diff --git a/.ai/lessons.md b/.ai/lessons.md
deleted file mode 100644
index 6bd6dcbaa..000000000
--- a/.ai/lessons.md
+++ /dev/null
@@ -1,20 +0,0 @@
-# Lessons Learned
-
-## Docker / Worktree Setup
-- The Docker dev container mounts from `young-stork` worktree, NOT `ivory-raccoon`
-- Do NOT copy files to `young-stork` or use `docker cp` — only modify files in the `ivory-raccoon` worktree
-- Do NOT use `docker exec` to run tests — work entirely within the `ivory-raccoon` worktree
-
-## Policy Tests
-- Policy methods have typed parameters (e.g., `Server $server`) — anonymous classes cause TypeError
-- Must use `Mockery::mock(Model::class)->makePartial()` instead of anonymous classes for model stubs
-- Use `shouldReceive('getAttribute')->with('property')->andReturn(value)` for model properties accessed via relationship chains
-
-## Browser Tests (Pest Browser Plugin)
-- Plugin runs an in-process HTTP server (Amphp) sharing the same SQLite :memory: database as the test process
-- Model boot events that call external services (e.g., `StandaloneDocker::created` runs docker commands) WILL fail in tests — use `Model::withoutEvents()` to wrap creation
-- Livewire full-page components that fail during `mount()` silently redirect to the previous URL instead of showing an error page
-- `Server::proxySet()` requires `isFunctional()` which requires `is_reachable=true` AND `is_usable=true` in ServerSetting — tests without a validated server won't show proxy controls
-- Application/Database/Service pages require complex model chains (Application → Environment → Project → Team, with StandaloneDocker destination) that are difficult to fully set up for browser tests due to Livewire mount() redirecting on any chain failure
-- The `currentTeam()` helper reads from session (`data_get(session('currentTeam'), 'id')`) — set during browser login flow
-- `Project::created` auto-creates a "production" environment — don't manually create one with that name
From 6a5fd40a5c4a2c0dd5af4692ffa24b54fd2c616a Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Mon, 20 Apr 2026 11:51:35 +0200
Subject: [PATCH 22/30] refactor(policies): add uploadBackup ability and
enforce it on backup upload endpoint
Introduce a dedicated `uploadBackup` ability on Application, Database,
Service, and ServiceDatabase policies (admin/owner only) and call
`$this->authorize('uploadBackup', $resource)` in `UploadController::upload`
so the backup-upload endpoint goes through the same policy layer as the
rest of the authorization refactor. Adds Pest coverage for each policy
variant plus HTTP-level checks.
Co-Authored-By: Claude Opus 4.7
---
app/Http/Controllers/UploadController.php | 6 +
app/Policies/ApplicationPolicy.php | 18 ++
app/Policies/DatabasePolicy.php | 18 ++
app/Policies/ServiceDatabasePolicy.php | 8 +
app/Policies/ServicePolicy.php | 10 +
.../UploadBackupAuthorizationTest.php | 186 ++++++++++++++++++
6 files changed, 246 insertions(+)
create mode 100644 tests/Feature/Authorization/UploadBackupAuthorizationTest.php
diff --git a/app/Http/Controllers/UploadController.php b/app/Http/Controllers/UploadController.php
index 93847589a..895564caa 100644
--- a/app/Http/Controllers/UploadController.php
+++ b/app/Http/Controllers/UploadController.php
@@ -2,6 +2,7 @@
namespace App\Http\Controllers;
+use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Routing\Controller as BaseController;
@@ -11,6 +12,8 @@ use Pion\Laravel\ChunkUpload\Receiver\FileReceiver;
class UploadController extends BaseController
{
+ use AuthorizesRequests;
+
public function upload(Request $request)
{
$databaseIdentifier = request()->route('databaseUuid');
@@ -18,6 +21,9 @@ class UploadController extends BaseController
if (is_null($resource)) {
return response()->json(['error' => 'You do not have permission for this database'], 500);
}
+
+ $this->authorize('uploadBackup', $resource);
+
$receiver = new FileReceiver('file', $request, HandlerFactory::classFromRequest($request));
if ($receiver->isUploaded() === false) {
diff --git a/app/Policies/ApplicationPolicy.php b/app/Policies/ApplicationPolicy.php
index 7a992f2fd..1a1290b55 100644
--- a/app/Policies/ApplicationPolicy.php
+++ b/app/Policies/ApplicationPolicy.php
@@ -78,6 +78,24 @@ class ApplicationPolicy
return false;
}
+ /**
+ * Determine whether the user can upload a backup archive for this application.
+ */
+ public function uploadBackup(User $user, Application $application): Response
+ {
+ $teamId = $this->getTeamId($application);
+
+ if ($teamId === null) {
+ return Response::deny('Application team not found.');
+ }
+
+ if ($user->isAdminOfTeam($teamId)) {
+ return Response::allow();
+ }
+
+ return Response::deny('You need at least admin or owner permissions to upload backups for this application.');
+ }
+
/**
* Determine whether the user can deploy the application.
*/
diff --git a/app/Policies/DatabasePolicy.php b/app/Policies/DatabasePolicy.php
index f62ffdde2..4217432b5 100644
--- a/app/Policies/DatabasePolicy.php
+++ b/app/Policies/DatabasePolicy.php
@@ -87,6 +87,24 @@ class DatabasePolicy
return $teamId !== null && $user->isAdminOfTeam($teamId);
}
+ /**
+ * Determine whether the user can upload a backup archive for this database.
+ */
+ public function uploadBackup(User $user, $database): Response
+ {
+ $teamId = $this->getTeamId($database);
+
+ if ($teamId === null) {
+ return Response::deny('Database team not found.');
+ }
+
+ if ($user->isAdminOfTeam($teamId)) {
+ return Response::allow();
+ }
+
+ return Response::deny('You need at least admin or owner permissions to upload backups for this database.');
+ }
+
/**
* Determine whether the user can manage database backups.
*/
diff --git a/app/Policies/ServiceDatabasePolicy.php b/app/Policies/ServiceDatabasePolicy.php
index e5cbe91a0..e94658e29 100644
--- a/app/Policies/ServiceDatabasePolicy.php
+++ b/app/Policies/ServiceDatabasePolicy.php
@@ -63,4 +63,12 @@ class ServiceDatabasePolicy
{
return Gate::allows('update', $serviceDatabase->service);
}
+
+ /**
+ * Determine whether the user can upload a backup archive for this service database.
+ */
+ public function uploadBackup(User $user, ServiceDatabase $serviceDatabase): bool
+ {
+ return Gate::allows('uploadBackup', $serviceDatabase->service);
+ }
}
diff --git a/app/Policies/ServicePolicy.php b/app/Policies/ServicePolicy.php
index d48728cdf..6ca79b42a 100644
--- a/app/Policies/ServicePolicy.php
+++ b/app/Policies/ServicePolicy.php
@@ -89,6 +89,16 @@ class ServicePolicy
return $teamId !== null && $user->isAdminOfTeam($teamId);
}
+ /**
+ * Determine whether the user can upload a backup archive for a database within this service.
+ */
+ public function uploadBackup(User $user, Service $service): bool
+ {
+ $teamId = $this->getTeamId($service);
+
+ return $teamId !== null && $user->isAdminOfTeam($teamId);
+ }
+
/**
* Determine whether the user can deploy the service.
*/
diff --git a/tests/Feature/Authorization/UploadBackupAuthorizationTest.php b/tests/Feature/Authorization/UploadBackupAuthorizationTest.php
new file mode 100644
index 000000000..126e6ef52
--- /dev/null
+++ b/tests/Feature/Authorization/UploadBackupAuthorizationTest.php
@@ -0,0 +1,186 @@
+ 0]);
+
+ $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->database = StandalonePostgresql::create([
+ 'uuid' => (string) Str::uuid(),
+ 'name' => 'Test DB',
+ 'postgres_user' => 'postgres',
+ 'postgres_password' => 'password',
+ 'postgres_db' => 'testdb',
+ 'image' => 'postgres:15',
+ 'status' => 'running',
+ 'environment_id' => $this->environment->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ ]);
+
+ $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(),
+ 'status' => 'running',
+ ]);
+
+ $this->service = Service::create([
+ 'uuid' => (string) Str::uuid(),
+ 'name' => 'test-service',
+ 'environment_id' => $this->environment->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ 'docker_compose_raw' => 'version: "3"',
+ ]);
+
+ $this->serviceDatabase = ServiceDatabase::create([
+ 'uuid' => (string) Str::uuid(),
+ 'name' => 'test-service-db',
+ 'service_id' => $this->service->id,
+ ]);
+});
+
+// --- DatabasePolicy::uploadBackup (covers Standalone* databases) ---
+
+test('admin can upload backup for standalone database', function () {
+ expect($this->admin->can('uploadBackup', $this->database))->toBeTrue();
+});
+
+test('member cannot upload backup for standalone database', function () {
+ expect($this->member->can('uploadBackup', $this->database))->toBeFalse();
+});
+
+// --- ApplicationPolicy::uploadBackup ---
+
+test('admin can upload backup for application', function () {
+ expect($this->admin->can('uploadBackup', $this->application))->toBeTrue();
+});
+
+test('member cannot upload backup for application', function () {
+ expect($this->member->can('uploadBackup', $this->application))->toBeFalse();
+});
+
+// --- ServicePolicy::uploadBackup ---
+
+test('admin can upload backup for service', function () {
+ expect($this->admin->can('uploadBackup', $this->service))->toBeTrue();
+});
+
+test('member cannot upload backup for service', function () {
+ expect($this->member->can('uploadBackup', $this->service))->toBeFalse();
+});
+
+// --- ServiceDatabasePolicy::uploadBackup (delegates to ServicePolicy) ---
+
+test('admin can upload backup for service database', function () {
+ expect($this->admin->can('uploadBackup', $this->serviceDatabase))->toBeTrue();
+});
+
+test('member cannot upload backup for service database', function () {
+ expect($this->member->can('uploadBackup', $this->serviceDatabase))->toBeFalse();
+});
+
+// --- Cross-team isolation ---
+
+test('user from different team cannot upload backup', function () {
+ $otherTeam = Team::factory()->create();
+ $otherUser = User::factory()->create();
+ $otherUser->teams()->attach($otherTeam, ['role' => 'admin']);
+
+ expect($otherUser->can('uploadBackup', $this->database))->toBeFalse();
+ expect($otherUser->can('uploadBackup', $this->application))->toBeFalse();
+ expect($otherUser->can('uploadBackup', $this->service))->toBeFalse();
+ expect($otherUser->can('uploadBackup', $this->serviceDatabase))->toBeFalse();
+});
+
+// --- HTTP endpoint: POST /upload/backup/{uuid} ---
+
+test('member gets 403 from POST /upload/backup and no file lands on disk', function () {
+ $uploadDir = storage_path('app/upload/'.$this->database->uuid);
+ if (File::exists($uploadDir)) {
+ File::deleteDirectory($uploadDir);
+ }
+
+ $this->actingAs($this->member);
+ session(['currentTeam' => $this->team]);
+
+ $response = $this->post(route('upload.backup', ['databaseUuid' => $this->database->uuid]));
+
+ $response->assertForbidden();
+
+ expect(File::exists($uploadDir.'/restore'))->toBeFalse();
+});
+
+test('user from different team hits null-resource branch with 500', function () {
+ $otherTeam = Team::factory()->create();
+ $otherUser = User::factory()->create();
+ $otherUser->teams()->attach($otherTeam, ['role' => 'admin']);
+
+ $this->actingAs($otherUser);
+ session(['currentTeam' => $otherTeam]);
+
+ $response = $this->post(route('upload.backup', ['databaseUuid' => $this->database->uuid]));
+
+ $response->assertStatus(500);
+});
+
+test('unauthenticated request is redirected to login', function () {
+ $response = $this->post(route('upload.backup', ['databaseUuid' => $this->database->uuid]));
+
+ $response->assertRedirect('/login');
+});
From 062ad5774041fb3be71abedcff33c4315613152c Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Thu, 4 Jun 2026 11:03:06 +0200
Subject: [PATCH 23/30] fix(security): enforce team access on mutable actions
Authorize cloud provider token access, audit sensitive operations, and
standardize public IDs across deployment and resource flows.
---
app/Console/Commands/Emails.php | 1 +
.../Api/ApplicationsController.php | 21 ++-
.../Api/CloudProviderTokensController.php | 9 ++
app/Http/Controllers/Api/DeployController.php | 3 +-
.../Controllers/Api/HetznerController.php | 5 +
.../Controllers/Api/SentinelController.php | 10 +-
app/Http/Controllers/Webhook/Bitbucket.php | 5 +-
app/Http/Controllers/Webhook/Gitea.php | 5 +-
app/Http/Controllers/Webhook/Github.php | 5 +-
app/Http/Controllers/Webhook/Gitlab.php | 5 +-
app/Jobs/ApplicationDeploymentJob.php | 3 +-
app/Jobs/DatabaseBackupJob.php | 3 +-
app/Jobs/ProcessGithubPullRequestWebhook.php | 3 +-
app/Livewire/Boarding/Index.php | 3 +-
app/Livewire/Destination/New/Docker.php | 5 +-
app/Livewire/MonacoEditor.php | 3 +-
app/Livewire/Project/AddEmpty.php | 3 +-
app/Livewire/Project/Application/General.php | 3 +-
app/Livewire/Project/Application/Heading.php | 3 +-
app/Livewire/Project/Application/Previews.php | 3 +-
.../Project/Application/PreviewsCompose.php | 5 +-
app/Livewire/Project/Application/Rollback.php | 3 +-
app/Livewire/Project/CloneMe.php | 17 ++-
app/Livewire/Project/Database/InitScript.php | 9 ++
app/Livewire/Project/New/DockerImage.php | 3 +-
app/Livewire/Project/New/EmptyProject.php | 3 +-
app/Livewire/Project/New/SimpleDockerfile.php | 3 +-
app/Livewire/Project/Shared/Danger.php | 3 +-
app/Livewire/Project/Shared/Destination.php | 3 +-
.../Shared/ExecuteContainerCommand.php | 12 ++
.../Project/Shared/ResourceOperations.php | 11 +-
app/Livewire/Project/Shared/Terminal.php | 7 +
app/Livewire/Project/Show.php | 3 +-
app/Livewire/Security/CloudInitScriptForm.php | 17 ++-
app/Livewire/Security/CloudInitScripts.php | 7 +
.../Security/CloudProviderTokenForm.php | 8 +-
app/Livewire/Security/CloudProviderTokens.php | 23 +++-
.../Server/CloudProviderToken/Show.php | 27 +++-
app/Livewire/Storage/Resources.php | 11 ++
app/Livewire/Team/InviteLink.php | 3 +-
app/Mcp/Concerns/ResolvesTeam.php | 35 ++++-
app/Mcp/Servers/CoolifyServer.php | 18 +--
app/Mcp/Tools/GetApplication.php | 20 +--
app/Mcp/Tools/GetDatabase.php | 20 +--
app/Mcp/Tools/GetInfrastructureOverview.php | 16 +--
app/Mcp/Tools/GetServer.php | 18 +--
app/Mcp/Tools/GetService.php | 20 +--
app/Mcp/Tools/ListApplications.php | 18 +--
app/Mcp/Tools/ListDatabases.php | 16 +--
app/Mcp/Tools/ListProjects.php | 16 +--
app/Mcp/Tools/ListServers.php | 16 +--
app/Mcp/Tools/ListServices.php | 16 +--
app/Models/Application.php | 3 +-
app/Models/ApplicationPreview.php | 5 +-
app/Models/BaseModel.php | 3 +-
app/Models/CloudProviderToken.php | 4 +
app/Models/PrivateKey.php | 3 +-
app/Models/Project.php | 3 +-
app/Models/Server.php | 3 +-
app/Models/Service.php | 5 +-
app/Providers/AuthServiceProvider.php | 122 +++++++++++++-----
app/View/Components/Forms/Checkbox.php | 3 +-
app/View/Components/Forms/Datalist.php | 5 +-
app/View/Components/Forms/EnvVarInput.php | 5 +-
app/View/Components/Forms/Input.php | 6 +-
app/View/Components/Forms/Select.php | 5 +-
app/View/Components/Forms/Textarea.php | 6 +-
bootstrap/helpers/applications.php | 9 +-
bootstrap/helpers/databases.php | 17 ++-
bootstrap/helpers/docker.php | 3 +-
bootstrap/helpers/parsers.php | 3 +-
bootstrap/helpers/shared.php | 14 +-
.../factories/CloudProviderTokenFactory.php | 25 ++++
database/factories/PrivateKeyFactory.php | 37 ++++++
.../database/postgresql/general.blade.php | 3 +-
.../Feature/Api/CloudProviderTokenApiTest.php | 38 ++++++
tests/Feature/Api/HetznerApiTest.php | 29 +++++
.../CloudProviderAuthorizationTest.php | 8 ++
tests/Feature/Mcp/McpEndpointTest.php | 60 +++++++++
...bleLivewireComponentsAuthorizationTest.php | 30 +++++
tests/Feature/Security/AuditLogTest.php | 100 ++++++++++++++
.../Feature/SentinelPushDeduplicationTest.php | 18 +++
tests/Unit/PublicIdTest.php | 19 +++
83 files changed, 824 insertions(+), 278 deletions(-)
create mode 100644 database/factories/CloudProviderTokenFactory.php
create mode 100644 database/factories/PrivateKeyFactory.php
create mode 100644 tests/Feature/MutableLivewireComponentsAuthorizationTest.php
create mode 100644 tests/Unit/PublicIdTest.php
diff --git a/app/Console/Commands/Emails.php b/app/Console/Commands/Emails.php
index 43ba06804..02be98fc9 100644
--- a/app/Console/Commands/Emails.php
+++ b/app/Console/Commands/Emails.php
@@ -18,6 +18,7 @@ use Exception;
use Illuminate\Console\Command;
use Illuminate\Mail\Message;
use Illuminate\Notifications\Messages\MailMessage;
+use Illuminate\Support\Str;
use Mail;
use function Laravel\Prompts\confirm;
diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php
index 79830ea41..824101be8 100644
--- a/app/Http/Controllers/Api/ApplicationsController.php
+++ b/app/Http/Controllers/Api/ApplicationsController.php
@@ -30,7 +30,6 @@ use Illuminate\Validation\Rule;
use OpenApi\Attributes as OA;
use Spatie\Url\Url;
use Symfony\Component\Yaml\Yaml;
-use Visus\Cuid2\Cuid2;
class ApplicationsController extends Controller
{
@@ -1197,7 +1196,7 @@ class ApplicationsController extends Controller
$application->isConfigurationChanged(true);
if ($instantDeploy) {
- $deployment_uuid = new Cuid2;
+ $deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,
@@ -1436,7 +1435,7 @@ class ApplicationsController extends Controller
$application->isConfigurationChanged(true);
if ($instantDeploy) {
- $deployment_uuid = new Cuid2;
+ $deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,
@@ -1645,7 +1644,7 @@ class ApplicationsController extends Controller
$application->isConfigurationChanged(true);
if ($instantDeploy) {
- $deployment_uuid = new Cuid2;
+ $deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,
@@ -1691,7 +1690,7 @@ class ApplicationsController extends Controller
], 422);
}
if (! $request->has('name')) {
- $request->offsetSet('name', 'dockerfile-'.new Cuid2);
+ $request->offsetSet('name', 'dockerfile-'.new_public_id());
}
$return = $this->validateDataApplications($request, $server);
@@ -1765,7 +1764,7 @@ class ApplicationsController extends Controller
$application->isConfigurationChanged(true);
if ($instantDeploy) {
- $deployment_uuid = new Cuid2;
+ $deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,
@@ -1809,7 +1808,7 @@ class ApplicationsController extends Controller
], 422);
}
if (! $request->has('name')) {
- $request->offsetSet('name', 'docker-image-'.new Cuid2);
+ $request->offsetSet('name', 'docker-image-'.new_public_id());
}
$return = $this->validateDataApplications($request, $server);
if ($return instanceof JsonResponse) {
@@ -1884,7 +1883,7 @@ class ApplicationsController extends Controller
$application->isConfigurationChanged(true);
if ($instantDeploy) {
- $deployment_uuid = new Cuid2;
+ $deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,
@@ -2682,7 +2681,7 @@ class ApplicationsController extends Controller
]);
if ($instantDeploy) {
- $deployment_uuid = new Cuid2;
+ $deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,
@@ -3589,7 +3588,7 @@ class ApplicationsController extends Controller
$this->authorize('deploy', $application);
- $deployment_uuid = new Cuid2;
+ $deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,
@@ -3787,7 +3786,7 @@ class ApplicationsController extends Controller
$this->authorize('deploy', $application);
- $deployment_uuid = new Cuid2;
+ $deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,
diff --git a/app/Http/Controllers/Api/CloudProviderTokensController.php b/app/Http/Controllers/Api/CloudProviderTokensController.php
index 1dd2e0006..ad6eeb982 100644
--- a/app/Http/Controllers/Api/CloudProviderTokensController.php
+++ b/app/Http/Controllers/Api/CloudProviderTokensController.php
@@ -549,9 +549,18 @@ class CloudProviderTokensController extends Controller
if (! $cloudToken) {
return response()->json(['message' => 'Cloud provider token not found.'], 404);
}
+ $this->authorize('view', $cloudToken);
$validation = $this->validateProviderToken($cloudToken->provider, $cloudToken->token);
+ auditLog('api.cloud_token.validated', [
+ 'team_id' => $teamId,
+ 'cloud_token_uuid' => $cloudToken->uuid,
+ 'cloud_token_name' => $cloudToken->name,
+ 'provider' => $cloudToken->provider,
+ 'valid' => $validation['valid'],
+ ]);
+
return response()->json([
'valid' => $validation['valid'],
'message' => $validation['valid'] ? 'Token is valid.' : $validation['error'],
diff --git a/app/Http/Controllers/Api/DeployController.php b/app/Http/Controllers/Api/DeployController.php
index c93731d68..f0cf48efa 100644
--- a/app/Http/Controllers/Api/DeployController.php
+++ b/app/Http/Controllers/Api/DeployController.php
@@ -15,7 +15,6 @@ use App\Models\Tag;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
-use Visus\Cuid2\Cuid2;
class DeployController extends Controller
{
@@ -511,7 +510,7 @@ class DeployController extends Controller
if ($dockerTag !== null && $resource->build_pack !== 'dockerimage') {
return ['message' => 'docker_tag can only be used with Docker Image applications.', 'deployment_uuid' => null];
}
- $deployment_uuid = new Cuid2;
+ $deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $resource,
deployment_uuid: $deployment_uuid,
diff --git a/app/Http/Controllers/Api/HetznerController.php b/app/Http/Controllers/Api/HetznerController.php
index ded23bb80..1c9d6f9ef 100644
--- a/app/Http/Controllers/Api/HetznerController.php
+++ b/app/Http/Controllers/Api/HetznerController.php
@@ -116,6 +116,7 @@ class HetznerController extends Controller
if (! $token) {
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
}
+ $this->authorize('view', $token);
try {
$hetznerService = new HetznerService($token->token);
@@ -237,6 +238,7 @@ class HetznerController extends Controller
if (! $token) {
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
}
+ $this->authorize('view', $token);
try {
$hetznerService = new HetznerService($token->token);
@@ -336,6 +338,7 @@ class HetznerController extends Controller
if (! $token) {
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
}
+ $this->authorize('view', $token);
try {
$hetznerService = new HetznerService($token->token);
@@ -445,6 +448,7 @@ class HetznerController extends Controller
if (! $token) {
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
}
+ $this->authorize('view', $token);
try {
$hetznerService = new HetznerService($token->token);
@@ -621,6 +625,7 @@ class HetznerController extends Controller
if (! $token) {
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
}
+ $this->authorize('view', $token);
// Validate private key
$privateKey = PrivateKey::whereTeamId($teamId)->whereUuid($request->private_key_uuid)->first();
diff --git a/app/Http/Controllers/Api/SentinelController.php b/app/Http/Controllers/Api/SentinelController.php
index df5c60d40..3af05f4fa 100644
--- a/app/Http/Controllers/Api/SentinelController.php
+++ b/app/Http/Controllers/Api/SentinelController.php
@@ -97,12 +97,12 @@ class SentinelController extends Controller
if ($this->shouldDispatchUpdate($server, $data)) {
PushServerUpdateJob::dispatch($server, $data);
- }
- auditLog('sentinel.metrics_pushed', [
- 'server_uuid' => $server->uuid,
- 'team_id' => $server->team_id,
- ]);
+ auditLog('sentinel.metrics_pushed', [
+ 'server_uuid' => $server->uuid,
+ 'team_id' => $server->team_id,
+ ]);
+ }
return response()->json(['message' => 'ok'], 200);
}
diff --git a/app/Http/Controllers/Webhook/Bitbucket.php b/app/Http/Controllers/Webhook/Bitbucket.php
index d37ba7cee..435f5efab 100644
--- a/app/Http/Controllers/Webhook/Bitbucket.php
+++ b/app/Http/Controllers/Webhook/Bitbucket.php
@@ -10,7 +10,6 @@ use App\Models\Application;
use App\Models\ApplicationPreview;
use Exception;
use Illuminate\Http\Request;
-use Visus\Cuid2\Cuid2;
class Bitbucket extends Controller
{
@@ -141,7 +140,7 @@ class Bitbucket extends Controller
continue;
}
- $deployment_uuid = new Cuid2;
+ $deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,
deployment_uuid: $deployment_uuid,
@@ -192,7 +191,7 @@ class Bitbucket extends Controller
continue;
}
- $deployment_uuid = new Cuid2;
+ $deployment_uuid = new_public_id();
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
if (! $found) {
if ($application->build_pack === 'dockercompose') {
diff --git a/app/Http/Controllers/Webhook/Gitea.php b/app/Http/Controllers/Webhook/Gitea.php
index be064e380..82a8cc8af 100644
--- a/app/Http/Controllers/Webhook/Gitea.php
+++ b/app/Http/Controllers/Webhook/Gitea.php
@@ -11,7 +11,6 @@ use App\Models\ApplicationPreview;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
-use Visus\Cuid2\Cuid2;
class Gitea extends Controller
{
@@ -127,7 +126,7 @@ class Gitea extends Controller
continue;
}
- $deployment_uuid = new Cuid2;
+ $deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,
deployment_uuid: $deployment_uuid,
@@ -194,7 +193,7 @@ class Gitea extends Controller
continue;
}
- $deployment_uuid = new Cuid2;
+ $deployment_uuid = new_public_id();
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
if (! $found) {
if ($application->build_pack === 'dockercompose') {
diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php
index 40c5cbdf0..c9b0116fb 100644
--- a/app/Http/Controllers/Webhook/Github.php
+++ b/app/Http/Controllers/Webhook/Github.php
@@ -17,7 +17,6 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
-use Visus\Cuid2\Cuid2;
class Github extends Controller
{
@@ -144,7 +143,7 @@ class Github extends Controller
continue;
}
- $deployment_uuid = new Cuid2;
+ $deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,
deployment_uuid: $deployment_uuid,
@@ -362,7 +361,7 @@ class Github extends Controller
continue;
}
- $deployment_uuid = new Cuid2;
+ $deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,
deployment_uuid: $deployment_uuid,
diff --git a/app/Http/Controllers/Webhook/Gitlab.php b/app/Http/Controllers/Webhook/Gitlab.php
index 231a0b6e5..c90f4ad40 100644
--- a/app/Http/Controllers/Webhook/Gitlab.php
+++ b/app/Http/Controllers/Webhook/Gitlab.php
@@ -11,7 +11,6 @@ use App\Models\ApplicationPreview;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
-use Visus\Cuid2\Cuid2;
class Gitlab extends Controller
{
@@ -168,7 +167,7 @@ class Gitlab extends Controller
continue;
}
- $deployment_uuid = new Cuid2;
+ $deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,
deployment_uuid: $deployment_uuid,
@@ -236,7 +235,7 @@ class Gitlab extends Controller
continue;
}
- $deployment_uuid = new Cuid2;
+ $deployment_uuid = new_public_id();
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
if (! $found) {
if ($application->build_pack === 'dockercompose') {
diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php
index 1b8ef3fc4..20eae036b 100644
--- a/app/Jobs/ApplicationDeploymentJob.php
+++ b/app/Jobs/ApplicationDeploymentJob.php
@@ -37,7 +37,6 @@ use JsonException;
use Spatie\Url\Url;
use Symfony\Component\Yaml\Yaml;
use Throwable;
-use Visus\Cuid2\Cuid2;
class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
{
@@ -2207,7 +2206,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
continue;
}
- $deployment_uuid = new Cuid2;
+ $deployment_uuid = new_public_id();
queue_application_deployment(
deployment_uuid: $deployment_uuid,
application: $this->application,
diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php
index 64e900b49..79bf929be 100644
--- a/app/Jobs/DatabaseBackupJob.php
+++ b/app/Jobs/DatabaseBackupJob.php
@@ -27,7 +27,6 @@ use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Throwable;
-use Visus\Cuid2\Cuid2;
class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
{
@@ -309,7 +308,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
// Generate unique UUID for each database backup execution
$attempts = 0;
do {
- $this->backup_log_uuid = (string) new Cuid2;
+ $this->backup_log_uuid = new_public_id();
$exists = ScheduledDatabaseBackupExecution::where('uuid', $this->backup_log_uuid)->exists();
$attempts++;
if ($attempts >= 3 && $exists) {
diff --git a/app/Jobs/ProcessGithubPullRequestWebhook.php b/app/Jobs/ProcessGithubPullRequestWebhook.php
index 141351784..93aee0676 100644
--- a/app/Jobs/ProcessGithubPullRequestWebhook.php
+++ b/app/Jobs/ProcessGithubPullRequestWebhook.php
@@ -14,7 +14,6 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
-use Visus\Cuid2\Cuid2;
class ProcessGithubPullRequestWebhook implements ShouldBeEncrypted, ShouldQueue
{
@@ -156,7 +155,7 @@ class ProcessGithubPullRequestWebhook implements ShouldBeEncrypted, ShouldQueue
}
// Queue the deployment
- $deployment_uuid = new Cuid2;
+ $deployment_uuid = new_public_id();
queue_application_deployment(
application: $application,
pull_request_id: $this->pullRequestId,
diff --git a/app/Livewire/Boarding/Index.php b/app/Livewire/Boarding/Index.php
index d57c06952..5582efbda 100644
--- a/app/Livewire/Boarding/Index.php
+++ b/app/Livewire/Boarding/Index.php
@@ -13,7 +13,6 @@ use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Livewire\Attributes\Url;
use Livewire\Component;
-use Visus\Cuid2\Cuid2;
class Index extends Component
{
@@ -470,7 +469,7 @@ class Index extends Component
$this->createdProject = Project::create([
'name' => 'My first project',
'team_id' => currentTeam()->id,
- 'uuid' => (string) new Cuid2,
+ 'uuid' => new_public_id(),
]);
$this->currentState = 'create-resource';
}
diff --git a/app/Livewire/Destination/New/Docker.php b/app/Livewire/Destination/New/Docker.php
index 254823163..61e8bba34 100644
--- a/app/Livewire/Destination/New/Docker.php
+++ b/app/Livewire/Destination/New/Docker.php
@@ -9,7 +9,6 @@ use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Validate;
use Livewire\Component;
-use Visus\Cuid2\Cuid2;
class Docker extends Component
{
@@ -35,7 +34,7 @@ class Docker extends Component
public function mount(?string $server_id = null): void
{
- $this->network = (string) new Cuid2;
+ $this->network = new_public_id();
$this->servers = Server::isUsable()->get();
if (filled($server_id)) {
@@ -68,7 +67,7 @@ class Docker extends Component
public function generateName(): void
{
- $name = data_get($this->selectedServer, 'name', new Cuid2);
+ $name = data_get($this->selectedServer, 'name', new_public_id());
$this->name = str("{$name}-{$this->network}")->kebab();
}
diff --git a/app/Livewire/MonacoEditor.php b/app/Livewire/MonacoEditor.php
index f660f9c13..cf476eb75 100644
--- a/app/Livewire/MonacoEditor.php
+++ b/app/Livewire/MonacoEditor.php
@@ -4,7 +4,6 @@ namespace App\Livewire;
// use Livewire\Component;
use Illuminate\View\Component;
-use Visus\Cuid2\Cuid2;
class MonacoEditor extends Component
{
@@ -40,7 +39,7 @@ class MonacoEditor extends Component
public function render()
{
if (is_null($this->id)) {
- $this->id = new Cuid2;
+ $this->id = new_public_id();
}
if (is_null($this->name)) {
diff --git a/app/Livewire/Project/AddEmpty.php b/app/Livewire/Project/AddEmpty.php
index 3430c69bb..e004ac69e 100644
--- a/app/Livewire/Project/AddEmpty.php
+++ b/app/Livewire/Project/AddEmpty.php
@@ -6,7 +6,6 @@ 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
{
@@ -38,7 +37,7 @@ class AddEmpty extends Component
'name' => $this->name,
'description' => $this->description,
'team_id' => currentTeam()->id,
- 'uuid' => (string) new Cuid2,
+ 'uuid' => new_public_id(),
]);
$productionEnvironment = $project->environments()->where('name', 'production')->first();
diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php
index 89b1b4217..7af0a275d 100644
--- a/app/Livewire/Project/Application/General.php
+++ b/app/Livewire/Project/Application/General.php
@@ -13,7 +13,6 @@ use Illuminate\Support\Collection;
use Livewire\Component;
use Livewire\Features\SupportEvents\Event;
use Spatie\Url\Url;
-use Visus\Cuid2\Cuid2;
class General extends Component
{
@@ -549,7 +548,7 @@ class General extends Component
try {
$this->authorize('update', $this->application);
- $uuid = new Cuid2;
+ $uuid = new_public_id();
$domain = generateUrl(server: $this->application->destination->server, random: $uuid);
$sanitizedKey = str($serviceName)->replace('-', '_')->replace('.', '_')->toString();
$this->parsedServiceDomains[$sanitizedKey]['domain'] = $domain;
diff --git a/app/Livewire/Project/Application/Heading.php b/app/Livewire/Project/Application/Heading.php
index eb5b5f06c..b7750e087 100644
--- a/app/Livewire/Project/Application/Heading.php
+++ b/app/Livewire/Project/Application/Heading.php
@@ -7,7 +7,6 @@ use App\Actions\Docker\GetContainersStatus;
use App\Models\Application;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
-use Visus\Cuid2\Cuid2;
class Heading extends Component
{
@@ -129,7 +128,7 @@ class Heading extends Component
protected function setDeploymentUuid()
{
- $this->deploymentUuid = new Cuid2;
+ $this->deploymentUuid = new_public_id();
$this->parameters['deployment_uuid'] = $this->deploymentUuid;
}
diff --git a/app/Livewire/Project/Application/Previews.php b/app/Livewire/Project/Application/Previews.php
index dc611b5af..74b2ebce8 100644
--- a/app/Livewire/Project/Application/Previews.php
+++ b/app/Livewire/Project/Application/Previews.php
@@ -9,7 +9,6 @@ use App\Models\ApplicationPreview;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Livewire\Component;
-use Visus\Cuid2\Cuid2;
class Previews extends Component
{
@@ -312,7 +311,7 @@ class Previews extends Component
protected function setDeploymentUuid()
{
- $this->deployment_uuid = new Cuid2;
+ $this->deployment_uuid = new_public_id();
$this->parameters['deployment_uuid'] = $this->deployment_uuid;
}
diff --git a/app/Livewire/Project/Application/PreviewsCompose.php b/app/Livewire/Project/Application/PreviewsCompose.php
index 85ba2328e..e8da3b45c 100644
--- a/app/Livewire/Project/Application/PreviewsCompose.php
+++ b/app/Livewire/Project/Application/PreviewsCompose.php
@@ -6,7 +6,6 @@ use App\Models\ApplicationPreview;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
use Spatie\Url\Url;
-use Visus\Cuid2\Cuid2;
class PreviewsCompose extends Component
{
@@ -64,7 +63,7 @@ class PreviewsCompose extends Component
if (empty($domain_string)) {
$server = $this->preview->application->destination->server;
$template = $this->preview->application->preview_url_template;
- $random = new Cuid2;
+ $random = new_public_id();
// Generate a unique domain like main app services do
$generated_fqdn = generateUrl(server: $server, random: $random);
@@ -79,7 +78,7 @@ class PreviewsCompose extends Component
$domain_list = explode(',', $domain_string);
$preview_fqdns = [];
$template = $this->preview->application->preview_url_template;
- $random = new Cuid2;
+ $random = new_public_id();
foreach ($domain_list as $single_domain) {
$single_domain = trim($single_domain);
diff --git a/app/Livewire/Project/Application/Rollback.php b/app/Livewire/Project/Application/Rollback.php
index 3edd77833..b070ae1cc 100644
--- a/app/Livewire/Project/Application/Rollback.php
+++ b/app/Livewire/Project/Application/Rollback.php
@@ -6,7 +6,6 @@ use App\Models\Application;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Validate;
use Livewire\Component;
-use Visus\Cuid2\Cuid2;
class Rollback extends Component
{
@@ -52,7 +51,7 @@ class Rollback extends Component
$commit = validateGitRef($commit, 'rollback commit');
- $deployment_uuid = new Cuid2;
+ $deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $this->application,
diff --git a/app/Livewire/Project/CloneMe.php b/app/Livewire/Project/CloneMe.php
index ac5d91ebe..0a6e3d8ec 100644
--- a/app/Livewire/Project/CloneMe.php
+++ b/app/Livewire/Project/CloneMe.php
@@ -13,7 +13,6 @@ 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
{
@@ -64,7 +63,7 @@ class CloneMe extends Component
->servers()
->get()
->reject(fn ($server) => $server->isBuildServer());
- $this->newName = str($this->project->name.'-clone-'.(string) new Cuid2)->slug();
+ $this->newName = str($this->project->name.'-clone-'.new_public_id())->slug();
}
public function toggleVolumeCloning(bool $value)
@@ -112,7 +111,7 @@ class CloneMe extends Component
if ($this->environment->name !== 'production') {
$project->environments()->create([
'name' => $this->environment->name,
- 'uuid' => (string) new Cuid2,
+ 'uuid' => new_public_id(),
]);
}
$environment = $project->environments->where('name', $this->environment->name)->first();
@@ -124,7 +123,7 @@ class CloneMe extends Component
$project = $this->project;
$environment = $this->project->environments()->create([
'name' => $this->newName,
- 'uuid' => (string) new Cuid2,
+ 'uuid' => new_public_id(),
]);
}
$applications = $this->environment->applications;
@@ -138,7 +137,7 @@ class CloneMe extends Component
}
foreach ($databases as $database) {
- $uuid = (string) new Cuid2;
+ $uuid = new_public_id();
$newDatabase = $database->replicate([
'id',
'created_at',
@@ -229,7 +228,7 @@ class CloneMe extends Component
$scheduledBackups = $database->scheduledBackups()->get();
foreach ($scheduledBackups as $backup) {
- $uuid = (string) new Cuid2;
+ $uuid = new_public_id();
$newBackup = $backup->replicate([
'id',
'created_at',
@@ -258,7 +257,7 @@ class CloneMe extends Component
}
foreach ($services as $service) {
- $uuid = (string) new Cuid2;
+ $uuid = new_public_id();
$newService = $service->replicate([
'id',
'created_at',
@@ -282,7 +281,7 @@ class CloneMe extends Component
'created_at',
'updated_at',
])->fill([
- 'uuid' => (string) new Cuid2,
+ 'uuid' => new_public_id(),
'service_id' => $newService->id,
'team_id' => currentTeam()->id,
]);
@@ -413,7 +412,7 @@ class CloneMe extends Component
$scheduledBackups = $database->scheduledBackups()->get();
foreach ($scheduledBackups as $backup) {
- $uuid = (string) new Cuid2;
+ $uuid = new_public_id();
$newBackup = $backup->replicate([
'id',
'created_at',
diff --git a/app/Livewire/Project/Database/InitScript.php b/app/Livewire/Project/Database/InitScript.php
index e3baa1c8e..7074c235d 100644
--- a/app/Livewire/Project/Database/InitScript.php
+++ b/app/Livewire/Project/Database/InitScript.php
@@ -2,13 +2,20 @@
namespace App\Livewire\Project\Database;
+use App\Models\StandalonePostgresql;
use Exception;
+use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Validate;
use Livewire\Component;
class InitScript extends Component
{
+ use AuthorizesRequests;
+
+ #[Locked]
+ public StandalonePostgresql $database;
+
#[Locked]
public array $script;
@@ -35,6 +42,7 @@ class InitScript extends Component
public function submit()
{
try {
+ $this->authorize('update', $this->database);
$this->validate();
$this->script['index'] = $this->index;
$this->script['content'] = $this->content;
@@ -48,6 +56,7 @@ class InitScript extends Component
public function delete()
{
try {
+ $this->authorize('update', $this->database);
$this->dispatch('delete_init_script', $this->script);
} catch (Exception $e) {
return handleError($e, $this);
diff --git a/app/Livewire/Project/New/DockerImage.php b/app/Livewire/Project/New/DockerImage.php
index 737806cb8..de86bea4a 100644
--- a/app/Livewire/Project/New/DockerImage.php
+++ b/app/Livewire/Project/New/DockerImage.php
@@ -7,7 +7,6 @@ use App\Models\Project;
use App\Services\DockerImageParser;
use App\Support\ValidationPatterns;
use Livewire\Component;
-use Visus\Cuid2\Cuid2;
class DockerImage extends Component
{
@@ -130,7 +129,7 @@ class DockerImage extends Component
$imageTag = $parser->isImageHash() ? 'sha256-'.$parser->getTag() : $parser->getTag();
$application = Application::create([
- 'name' => 'docker-image-'.new Cuid2,
+ 'name' => 'docker-image-'.new_public_id(),
'repository_project_id' => 0,
'git_repository' => 'coollabsio/coolify',
'git_branch' => 'main',
diff --git a/app/Livewire/Project/New/EmptyProject.php b/app/Livewire/Project/New/EmptyProject.php
index 0360365a9..7c92ce96b 100644
--- a/app/Livewire/Project/New/EmptyProject.php
+++ b/app/Livewire/Project/New/EmptyProject.php
@@ -4,7 +4,6 @@ namespace App\Livewire\Project\New;
use App\Models\Project;
use Livewire\Component;
-use Visus\Cuid2\Cuid2;
class EmptyProject extends Component
{
@@ -13,7 +12,7 @@ class EmptyProject extends Component
$project = Project::create([
'name' => generate_random_name(),
'team_id' => currentTeam()->id,
- 'uuid' => (string) new Cuid2,
+ 'uuid' => new_public_id(),
]);
return redirectRoute($this, 'project.show', ['project_uuid' => $project->uuid, 'environment_uuid' => $project->environments->first()->uuid]);
diff --git a/app/Livewire/Project/New/SimpleDockerfile.php b/app/Livewire/Project/New/SimpleDockerfile.php
index f07948dba..5a84343fd 100644
--- a/app/Livewire/Project/New/SimpleDockerfile.php
+++ b/app/Livewire/Project/New/SimpleDockerfile.php
@@ -6,7 +6,6 @@ use App\Models\Application;
use App\Models\GithubApp;
use App\Models\Project;
use Livewire\Component;
-use Visus\Cuid2\Cuid2;
class SimpleDockerfile extends Component
{
@@ -48,7 +47,7 @@ CMD ["nginx", "-g", "daemon off;"]
$port = 80;
}
$application = Application::create([
- 'name' => 'dockerfile-'.new Cuid2,
+ 'name' => 'dockerfile-'.new_public_id(),
'repository_project_id' => 0,
'git_repository' => 'coollabsio/coolify',
'git_branch' => 'main',
diff --git a/app/Livewire/Project/Shared/Danger.php b/app/Livewire/Project/Shared/Danger.php
index caaabc494..7f0d3b173 100644
--- a/app/Livewire/Project/Shared/Danger.php
+++ b/app/Livewire/Project/Shared/Danger.php
@@ -8,7 +8,6 @@ use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
-use Visus\Cuid2\Cuid2;
class Danger extends Component
{
@@ -39,7 +38,7 @@ class Danger extends Component
public function mount()
{
$parameters = get_route_parameters();
- $this->modalId = new Cuid2;
+ $this->modalId = new_public_id();
$this->projectUuid = data_get($parameters, 'project_uuid');
$this->environmentUuid = data_get($parameters, 'environment_uuid');
diff --git a/app/Livewire/Project/Shared/Destination.php b/app/Livewire/Project/Shared/Destination.php
index 51965e81f..4f3e659da 100644
--- a/app/Livewire/Project/Shared/Destination.php
+++ b/app/Livewire/Project/Shared/Destination.php
@@ -10,7 +10,6 @@ use App\Models\StandaloneDocker;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Livewire\Component;
-use Visus\Cuid2\Cuid2;
class Destination extends Component
{
@@ -80,7 +79,7 @@ class Destination extends Component
return;
}
- $deployment_uuid = new Cuid2;
+ $deployment_uuid = new_public_id();
$server = Server::ownedByCurrentTeam()->findOrFail($server_id);
$destination = $server->standaloneDockers->where('id', $network_id)->firstOrFail();
$result = queue_application_deployment(
diff --git a/app/Livewire/Project/Shared/ExecuteContainerCommand.php b/app/Livewire/Project/Shared/ExecuteContainerCommand.php
index 4ea5e12db..3fa063298 100644
--- a/app/Livewire/Project/Shared/ExecuteContainerCommand.php
+++ b/app/Livewire/Project/Shared/ExecuteContainerCommand.php
@@ -6,12 +6,15 @@ use App\Models\Application;
use App\Models\Server;
use App\Models\Service;
use App\Support\ValidationPatterns;
+use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Livewire\Attributes\On;
use Livewire\Component;
class ExecuteContainerCommand extends Component
{
+ use AuthorizesRequests;
+
public $selected_container = 'default';
public Collection $containers;
@@ -40,6 +43,7 @@ class ExecuteContainerCommand extends Component
if (data_get($this->parameters, 'application_uuid')) {
$this->type = 'application';
$this->resource = Application::ownedByCurrentTeam()->where('uuid', $this->parameters['application_uuid'])->firstOrFail();
+ $this->authorize('view', $this->resource);
if ($this->resource->destination->server->isFunctional()) {
$this->servers = $this->servers->push($this->resource->destination->server);
}
@@ -56,6 +60,7 @@ class ExecuteContainerCommand extends Component
abort(404);
}
$this->resource = $resource;
+ $this->authorize('view', $this->resource);
if ($this->resource->destination->server->isFunctional()) {
$this->servers = $this->servers->push($this->resource->destination->server);
}
@@ -63,6 +68,7 @@ class ExecuteContainerCommand extends Component
} elseif (data_get($this->parameters, 'service_uuid')) {
$this->type = 'service';
$this->resource = Service::ownedByCurrentTeam()->where('uuid', $this->parameters['service_uuid'])->firstOrFail();
+ $this->authorize('view', $this->resource);
if ($this->resource->server->isFunctional()) {
$this->servers = $this->servers->push($this->resource->server);
}
@@ -70,6 +76,7 @@ class ExecuteContainerCommand extends Component
} elseif (data_get($this->parameters, 'server_uuid')) {
$this->type = 'server';
$this->resource = Server::ownedByCurrentTeam()->where('uuid', $this->parameters['server_uuid'])->firstOrFail();
+ $this->authorize('view', $this->resource);
$this->servers = $this->servers->push($this->resource);
}
$this->servers = $this->servers->sortByDesc(fn ($server) => $server->isTerminalEnabled());
@@ -152,7 +159,9 @@ class ExecuteContainerCommand extends Component
public function connectToServer()
{
try {
+ $this->authorize('canAccessTerminal');
$server = $this->servers->first();
+ $this->authorize('view', $server);
if ($server->isForceDisabled()) {
throw new \RuntimeException('Server is disabled.');
}
@@ -181,6 +190,7 @@ class ExecuteContainerCommand extends Component
return;
}
try {
+ $this->authorize('canAccessTerminal');
// Validate container name format
if (! ValidationPatterns::isValidContainerName($this->selected_container)) {
throw new \InvalidArgumentException('Invalid container name format');
@@ -198,6 +208,8 @@ class ExecuteContainerCommand extends Component
throw new \RuntimeException('Invalid server configuration.');
}
+ $this->authorize('view', $server);
+
if ($server->isForceDisabled()) {
throw new \RuntimeException('Server is disabled.');
}
diff --git a/app/Livewire/Project/Shared/ResourceOperations.php b/app/Livewire/Project/Shared/ResourceOperations.php
index 9da666d8b..02171af8d 100644
--- a/app/Livewire/Project/Shared/ResourceOperations.php
+++ b/app/Livewire/Project/Shared/ResourceOperations.php
@@ -22,7 +22,6 @@ use App\Models\StandaloneRedis;
use App\Models\SwarmDocker;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
-use Visus\Cuid2\Cuid2;
class ResourceOperations extends Component
{
@@ -66,7 +65,7 @@ class ResourceOperations extends Component
if (! $new_destination) {
return $this->addError('destination_id', 'Destination not found.');
}
- $uuid = (string) new Cuid2;
+ $uuid = new_public_id();
$server = $new_destination->server;
if ($this->resource->getMorphClass() === Application::class) {
@@ -89,7 +88,7 @@ class ResourceOperations extends Component
$this->resource->getMorphClass() === StandaloneDragonfly::class ||
$this->resource->getMorphClass() === StandaloneClickhouse::class
) {
- $uuid = (string) new Cuid2;
+ $uuid = new_public_id();
$new_resource = $this->resource->replicate([
'id',
'created_at',
@@ -180,7 +179,7 @@ class ResourceOperations extends Component
$scheduledBackups = $this->resource->scheduledBackups()->get();
foreach ($scheduledBackups as $backup) {
- $uuid = (string) new Cuid2;
+ $uuid = new_public_id();
$newBackup = $backup->replicate([
'id',
'created_at',
@@ -216,7 +215,7 @@ class ResourceOperations extends Component
return redirect()->to($route);
} elseif ($this->resource->type() === 'service') {
- $uuid = (string) new Cuid2;
+ $uuid = new_public_id();
$new_resource = $this->resource->replicate([
'id',
'created_at',
@@ -243,7 +242,7 @@ class ResourceOperations extends Component
'created_at',
'updated_at',
])->fill([
- 'uuid' => (string) new Cuid2,
+ 'uuid' => new_public_id(),
'service_id' => $new_resource->id,
'team_id' => currentTeam()->id,
]);
diff --git a/app/Livewire/Project/Shared/Terminal.php b/app/Livewire/Project/Shared/Terminal.php
index db65cdaad..46c75e352 100644
--- a/app/Livewire/Project/Shared/Terminal.php
+++ b/app/Livewire/Project/Shared/Terminal.php
@@ -5,11 +5,14 @@ namespace App\Livewire\Project\Shared;
use App\Helpers\SshMultiplexingHelper;
use App\Models\Server;
use App\Support\ValidationPatterns;
+use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\On;
use Livewire\Component;
class Terminal extends Component
{
+ use AuthorizesRequests;
+
public bool $hasShell = true;
public bool $isTerminalConnected = false;
@@ -32,7 +35,11 @@ class Terminal extends Component
#[On('send-terminal-command')]
public function sendTerminalCommand($isContainer, $identifier, $serverUuid)
{
+ $this->authorize('canAccessTerminal');
+
$server = Server::ownedByCurrentTeam()->whereUuid($serverUuid)->firstOrFail();
+ $this->authorize('view', $server);
+
if (! $server->isTerminalEnabled() || $server->isForceDisabled()) {
abort(403, 'Terminal access is disabled on this server.');
}
diff --git a/app/Livewire/Project/Show.php b/app/Livewire/Project/Show.php
index c86fa377d..fc84e4fbd 100644
--- a/app/Livewire/Project/Show.php
+++ b/app/Livewire/Project/Show.php
@@ -7,7 +7,6 @@ use App\Models\Project;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
-use Visus\Cuid2\Cuid2;
class Show extends Component
{
@@ -49,7 +48,7 @@ class Show extends Component
$environment = Environment::create([
'name' => $this->name,
'project_id' => $this->project->id,
- 'uuid' => (string) new Cuid2,
+ 'uuid' => new_public_id(),
]);
return redirectRoute($this, 'project.resource.index', [
diff --git a/app/Livewire/Security/CloudInitScriptForm.php b/app/Livewire/Security/CloudInitScriptForm.php
index 5e4ca9853..c7f933d39 100644
--- a/app/Livewire/Security/CloudInitScriptForm.php
+++ b/app/Livewire/Security/CloudInitScriptForm.php
@@ -3,6 +3,7 @@
namespace App\Livewire\Security;
use App\Models\CloudInitScript;
+use App\Rules\ValidCloudInitYaml;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
@@ -40,7 +41,7 @@ class CloudInitScriptForm extends Component
{
return [
'name' => 'required|string|max:255',
- 'script' => ['required', 'string', new \App\Rules\ValidCloudInitYaml],
+ 'script' => ['required', 'string', new ValidCloudInitYaml],
];
}
@@ -68,17 +69,29 @@ class CloudInitScriptForm extends Component
'script' => $this->script,
]);
+ auditLog('ui.cloud_init_script.updated', [
+ 'team_id' => currentTeam()->id,
+ 'cloud_init_script_id' => $cloudInitScript->id,
+ 'cloud_init_script_name' => $cloudInitScript->name,
+ ]);
+
$message = 'Cloud-init script updated successfully.';
} else {
// Create new script
$this->authorize('create', CloudInitScript::class);
- CloudInitScript::create([
+ $cloudInitScript = CloudInitScript::create([
'team_id' => currentTeam()->id,
'name' => $this->name,
'script' => $this->script,
]);
+ auditLog('ui.cloud_init_script.created', [
+ 'team_id' => currentTeam()->id,
+ 'cloud_init_script_id' => $cloudInitScript->id,
+ 'cloud_init_script_name' => $cloudInitScript->name,
+ ]);
+
$message = 'Cloud-init script created successfully.';
}
diff --git a/app/Livewire/Security/CloudInitScripts.php b/app/Livewire/Security/CloudInitScripts.php
index 13bcf2caa..57b7324d3 100644
--- a/app/Livewire/Security/CloudInitScripts.php
+++ b/app/Livewire/Security/CloudInitScripts.php
@@ -36,9 +36,16 @@ class CloudInitScripts extends Component
$script = CloudInitScript::ownedByCurrentTeam()->findOrFail($scriptId);
$this->authorize('delete', $script);
+ $scriptName = $script->name;
$script->delete();
$this->loadScripts();
+ auditLog('ui.cloud_init_script.deleted', [
+ 'team_id' => currentTeam()->id,
+ 'cloud_init_script_id' => $scriptId,
+ 'cloud_init_script_name' => $scriptName,
+ ]);
+
$this->dispatch('success', 'Cloud-init script deleted successfully.');
} catch (\Throwable $e) {
return handleError($e, $this);
diff --git a/app/Livewire/Security/CloudProviderTokenForm.php b/app/Livewire/Security/CloudProviderTokenForm.php
index ec4513ff3..6d0efa15f 100644
--- a/app/Livewire/Security/CloudProviderTokenForm.php
+++ b/app/Livewire/Security/CloudProviderTokenForm.php
@@ -54,7 +54,6 @@ class CloudProviderTokenForm extends Component
$response = Http::withHeaders([
'Authorization' => 'Bearer '.$token,
])->timeout(10)->get('https://api.hetzner.cloud/v1/servers');
- ray($response);
return $response->successful();
}
@@ -85,6 +84,13 @@ class CloudProviderTokenForm extends Component
'name' => $this->name,
]);
+ auditLog('ui.cloud_token.created', [
+ 'team_id' => currentTeam()->id,
+ 'cloud_token_uuid' => $savedToken->uuid,
+ 'cloud_token_name' => $savedToken->name,
+ 'provider' => $savedToken->provider,
+ ]);
+
$this->reset(['token', 'name']);
// Dispatch event with token ID so parent components can react
diff --git a/app/Livewire/Security/CloudProviderTokens.php b/app/Livewire/Security/CloudProviderTokens.php
index b7f389534..dabb199ed 100644
--- a/app/Livewire/Security/CloudProviderTokens.php
+++ b/app/Livewire/Security/CloudProviderTokens.php
@@ -4,6 +4,7 @@ namespace App\Livewire\Security;
use App\Models\CloudProviderToken;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
+use Illuminate\Support\Facades\Http;
use Livewire\Component;
class CloudProviderTokens extends Component
@@ -57,6 +58,14 @@ class CloudProviderTokens extends Component
} else {
$this->dispatch('error', 'Unknown provider.');
}
+
+ auditLog('ui.cloud_token.validated', [
+ 'team_id' => currentTeam()->id,
+ 'cloud_token_uuid' => $token->uuid,
+ 'cloud_token_name' => $token->name,
+ 'provider' => $token->provider,
+ 'valid' => $isValid ?? false,
+ ]);
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -65,7 +74,7 @@ class CloudProviderTokens extends Component
private function validateHetznerToken(string $token): bool
{
try {
- $response = \Illuminate\Support\Facades\Http::withToken($token)
+ $response = Http::withToken($token)
->timeout(10)
->get('https://api.hetzner.cloud/v1/servers?per_page=1');
@@ -78,7 +87,7 @@ class CloudProviderTokens extends Component
private function validateDigitalOceanToken(string $token): bool
{
try {
- $response = \Illuminate\Support\Facades\Http::withToken($token)
+ $response = Http::withToken($token)
->timeout(10)
->get('https://api.digitalocean.com/v2/account');
@@ -102,9 +111,19 @@ class CloudProviderTokens extends Component
return;
}
+ $tokenUuid = $token->uuid;
+ $tokenName = $token->name;
+ $tokenProvider = $token->provider;
$token->delete();
$this->loadTokens();
+ auditLog('ui.cloud_token.deleted', [
+ 'team_id' => currentTeam()->id,
+ 'cloud_token_uuid' => $tokenUuid,
+ 'cloud_token_name' => $tokenName,
+ 'provider' => $tokenProvider,
+ ]);
+
$this->dispatch('success', 'Cloud provider token deleted successfully.');
} catch (\Throwable $e) {
return handleError($e, $this);
diff --git a/app/Livewire/Server/CloudProviderToken/Show.php b/app/Livewire/Server/CloudProviderToken/Show.php
index 6b22fddc6..e3232d3f3 100644
--- a/app/Livewire/Server/CloudProviderToken/Show.php
+++ b/app/Livewire/Server/CloudProviderToken/Show.php
@@ -5,6 +5,7 @@ namespace App\Livewire\Server\CloudProviderToken;
use App\Models\CloudProviderToken;
use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
+use Illuminate\Support\Facades\Http;
use Livewire\Component;
class Show extends Component
@@ -67,6 +68,16 @@ class Show extends Component
$this->server->cloudProviderToken()->associate($ownedToken);
$this->server->save();
+
+ auditLog('ui.server.cloud_token_assigned', [
+ 'team_id' => currentTeam()->id,
+ 'server_uuid' => $this->server->uuid,
+ 'server_name' => $this->server->name,
+ 'cloud_token_uuid' => $ownedToken->uuid,
+ 'cloud_token_name' => $ownedToken->name,
+ 'provider' => $ownedToken->provider,
+ ]);
+
$this->dispatch('success', 'Hetzner token updated successfully.');
$this->dispatch('refreshServerShow');
} catch (\Exception $e) {
@@ -79,7 +90,7 @@ class Show extends Component
{
try {
// First, validate the token itself
- $response = \Illuminate\Support\Facades\Http::withHeaders([
+ $response = Http::withHeaders([
'Authorization' => 'Bearer '.$token->token,
])->timeout(10)->get('https://api.hetzner.cloud/v1/servers');
@@ -92,7 +103,7 @@ class Show extends Component
// Check if this token can access the specific Hetzner server
if ($this->server->hetzner_server_id) {
- $serverResponse = \Illuminate\Support\Facades\Http::withHeaders([
+ $serverResponse = Http::withHeaders([
'Authorization' => 'Bearer '.$token->token,
])->timeout(10)->get("https://api.hetzner.cloud/v1/servers/{$this->server->hetzner_server_id}");
@@ -123,7 +134,7 @@ class Show extends Component
return;
}
- $response = \Illuminate\Support\Facades\Http::withHeaders([
+ $response = Http::withHeaders([
'Authorization' => 'Bearer '.$token->token,
])->timeout(10)->get('https://api.hetzner.cloud/v1/servers');
@@ -132,6 +143,16 @@ class Show extends Component
} else {
$this->dispatch('error', 'Hetzner token is invalid or has insufficient permissions.');
}
+
+ auditLog('ui.server.cloud_token_validated', [
+ 'team_id' => currentTeam()->id,
+ 'server_uuid' => $this->server->uuid,
+ 'server_name' => $this->server->name,
+ 'cloud_token_uuid' => $token->uuid,
+ 'cloud_token_name' => $token->name,
+ 'provider' => $token->provider,
+ 'valid' => $response->successful(),
+ ]);
} catch (\Throwable $e) {
return handleError($e, $this);
}
diff --git a/app/Livewire/Storage/Resources.php b/app/Livewire/Storage/Resources.php
index 0dad2d548..4f39943e4 100644
--- a/app/Livewire/Storage/Resources.php
+++ b/app/Livewire/Storage/Resources.php
@@ -4,16 +4,21 @@ namespace App\Livewire\Storage;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
+use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class Resources extends Component
{
+ use AuthorizesRequests;
+
public S3Storage $storage;
public array $selectedStorages = [];
public function mount(): void
{
+ $this->authorize('view', $this->storage);
+
$backups = ScheduledDatabaseBackup::where('s3_storage_id', $this->storage->id)
->where('save_s3', true)
->get();
@@ -25,6 +30,8 @@ class Resources extends Component
public function disableS3(int $backupId): void
{
+ $this->authorize('update', $this->storage);
+
$backup = ScheduledDatabaseBackup::where('id', $backupId)
->where('s3_storage_id', $this->storage->id)
->firstOrFail();
@@ -41,6 +48,8 @@ class Resources extends Component
public function moveBackup(int $backupId): void
{
+ $this->authorize('update', $this->storage);
+
$backup = ScheduledDatabaseBackup::where('id', $backupId)
->where('s3_storage_id', $this->storage->id)
->firstOrFail();
@@ -62,6 +71,8 @@ class Resources extends Component
return;
}
+ $this->authorize('update', $newStorage);
+
$backup->update(['s3_storage_id' => $newStorage->id]);
unset($this->selectedStorages[$backupId]);
diff --git a/app/Livewire/Team/InviteLink.php b/app/Livewire/Team/InviteLink.php
index fb30961e9..5b040db71 100644
--- a/app/Livewire/Team/InviteLink.php
+++ b/app/Livewire/Team/InviteLink.php
@@ -10,7 +10,6 @@ use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Livewire\Component;
-use Visus\Cuid2\Cuid2;
class InviteLink extends Component
{
@@ -61,7 +60,7 @@ class InviteLink extends Component
if ($member_emails->contains($this->email)) {
return handleError(livewire: $this, customErrorMessage: "$this->email is already a member of ".currentTeam()->name.'.');
}
- $uuid = (string) new Cuid2(32);
+ $uuid = new_public_id(32);
$link = url('/').config('constants.invitation.link.base_url').$uuid;
$user = User::whereEmail($this->email)->first();
diff --git a/app/Mcp/Concerns/ResolvesTeam.php b/app/Mcp/Concerns/ResolvesTeam.php
index f6d82453a..8e0ae0467 100644
--- a/app/Mcp/Concerns/ResolvesTeam.php
+++ b/app/Mcp/Concerns/ResolvesTeam.php
@@ -7,15 +7,19 @@ use Laravel\Mcp\Response;
trait ResolvesTeam
{
- protected function ensureAbility(Request $request, string $ability = 'read'): ?Response
+ protected function ensureAbility(Request $request, string $ability = 'read', ?string $tool = null): ?Response
{
$user = $request->user();
if (! $user) {
+ $this->auditMcpTool($request, $tool, 'denied', ['reason' => 'unauthenticated']);
+
return Response::error('Unauthenticated.');
}
$token = $user->currentAccessToken();
if (! $token) {
+ $this->auditMcpTool($request, $tool, 'denied', ['reason' => 'invalid_token']);
+
return Response::error('Invalid token.');
}
@@ -23,6 +27,11 @@ trait ResolvesTeam
return null;
}
+ $this->auditMcpTool($request, $tool, 'denied', [
+ 'reason' => 'missing_ability',
+ 'required_ability' => $ability,
+ ]);
+
return Response::error("Missing required permissions: {$ability}");
}
@@ -38,4 +47,28 @@ trait ResolvesTeam
return (int) $teamId;
}
+
+ protected function mcpSuccess(Request $request, Response $response, array $context = []): Response
+ {
+ $this->auditMcpTool($request, $this->name ?? null, 'success', $context);
+
+ return $response;
+ }
+
+ protected function mcpError(Request $request, string $message, array $context = []): Response
+ {
+ $this->auditMcpTool($request, $this->name ?? null, 'error', $context + ['reason' => $message]);
+
+ return Response::error($message);
+ }
+
+ protected function auditMcpTool(Request $request, ?string $tool, string $outcome, array $context = []): void
+ {
+ auditLog('mcp.tool.called', [
+ 'tool' => $tool ?: 'unknown',
+ 'team_id' => $this->resolveTeamId($request),
+ 'outcome' => $outcome,
+ ...$context,
+ ]);
+ }
}
diff --git a/app/Mcp/Servers/CoolifyServer.php b/app/Mcp/Servers/CoolifyServer.php
index aff7e3f76..2b2d33d60 100644
--- a/app/Mcp/Servers/CoolifyServer.php
+++ b/app/Mcp/Servers/CoolifyServer.php
@@ -13,13 +13,14 @@ use App\Mcp\Tools\ListProjects;
use App\Mcp\Tools\ListServers;
use App\Mcp\Tools\ListServices;
use Laravel\Mcp\Server;
-use Laravel\Mcp\Server\Attributes\Instructions;
-use Laravel\Mcp\Server\Attributes\Name;
-use Laravel\Mcp\Server\Attributes\Version;
-#[Name('Coolify')]
-#[Version('0.1.0')]
-#[Instructions(<<<'MD'
+class CoolifyServer extends Server
+{
+ protected string $name = 'Coolify';
+
+ protected string $version = '0.1.0';
+
+ protected string $instructions = <<<'MD'
Read-only MCP server for Coolify, scoped to the authenticated team token.
Recommended workflow:
@@ -28,9 +29,8 @@ Recommended workflow:
3. get_server / get_application / get_database / get_service — full details for a single UUID.
Every response is `{ data, _actions?, _pagination? }`. `_actions` suggests the next tool + args; `_pagination.next` is the args to call again for the next page.
-MD)]
-class CoolifyServer extends Server
-{
+MD;
+
protected array $tools = [
GetInfrastructureOverview::class,
ListServers::class,
diff --git a/app/Mcp/Tools/GetApplication.php b/app/Mcp/Tools/GetApplication.php
index f7ac8db77..1d2f9f014 100644
--- a/app/Mcp/Tools/GetApplication.php
+++ b/app/Mcp/Tools/GetApplication.php
@@ -8,36 +8,36 @@ use App\Models\Application;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
-use Laravel\Mcp\Server\Attributes\Description;
-use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Tool;
-#[Name('get_application')]
-#[Description('Get full details for a single application by UUID.')]
class GetApplication extends Tool
{
+ protected string $name = 'get_application';
+
+ protected string $description = 'Get full details for a single application by UUID.';
+
use BuildsResponse;
use ResolvesTeam;
public function handle(Request $request): Response
{
- if ($error = $this->ensureAbility($request, 'read')) {
+ if ($error = $this->ensureAbility($request, 'read', $this->name)) {
return $error;
}
$teamId = $this->resolveTeamId($request);
if (is_null($teamId)) {
- return Response::error('Invalid token.');
+ return $this->mcpError($request, 'Invalid token.');
}
$uuid = $request->get('uuid');
if (! is_string($uuid) || $uuid === '') {
- return Response::error('uuid argument is required.');
+ return $this->mcpError($request, 'uuid argument is required.');
}
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $uuid)->first();
if (! $application) {
- return Response::error("Application [{$uuid}] not found.");
+ return $this->mcpError($request, "Application [{$uuid}] not found.", ['resource_uuid' => $uuid]);
}
// Drop relations that the server_status accessor lazy-loads — they
@@ -45,10 +45,10 @@ class GetApplication extends Tool
$application->setRelations([]);
$application->makeHidden(['destination', 'source', 'additional_servers', 'environment', 'tags', 'environmentVariables']);
- return $this->respond(
+ return $this->mcpSuccess($request, $this->respond(
$this->scrubSensitive($application->toArray()),
$this->actionsForApplication($uuid, $application->status),
- );
+ ), ['resource_uuid' => $uuid]);
}
public function schema(JsonSchema $schema): array
diff --git a/app/Mcp/Tools/GetDatabase.php b/app/Mcp/Tools/GetDatabase.php
index 4eee9c961..c5d62e3a0 100644
--- a/app/Mcp/Tools/GetDatabase.php
+++ b/app/Mcp/Tools/GetDatabase.php
@@ -7,46 +7,46 @@ use App\Mcp\Concerns\ResolvesTeam;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
-use Laravel\Mcp\Server\Attributes\Description;
-use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Tool;
-#[Name('get_database')]
-#[Description('Get full details for a standalone database by UUID. Detects type across postgresql, mysql, mariadb, mongodb, redis, keydb, dragonfly, clickhouse.')]
class GetDatabase extends Tool
{
+ protected string $name = 'get_database';
+
+ protected string $description = 'Get full details for a standalone database by UUID. Detects type across postgresql, mysql, mariadb, mongodb, redis, keydb, dragonfly, clickhouse.';
+
use BuildsResponse;
use ResolvesTeam;
public function handle(Request $request): Response
{
- if ($error = $this->ensureAbility($request, 'read')) {
+ if ($error = $this->ensureAbility($request, 'read', $this->name)) {
return $error;
}
$teamId = $this->resolveTeamId($request);
if (is_null($teamId)) {
- return Response::error('Invalid token.');
+ return $this->mcpError($request, 'Invalid token.');
}
$uuid = $request->get('uuid');
if (! is_string($uuid) || $uuid === '') {
- return Response::error('uuid argument is required.');
+ return $this->mcpError($request, 'uuid argument is required.');
}
$database = queryDatabaseByUuidWithinTeam($uuid, (string) $teamId);
if (! $database) {
- return Response::error("Database [{$uuid}] not found.");
+ return $this->mcpError($request, "Database [{$uuid}] not found.", ['resource_uuid' => $uuid]);
}
// Drop relations so deep server/destination data doesn't leak.
$database->setRelations([]);
$database->makeHidden(['destination', 'source', 'environment', 'environment_variables', 'environment_variables_preview']);
- return $this->respond(
+ return $this->mcpSuccess($request, $this->respond(
$this->scrubSensitive($database->toArray()),
$this->actionsForDatabase($uuid, $database->status ?? null),
- );
+ ), ['resource_uuid' => $uuid]);
}
public function schema(JsonSchema $schema): array
diff --git a/app/Mcp/Tools/GetInfrastructureOverview.php b/app/Mcp/Tools/GetInfrastructureOverview.php
index 06e91ff57..6fcafa316 100644
--- a/app/Mcp/Tools/GetInfrastructureOverview.php
+++ b/app/Mcp/Tools/GetInfrastructureOverview.php
@@ -9,26 +9,26 @@ use App\Models\Server;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
-use Laravel\Mcp\Server\Attributes\Description;
-use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Tool;
-#[Name('get_infrastructure_overview')]
-#[Description('High-level overview of the authenticated team: Coolify version, all servers, projects with resource counts, and aggregate counts. Start here to understand the setup.')]
class GetInfrastructureOverview extends Tool
{
+ protected string $name = 'get_infrastructure_overview';
+
+ protected string $description = 'High-level overview of the authenticated team: Coolify version, all servers, projects with resource counts, and aggregate counts. Start here to understand the setup.';
+
use BuildsResponse;
use ResolvesTeam;
public function handle(Request $request): Response
{
- if ($error = $this->ensureAbility($request, 'read')) {
+ if ($error = $this->ensureAbility($request, 'read', $this->name)) {
return $error;
}
$teamId = $this->resolveTeamId($request);
if (is_null($teamId)) {
- return Response::error('Invalid token.');
+ return $this->mcpError($request, 'Invalid token.');
}
$servers = Server::whereTeamId($teamId)
@@ -72,7 +72,7 @@ class GetInfrastructureOverview extends Tool
];
}
- return $this->respond([
+ return $this->mcpSuccess($request, $this->respond([
'coolify_version' => config('constants.coolify.version'),
'servers' => $servers,
'projects' => $projectSummaries,
@@ -83,7 +83,7 @@ class GetInfrastructureOverview extends Tool
'services' => $serviceCount,
'databases' => $databaseCount,
],
- ]);
+ ]));
}
public function schema(JsonSchema $schema): array
diff --git a/app/Mcp/Tools/GetServer.php b/app/Mcp/Tools/GetServer.php
index fc3e72f14..771aa7d36 100644
--- a/app/Mcp/Tools/GetServer.php
+++ b/app/Mcp/Tools/GetServer.php
@@ -8,36 +8,36 @@ use App\Models\Server;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
-use Laravel\Mcp\Server\Attributes\Description;
-use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Tool;
-#[Name('get_server')]
-#[Description('Get full details for a single server by UUID.')]
class GetServer extends Tool
{
+ protected string $name = 'get_server';
+
+ protected string $description = 'Get full details for a single server by UUID.';
+
use BuildsResponse;
use ResolvesTeam;
public function handle(Request $request): Response
{
- if ($error = $this->ensureAbility($request, 'read')) {
+ if ($error = $this->ensureAbility($request, 'read', $this->name)) {
return $error;
}
$teamId = $this->resolveTeamId($request);
if (is_null($teamId)) {
- return Response::error('Invalid token.');
+ return $this->mcpError($request, 'Invalid token.');
}
$uuid = $request->get('uuid');
if (! is_string($uuid) || $uuid === '') {
- return Response::error('uuid argument is required.');
+ return $this->mcpError($request, 'uuid argument is required.');
}
$server = Server::whereTeamId($teamId)->where('uuid', $uuid)->with('settings')->first();
if (! $server) {
- return Response::error("Server [{$uuid}] not found.");
+ return $this->mcpError($request, "Server [{$uuid}] not found.", ['resource_uuid' => $uuid]);
}
$data = $this->scrubSensitive($server->toArray());
@@ -45,7 +45,7 @@ class GetServer extends Tool
$data['is_usable'] = $server->settings?->is_usable;
$data['connection_timeout'] = $server->settings?->connection_timeout;
- return $this->respond($data, $this->actionsForServer($uuid));
+ return $this->mcpSuccess($request, $this->respond($data, $this->actionsForServer($uuid)), ['resource_uuid' => $uuid]);
}
public function schema(JsonSchema $schema): array
diff --git a/app/Mcp/Tools/GetService.php b/app/Mcp/Tools/GetService.php
index 475958272..ad14ddb49 100644
--- a/app/Mcp/Tools/GetService.php
+++ b/app/Mcp/Tools/GetService.php
@@ -8,31 +8,31 @@ use App\Models\Service;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
-use Laravel\Mcp\Server\Attributes\Description;
-use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Tool;
-#[Name('get_service')]
-#[Description('Get full details for a single service (multi-container stack) by UUID.')]
class GetService extends Tool
{
+ protected string $name = 'get_service';
+
+ protected string $description = 'Get full details for a single service (multi-container stack) by UUID.';
+
use BuildsResponse;
use ResolvesTeam;
public function handle(Request $request): Response
{
- if ($error = $this->ensureAbility($request, 'read')) {
+ if ($error = $this->ensureAbility($request, 'read', $this->name)) {
return $error;
}
$teamId = $this->resolveTeamId($request);
if (is_null($teamId)) {
- return Response::error('Invalid token.');
+ return $this->mcpError($request, 'Invalid token.');
}
$uuid = $request->get('uuid');
if (! is_string($uuid) || $uuid === '') {
- return Response::error('uuid argument is required.');
+ return $this->mcpError($request, 'uuid argument is required.');
}
$service = Service::whereRelation('environment.project.team', 'id', $teamId)
@@ -40,16 +40,16 @@ class GetService extends Tool
->first();
if (! $service) {
- return Response::error("Service [{$uuid}] not found.");
+ return $this->mcpError($request, "Service [{$uuid}] not found.", ['resource_uuid' => $uuid]);
}
$service->setRelations([]);
$service->makeHidden(['destination', 'source', 'environment', 'applications', 'databases', 'serviceApplications', 'serviceDatabases']);
- return $this->respond(
+ return $this->mcpSuccess($request, $this->respond(
$this->scrubSensitive($service->toArray()),
$this->actionsForService($uuid, $service->status ?? null),
- );
+ ), ['resource_uuid' => $uuid]);
}
public function schema(JsonSchema $schema): array
diff --git a/app/Mcp/Tools/ListApplications.php b/app/Mcp/Tools/ListApplications.php
index 815edd61a..bf31131b2 100644
--- a/app/Mcp/Tools/ListApplications.php
+++ b/app/Mcp/Tools/ListApplications.php
@@ -8,31 +8,31 @@ use App\Models\Application;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
-use Laravel\Mcp\Server\Attributes\Description;
-use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Tool;
-#[Name('list_applications')]
-#[Description('List applications owned by the authenticated team. Returns summary (uuid, name, status, fqdn, git_repository). Optional "tag" argument filters by tag name. Use get_application for full details.')]
class ListApplications extends Tool
{
+ protected string $name = 'list_applications';
+
+ protected string $description = 'List applications owned by the authenticated team. Returns summary (uuid, name, status, fqdn, git_repository). Optional "tag" argument filters by tag name. Use get_application for full details.';
+
use BuildsResponse;
use ResolvesTeam;
public function handle(Request $request): Response
{
- if ($error = $this->ensureAbility($request, 'read')) {
+ if ($error = $this->ensureAbility($request, 'read', $this->name)) {
return $error;
}
$teamId = $this->resolveTeamId($request);
if (is_null($teamId)) {
- return Response::error('Invalid token.');
+ return $this->mcpError($request, 'Invalid token.');
}
$tagName = $request->get('tag');
if ($tagName !== null && (! is_string($tagName) || trim($tagName) === '')) {
- return Response::error('tag argument must be a non-empty string.');
+ return $this->mcpError($request, 'tag argument must be a non-empty string.');
}
$args = $this->paginationArgs($request);
@@ -59,11 +59,11 @@ class ListApplications extends Tool
$extra = $tagName ? ['tag' => $tagName] : [];
- return $this->respond(
+ return $this->mcpSuccess($request, $this->respond(
$summaries,
[],
$this->paginationMeta('list_applications', $args, $total, $extra),
- );
+ ));
}
public function schema(JsonSchema $schema): array
diff --git a/app/Mcp/Tools/ListDatabases.php b/app/Mcp/Tools/ListDatabases.php
index 7eb1fde00..98de6ecee 100644
--- a/app/Mcp/Tools/ListDatabases.php
+++ b/app/Mcp/Tools/ListDatabases.php
@@ -8,26 +8,26 @@ use App\Models\Project;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
-use Laravel\Mcp\Server\Attributes\Description;
-use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Tool;
-#[Name('list_databases')]
-#[Description('List standalone databases owned by the authenticated team. Returns summary (uuid, name, status, type). Use get_database for full details.')]
class ListDatabases extends Tool
{
+ protected string $name = 'list_databases';
+
+ protected string $description = 'List standalone databases owned by the authenticated team. Returns summary (uuid, name, status, type). Use get_database for full details.';
+
use BuildsResponse;
use ResolvesTeam;
public function handle(Request $request): Response
{
- if ($error = $this->ensureAbility($request, 'read')) {
+ if ($error = $this->ensureAbility($request, 'read', $this->name)) {
return $error;
}
$teamId = $this->resolveTeamId($request);
if (is_null($teamId)) {
- return Response::error('Invalid token.');
+ return $this->mcpError($request, 'Invalid token.');
}
$args = $this->paginationArgs($request);
@@ -52,11 +52,11 @@ class ListDatabases extends Tool
->values()
->all();
- return $this->respond(
+ return $this->mcpSuccess($request, $this->respond(
$summaries,
[],
$this->paginationMeta('list_databases', $args, $total),
- );
+ ));
}
public function schema(JsonSchema $schema): array
diff --git a/app/Mcp/Tools/ListProjects.php b/app/Mcp/Tools/ListProjects.php
index 9ce1576b9..0a6de7f60 100644
--- a/app/Mcp/Tools/ListProjects.php
+++ b/app/Mcp/Tools/ListProjects.php
@@ -8,26 +8,26 @@ use App\Models\Project;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
-use Laravel\Mcp\Server\Attributes\Description;
-use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Tool;
-#[Name('list_projects')]
-#[Description('List projects owned by the authenticated team. Returns summary (uuid, name, description).')]
class ListProjects extends Tool
{
+ protected string $name = 'list_projects';
+
+ protected string $description = 'List projects owned by the authenticated team. Returns summary (uuid, name, description).';
+
use BuildsResponse;
use ResolvesTeam;
public function handle(Request $request): Response
{
- if ($error = $this->ensureAbility($request, 'read')) {
+ if ($error = $this->ensureAbility($request, 'read', $this->name)) {
return $error;
}
$teamId = $this->resolveTeamId($request);
if (is_null($teamId)) {
- return Response::error('Invalid token.');
+ return $this->mcpError($request, 'Invalid token.');
}
$args = $this->paginationArgs($request);
@@ -49,11 +49,11 @@ class ListProjects extends Tool
->values()
->all();
- return $this->respond(
+ return $this->mcpSuccess($request, $this->respond(
$summaries,
[],
$this->paginationMeta('list_projects', $args, $total),
- );
+ ));
}
public function schema(JsonSchema $schema): array
diff --git a/app/Mcp/Tools/ListServers.php b/app/Mcp/Tools/ListServers.php
index 20250c454..ed10afc93 100644
--- a/app/Mcp/Tools/ListServers.php
+++ b/app/Mcp/Tools/ListServers.php
@@ -8,26 +8,26 @@ use App\Models\Server;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
-use Laravel\Mcp\Server\Attributes\Description;
-use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Tool;
-#[Name('list_servers')]
-#[Description('List servers visible to the authenticated team token. Returns summary (uuid, name, ip, reachability). Use get_server for full details.')]
class ListServers extends Tool
{
+ protected string $name = 'list_servers';
+
+ protected string $description = 'List servers visible to the authenticated team token. Returns summary (uuid, name, ip, reachability). Use get_server for full details.';
+
use BuildsResponse;
use ResolvesTeam;
public function handle(Request $request): Response
{
- if ($error = $this->ensureAbility($request, 'read')) {
+ if ($error = $this->ensureAbility($request, 'read', $this->name)) {
return $error;
}
$teamId = $this->resolveTeamId($request);
if (is_null($teamId)) {
- return Response::error('Invalid token.');
+ return $this->mcpError($request, 'Invalid token.');
}
$args = $this->paginationArgs($request);
@@ -50,11 +50,11 @@ class ListServers extends Tool
->values()
->all();
- return $this->respond(
+ return $this->mcpSuccess($request, $this->respond(
$summaries,
[],
$this->paginationMeta('list_servers', $args, $total),
- );
+ ));
}
public function schema(JsonSchema $schema): array
diff --git a/app/Mcp/Tools/ListServices.php b/app/Mcp/Tools/ListServices.php
index b0bff4fad..3a0ea158a 100644
--- a/app/Mcp/Tools/ListServices.php
+++ b/app/Mcp/Tools/ListServices.php
@@ -8,26 +8,26 @@ use App\Models\Service;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
-use Laravel\Mcp\Server\Attributes\Description;
-use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Tool;
-#[Name('list_services')]
-#[Description('List services (multi-container stacks) owned by the authenticated team. Returns summary (uuid, name, status). Use get_service for full details.')]
class ListServices extends Tool
{
+ protected string $name = 'list_services';
+
+ protected string $description = 'List services (multi-container stacks) owned by the authenticated team. Returns summary (uuid, name, status). Use get_service for full details.';
+
use BuildsResponse;
use ResolvesTeam;
public function handle(Request $request): Response
{
- if ($error = $this->ensureAbility($request, 'read')) {
+ if ($error = $this->ensureAbility($request, 'read', $this->name)) {
return $error;
}
$teamId = $this->resolveTeamId($request);
if (is_null($teamId)) {
- return Response::error('Invalid token.');
+ return $this->mcpError($request, 'Invalid token.');
}
$args = $this->paginationArgs($request);
@@ -49,11 +49,11 @@ class ListServices extends Tool
->values()
->all();
- return $this->respond(
+ return $this->mcpSuccess($request, $this->respond(
$summaries,
[],
$this->paginationMeta('list_services', $args, $total),
- );
+ ));
}
public function schema(JsonSchema $schema): array
diff --git a/app/Models/Application.php b/app/Models/Application.php
index b2f852f15..4c53242ed 100644
--- a/app/Models/Application.php
+++ b/app/Models/Application.php
@@ -23,7 +23,6 @@ use RuntimeException;
use Spatie\Activitylog\Models\Activity;
use Spatie\Url\Url;
use Symfony\Component\Yaml\Yaml;
-use Visus\Cuid2\Cuid2;
#[OA\Schema(
description: 'Application model',
@@ -1925,7 +1924,7 @@ class Application extends BaseModel
if ($isInit && $this->docker_compose_raw) {
return;
}
- $uuid = new Cuid2;
+ $uuid = new_public_id();
['commands' => $cloneCommand] = $this->generateGitImportCommands(deployment_uuid: $uuid, only_checkout: true, exec_in_docker: false, custom_base_dir: 'checkout');
$cloneCommand = str_replace(' clone ', ' clone --quiet ', $cloneCommand);
$workdir = rtrim($this->base_directory, '/');
diff --git a/app/Models/ApplicationPreview.php b/app/Models/ApplicationPreview.php
index 9159fd0d8..6e4b696d5 100644
--- a/app/Models/ApplicationPreview.php
+++ b/app/Models/ApplicationPreview.php
@@ -5,7 +5,6 @@ namespace App\Models;
use App\Support\ValidationPatterns;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\Url\Url;
-use Visus\Cuid2\Cuid2;
class ApplicationPreview extends BaseModel
{
@@ -111,7 +110,7 @@ class ApplicationPreview extends BaseModel
$port = $portInt !== null ? ':'.$portInt : '';
$urlPath = $url->getPath();
$path = ($urlPath !== '' && $urlPath !== '/') ? $urlPath : '';
- $random = new Cuid2;
+ $random = new_public_id();
$preview_fqdn = str_replace('{{random}}', $random, $template);
$preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn);
$preview_fqdn = str_replace('{{pr_id}}', $this->pull_request_id, $preview_fqdn);
@@ -173,7 +172,7 @@ class ApplicationPreview extends BaseModel
$port = $portInt !== null ? ':'.$portInt : '';
$urlPath = $url->getPath();
$path = ($urlPath !== '' && $urlPath !== '/') ? $urlPath : '';
- $random = new Cuid2;
+ $random = new_public_id();
$preview_fqdn = str_replace('{{random}}', $random, $template);
$preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn);
$preview_fqdn = str_replace('{{pr_id}}', $this->pull_request_id, $preview_fqdn);
diff --git a/app/Models/BaseModel.php b/app/Models/BaseModel.php
index 727abed5f..d657fbec4 100644
--- a/app/Models/BaseModel.php
+++ b/app/Models/BaseModel.php
@@ -4,7 +4,6 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
-use Visus\Cuid2\Cuid2;
abstract class BaseModel extends Model
{
@@ -15,7 +14,7 @@ abstract class BaseModel extends Model
static::creating(function (Model $model) {
// Generate a UUID if one isn't set
if (! $model->uuid) {
- $model->uuid = (string) new Cuid2;
+ $model->uuid = new_public_id();
}
});
}
diff --git a/app/Models/CloudProviderToken.php b/app/Models/CloudProviderToken.php
index 026d11fba..35452553b 100644
--- a/app/Models/CloudProviderToken.php
+++ b/app/Models/CloudProviderToken.php
@@ -2,8 +2,12 @@
namespace App\Models;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+
class CloudProviderToken extends BaseModel
{
+ use HasFactory;
+
protected $fillable = [
'team_id',
'provider',
diff --git a/app/Models/PrivateKey.php b/app/Models/PrivateKey.php
index 1521678f3..bf42f21c7 100644
--- a/app/Models/PrivateKey.php
+++ b/app/Models/PrivateKey.php
@@ -4,6 +4,7 @@ namespace App\Models;
use App\Traits\HasSafeStringAttribute;
use DanHarrin\LivewireRateLimiting\WithRateLimiting;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
@@ -30,7 +31,7 @@ use phpseclib3\Crypt\PublicKeyLoader;
)]
class PrivateKey extends BaseModel
{
- use HasSafeStringAttribute, WithRateLimiting;
+ use HasFactory, HasSafeStringAttribute, WithRateLimiting;
protected $fillable = [
'name',
diff --git a/app/Models/Project.php b/app/Models/Project.php
index 632787a07..b47e7cf04 100644
--- a/app/Models/Project.php
+++ b/app/Models/Project.php
@@ -6,7 +6,6 @@ use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use OpenApi\Attributes as OA;
-use Visus\Cuid2\Cuid2;
#[OA\Schema(
description: 'Project model',
@@ -59,7 +58,7 @@ class Project extends BaseModel
Environment::create([
'name' => 'production',
'project_id' => $project->id,
- 'uuid' => (string) new Cuid2,
+ 'uuid' => new_public_id(),
]);
});
static::deleting(function ($project) {
diff --git a/app/Models/Server.php b/app/Models/Server.php
index 2b7bbac55..0102b327e 100644
--- a/app/Models/Server.php
+++ b/app/Models/Server.php
@@ -37,7 +37,6 @@ use Spatie\SchemalessAttributes\SchemalessAttributesTrait;
use Spatie\Url\Url;
use Stevebauman\Purify\Facades\Purify;
use Symfony\Component\Yaml\Yaml;
-use Visus\Cuid2\Cuid2;
/**
* @property array{
@@ -1041,7 +1040,7 @@ $schema://$host {
{
$attributes = [
'name' => 'coolify',
- 'uuid' => (string) new Cuid2,
+ 'uuid' => new_public_id(),
'network' => 'coolify',
'server_id' => $this->id,
];
diff --git a/app/Models/Service.php b/app/Models/Service.php
index cc8074b74..bf93bfd72 100644
--- a/app/Models/Service.php
+++ b/app/Models/Service.php
@@ -16,7 +16,6 @@ use OpenApi\Attributes as OA;
use Spatie\Activitylog\Models\Activity;
use Spatie\Url\Url;
use Symfony\Component\Yaml\Yaml;
-use Visus\Cuid2\Cuid2;
#[OA\Schema(
description: 'Service model',
@@ -71,7 +70,7 @@ class Service extends BaseModel
{
static::creating(function ($service) {
if (blank($service->name)) {
- $service->name = 'service-'.(new Cuid2);
+ $service->name = 'service-'.new_public_id();
}
});
static::created(function ($service) {
@@ -1555,7 +1554,7 @@ class Service extends BaseModel
"cd $workdir",
], $this->server);
- $filename = new Cuid2.'-docker-compose.yml';
+ $filename = new_public_id().'-docker-compose.yml';
Storage::disk('local')->put("tmp/{$filename}", $this->docker_compose);
$path = Storage::path("tmp/{$filename}");
instant_scp($path, "{$workdir}/docker-compose.yml", $this->server);
diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php
index e473d2875..1ae539174 100644
--- a/app/Providers/AuthServiceProvider.php
+++ b/app/Providers/AuthServiceProvider.php
@@ -3,9 +3,65 @@
namespace App\Providers;
// use Illuminate\Support\Facades\Gate;
+use App\Models\Application;
+use App\Models\ApplicationPreview;
+use App\Models\ApplicationSetting;
+use App\Models\CloudInitScript;
+use App\Models\CloudProviderToken;
+use App\Models\DiscordNotificationSettings;
+use App\Models\EmailNotificationSettings;
+use App\Models\Environment;
+use App\Models\EnvironmentVariable;
+use App\Models\GithubApp;
+use App\Models\InstanceSettings;
+use App\Models\PrivateKey;
+use App\Models\Project;
+use App\Models\PushoverNotificationSettings;
+use App\Models\Server;
+use App\Models\Service;
+use App\Models\ServiceApplication;
+use App\Models\ServiceDatabase;
+use App\Models\SharedEnvironmentVariable;
+use App\Models\SlackNotificationSettings;
+use App\Models\StandaloneClickhouse;
+use App\Models\StandaloneDocker;
+use App\Models\StandaloneDragonfly;
+use App\Models\StandaloneKeydb;
+use App\Models\StandaloneMariadb;
+use App\Models\StandaloneMongodb;
+use App\Models\StandaloneMysql;
+use App\Models\StandalonePostgresql;
+use App\Models\StandaloneRedis;
+use App\Models\SwarmDocker;
+use App\Models\Team;
+use App\Models\TelegramNotificationSettings;
+use App\Models\WebhookNotificationSettings;
+use App\Policies\ApiTokenPolicy;
+use App\Policies\ApplicationPolicy;
+use App\Policies\ApplicationPreviewPolicy;
+use App\Policies\ApplicationSettingPolicy;
+use App\Policies\CloudInitScriptPolicy;
+use App\Policies\CloudProviderTokenPolicy;
+use App\Policies\DatabasePolicy;
+use App\Policies\EnvironmentPolicy;
+use App\Policies\EnvironmentVariablePolicy;
+use App\Policies\GithubAppPolicy;
+use App\Policies\InstanceSettingsPolicy;
+use App\Policies\NotificationPolicy;
+use App\Policies\PrivateKeyPolicy;
+use App\Policies\ProjectPolicy;
use App\Policies\ResourceCreatePolicy;
+use App\Policies\ServerPolicy;
+use App\Policies\ServiceApplicationPolicy;
+use App\Policies\ServiceDatabasePolicy;
+use App\Policies\ServicePolicy;
+use App\Policies\SharedEnvironmentVariablePolicy;
+use App\Policies\StandaloneDockerPolicy;
+use App\Policies\SwarmDockerPolicy;
+use App\Policies\TeamPolicy;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Gate;
+use Laravel\Sanctum\PersonalAccessToken;
class AuthServiceProvider extends ServiceProvider
{
@@ -15,49 +71,51 @@ class AuthServiceProvider extends ServiceProvider
* @var array
*/
protected $policies = [
- \App\Models\Server::class => \App\Policies\ServerPolicy::class,
- \App\Models\PrivateKey::class => \App\Policies\PrivateKeyPolicy::class,
- \App\Models\StandaloneDocker::class => \App\Policies\StandaloneDockerPolicy::class,
- \App\Models\SwarmDocker::class => \App\Policies\SwarmDockerPolicy::class,
- \App\Models\Application::class => \App\Policies\ApplicationPolicy::class,
- \App\Models\ApplicationPreview::class => \App\Policies\ApplicationPreviewPolicy::class,
- \App\Models\ApplicationSetting::class => \App\Policies\ApplicationSettingPolicy::class,
- \App\Models\Service::class => \App\Policies\ServicePolicy::class,
- \App\Models\ServiceApplication::class => \App\Policies\ServiceApplicationPolicy::class,
- \App\Models\ServiceDatabase::class => \App\Policies\ServiceDatabasePolicy::class,
- \App\Models\Project::class => \App\Policies\ProjectPolicy::class,
- \App\Models\Environment::class => \App\Policies\EnvironmentPolicy::class,
- \App\Models\EnvironmentVariable::class => \App\Policies\EnvironmentVariablePolicy::class,
- \App\Models\SharedEnvironmentVariable::class => \App\Policies\SharedEnvironmentVariablePolicy::class,
+ Server::class => ServerPolicy::class,
+ PrivateKey::class => PrivateKeyPolicy::class,
+ StandaloneDocker::class => StandaloneDockerPolicy::class,
+ SwarmDocker::class => SwarmDockerPolicy::class,
+ Application::class => ApplicationPolicy::class,
+ ApplicationPreview::class => ApplicationPreviewPolicy::class,
+ ApplicationSetting::class => ApplicationSettingPolicy::class,
+ Service::class => ServicePolicy::class,
+ ServiceApplication::class => ServiceApplicationPolicy::class,
+ ServiceDatabase::class => ServiceDatabasePolicy::class,
+ Project::class => ProjectPolicy::class,
+ Environment::class => EnvironmentPolicy::class,
+ EnvironmentVariable::class => EnvironmentVariablePolicy::class,
+ SharedEnvironmentVariable::class => SharedEnvironmentVariablePolicy::class,
// Database policies - all use the shared DatabasePolicy
- \App\Models\StandalonePostgresql::class => \App\Policies\DatabasePolicy::class,
- \App\Models\StandaloneMysql::class => \App\Policies\DatabasePolicy::class,
- \App\Models\StandaloneMariadb::class => \App\Policies\DatabasePolicy::class,
- \App\Models\StandaloneMongodb::class => \App\Policies\DatabasePolicy::class,
- \App\Models\StandaloneRedis::class => \App\Policies\DatabasePolicy::class,
- \App\Models\StandaloneKeydb::class => \App\Policies\DatabasePolicy::class,
- \App\Models\StandaloneDragonfly::class => \App\Policies\DatabasePolicy::class,
- \App\Models\StandaloneClickhouse::class => \App\Policies\DatabasePolicy::class,
+ StandalonePostgresql::class => DatabasePolicy::class,
+ StandaloneMysql::class => DatabasePolicy::class,
+ StandaloneMariadb::class => DatabasePolicy::class,
+ StandaloneMongodb::class => DatabasePolicy::class,
+ StandaloneRedis::class => DatabasePolicy::class,
+ StandaloneKeydb::class => DatabasePolicy::class,
+ StandaloneDragonfly::class => DatabasePolicy::class,
+ StandaloneClickhouse::class => DatabasePolicy::class,
// Notification policies - all use the shared NotificationPolicy
- \App\Models\EmailNotificationSettings::class => \App\Policies\NotificationPolicy::class,
- \App\Models\DiscordNotificationSettings::class => \App\Policies\NotificationPolicy::class,
- \App\Models\TelegramNotificationSettings::class => \App\Policies\NotificationPolicy::class,
- \App\Models\SlackNotificationSettings::class => \App\Policies\NotificationPolicy::class,
- \App\Models\PushoverNotificationSettings::class => \App\Policies\NotificationPolicy::class,
- \App\Models\WebhookNotificationSettings::class => \App\Policies\NotificationPolicy::class,
+ EmailNotificationSettings::class => NotificationPolicy::class,
+ DiscordNotificationSettings::class => NotificationPolicy::class,
+ TelegramNotificationSettings::class => NotificationPolicy::class,
+ SlackNotificationSettings::class => NotificationPolicy::class,
+ PushoverNotificationSettings::class => NotificationPolicy::class,
+ WebhookNotificationSettings::class => NotificationPolicy::class,
// API Token policy
- \Laravel\Sanctum\PersonalAccessToken::class => \App\Policies\ApiTokenPolicy::class,
+ PersonalAccessToken::class => ApiTokenPolicy::class,
// Instance settings policy
- \App\Models\InstanceSettings::class => \App\Policies\InstanceSettingsPolicy::class,
+ InstanceSettings::class => InstanceSettingsPolicy::class,
// Team policy
- \App\Models\Team::class => \App\Policies\TeamPolicy::class,
+ Team::class => TeamPolicy::class,
// Git source policies
- \App\Models\GithubApp::class => \App\Policies\GithubAppPolicy::class,
+ GithubApp::class => GithubAppPolicy::class,
+ CloudProviderToken::class => CloudProviderTokenPolicy::class,
+ CloudInitScript::class => CloudInitScriptPolicy::class,
];
diff --git a/app/View/Components/Forms/Checkbox.php b/app/View/Components/Forms/Checkbox.php
index eb38d84af..e33e4b919 100644
--- a/app/View/Components/Forms/Checkbox.php
+++ b/app/View/Components/Forms/Checkbox.php
@@ -6,7 +6,6 @@ use Closure;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Gate;
use Illuminate\View\Component;
-use Visus\Cuid2\Cuid2;
class Checkbox extends Component
{
@@ -58,7 +57,7 @@ class Checkbox extends Component
// Generate unique HTML ID by adding random suffix
// This prevents duplicate IDs when multiple forms are on the same page
if ($this->id) {
- $uniqueSuffix = new Cuid2;
+ $uniqueSuffix = new_public_id();
$this->htmlId = $this->id.'-'.$uniqueSuffix;
} else {
$this->htmlId = $this->id;
diff --git a/app/View/Components/Forms/Datalist.php b/app/View/Components/Forms/Datalist.php
index 3b7a9ee34..b0f85c8cb 100644
--- a/app/View/Components/Forms/Datalist.php
+++ b/app/View/Components/Forms/Datalist.php
@@ -6,7 +6,6 @@ use Closure;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Gate;
use Illuminate\View\Component;
-use Visus\Cuid2\Cuid2;
class Datalist extends Component
{
@@ -55,7 +54,7 @@ class Datalist extends Component
$this->modelBinding = $this->id;
if (is_null($this->id)) {
- $this->id = new Cuid2;
+ $this->id = new_public_id();
// Don't create wire:model binding for auto-generated IDs
$this->modelBinding = 'null';
}
@@ -64,7 +63,7 @@ class Datalist extends Component
// This prevents duplicate IDs when multiple forms are on the same page
if ($this->modelBinding && $this->modelBinding !== 'null') {
// Use original ID with random suffix for uniqueness
- $uniqueSuffix = new Cuid2;
+ $uniqueSuffix = new_public_id();
$this->htmlId = $this->modelBinding.'-'.$uniqueSuffix;
} else {
$this->htmlId = (string) $this->id;
diff --git a/app/View/Components/Forms/EnvVarInput.php b/app/View/Components/Forms/EnvVarInput.php
index faef64a36..2f26e44cc 100644
--- a/app/View/Components/Forms/EnvVarInput.php
+++ b/app/View/Components/Forms/EnvVarInput.php
@@ -6,7 +6,6 @@ use Closure;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Gate;
use Illuminate\View\Component;
-use Visus\Cuid2\Cuid2;
class EnvVarInput extends Component
{
@@ -56,7 +55,7 @@ class EnvVarInput extends Component
$this->modelBinding = $this->id;
if (is_null($this->id)) {
- $this->id = new Cuid2;
+ $this->id = new_public_id();
// Don't create wire:model binding for auto-generated IDs
$this->modelBinding = 'null';
}
@@ -64,7 +63,7 @@ class EnvVarInput extends Component
// This prevents duplicate IDs when multiple forms are on the same page
if ($this->modelBinding && $this->modelBinding !== 'null') {
// Use original ID with random suffix for uniqueness
- $uniqueSuffix = new Cuid2;
+ $uniqueSuffix = new_public_id();
$this->htmlId = $this->modelBinding.'-'.$uniqueSuffix;
} else {
$this->htmlId = (string) $this->id;
diff --git a/app/View/Components/Forms/Input.php b/app/View/Components/Forms/Input.php
index 5ed347f42..303856926 100644
--- a/app/View/Components/Forms/Input.php
+++ b/app/View/Components/Forms/Input.php
@@ -5,8 +5,8 @@ namespace App\View\Components\Forms;
use Closure;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Gate;
+use Illuminate\Support\Str;
use Illuminate\View\Component;
-use Visus\Cuid2\Cuid2;
class Input extends Component
{
@@ -51,7 +51,7 @@ class Input extends Component
$this->modelBinding = $this->id;
if (is_null($this->id)) {
- $this->id = new Cuid2;
+ $this->id = new_public_id();
// Don't create wire:model binding for auto-generated IDs
$this->modelBinding = 'null';
}
@@ -59,7 +59,7 @@ class Input extends Component
// This prevents duplicate IDs when multiple forms are on the same page
if ($this->modelBinding && $this->modelBinding !== 'null') {
// Use original ID with random suffix for uniqueness
- $uniqueSuffix = new Cuid2;
+ $uniqueSuffix = new_public_id();
$this->htmlId = $this->modelBinding.'-'.$uniqueSuffix;
} else {
$this->htmlId = (string) $this->id;
diff --git a/app/View/Components/Forms/Select.php b/app/View/Components/Forms/Select.php
index 026e3ba8c..327c33da6 100644
--- a/app/View/Components/Forms/Select.php
+++ b/app/View/Components/Forms/Select.php
@@ -6,7 +6,6 @@ use Closure;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Gate;
use Illuminate\View\Component;
-use Visus\Cuid2\Cuid2;
class Select extends Component
{
@@ -48,7 +47,7 @@ class Select extends Component
$this->modelBinding = $this->id;
if (is_null($this->id)) {
- $this->id = new Cuid2;
+ $this->id = new_public_id();
// Don't create wire:model binding for auto-generated IDs
$this->modelBinding = 'null';
}
@@ -57,7 +56,7 @@ class Select extends Component
// This prevents duplicate IDs when multiple forms are on the same page
if ($this->modelBinding && $this->modelBinding !== 'null') {
// Use original ID with random suffix for uniqueness
- $uniqueSuffix = new Cuid2;
+ $uniqueSuffix = new_public_id();
$this->htmlId = $this->modelBinding.'-'.$uniqueSuffix;
} else {
$this->htmlId = (string) $this->id;
diff --git a/app/View/Components/Forms/Textarea.php b/app/View/Components/Forms/Textarea.php
index 02a23a26a..5a5f975c6 100644
--- a/app/View/Components/Forms/Textarea.php
+++ b/app/View/Components/Forms/Textarea.php
@@ -5,8 +5,8 @@ namespace App\View\Components\Forms;
use Closure;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Gate;
+use Illuminate\Support\Str;
use Illuminate\View\Component;
-use Visus\Cuid2\Cuid2;
class Textarea extends Component
{
@@ -63,7 +63,7 @@ class Textarea extends Component
$this->modelBinding = $this->id;
if (is_null($this->id)) {
- $this->id = new Cuid2;
+ $this->id = new_public_id();
// Don't create wire:model binding for auto-generated IDs
$this->modelBinding = 'null';
}
@@ -72,7 +72,7 @@ class Textarea extends Component
// This prevents duplicate IDs when multiple forms are on the same page
if ($this->modelBinding && $this->modelBinding !== 'null') {
// Use original ID with random suffix for uniqueness
- $uniqueSuffix = new Cuid2;
+ $uniqueSuffix = new_public_id();
$this->htmlId = $this->modelBinding.'-'.$uniqueSuffix;
} else {
$this->htmlId = (string) $this->id;
diff --git a/bootstrap/helpers/applications.php b/bootstrap/helpers/applications.php
index 4707b0a07..b7e4af7ab 100644
--- a/bootstrap/helpers/applications.php
+++ b/bootstrap/helpers/applications.php
@@ -10,7 +10,6 @@ use App\Models\EnvironmentVariable;
use App\Models\Server;
use App\Models\StandaloneDocker;
use Spatie\Url\Url;
-use Visus\Cuid2\Cuid2;
function queue_application_deployment(Application $application, string $deployment_uuid, ?int $pull_request_id = 0, ?string $commit = null, bool $force_rebuild = false, bool $is_webhook = false, bool $is_api = false, bool $restart_only = false, ?string $git_type = null, bool $no_questions_asked = false, ?Server $server = null, ?StandaloneDocker $destination = null, bool $only_this_server = false, bool $rollback = false, ?string $docker_registry_image_tag = null)
{
@@ -192,7 +191,7 @@ function next_after_cancel(?Server $server = null)
function clone_application(Application $source, $destination, array $overrides = [], bool $cloneVolumeData = false): Application
{
- $uuid = $overrides['uuid'] ?? (string) new Cuid2;
+ $uuid = $overrides['uuid'] ?? new_public_id();
$server = $destination->server;
if ($server->team_id !== currentTeam()->id) {
@@ -259,7 +258,7 @@ function clone_application(Application $source, $destination, array $overrides =
'created_at',
'updated_at',
])->fill([
- 'uuid' => (string) new Cuid2,
+ 'uuid' => new_public_id(),
'application_id' => $newApplication->id,
'team_id' => currentTeam()->id,
]);
@@ -274,7 +273,7 @@ function clone_application(Application $source, $destination, array $overrides =
'created_at',
'updated_at',
])->fill([
- 'uuid' => (string) new Cuid2,
+ 'uuid' => new_public_id(),
'application_id' => $newApplication->id,
'status' => 'exited',
'fqdn' => null,
@@ -322,7 +321,7 @@ function clone_application(Application $source, $destination, array $overrides =
VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume);
queue_application_deployment(
- deployment_uuid: (string) new Cuid2,
+ deployment_uuid: new_public_id(),
application: $source,
server: $sourceServer,
destination: $source->destination,
diff --git a/bootstrap/helpers/databases.php b/bootstrap/helpers/databases.php
index 4d5e085f3..5f0b2e690 100644
--- a/bootstrap/helpers/databases.php
+++ b/bootstrap/helpers/databases.php
@@ -17,12 +17,11 @@ use App\Models\SwarmDocker;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
-use Visus\Cuid2\Cuid2;
function create_standalone_postgresql($environmentId, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null, string $databaseImage = 'postgres:16-alpine'): StandalonePostgresql
{
$database = new StandalonePostgresql;
- $database->uuid = (new Cuid2);
+ $database->uuid = new_public_id();
$database->name = 'postgresql-database-'.$database->uuid;
$database->image = $databaseImage;
$database->postgres_password = Str::password(length: 64, symbols: false);
@@ -40,7 +39,7 @@ function create_standalone_postgresql($environmentId, StandaloneDocker|SwarmDock
function create_standalone_redis($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneRedis
{
$database = new StandaloneRedis;
- $database->uuid = (new Cuid2);
+ $database->uuid = new_public_id();
$database->name = 'redis-database-'.$database->uuid;
$redis_password = Str::password(length: 64, symbols: false);
@@ -79,7 +78,7 @@ function create_standalone_redis($environment_id, StandaloneDocker|SwarmDocker $
function create_standalone_mongodb($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneMongodb
{
$database = new StandaloneMongodb;
- $database->uuid = (new Cuid2);
+ $database->uuid = new_public_id();
$database->name = 'mongodb-database-'.$database->uuid;
$database->mongo_initdb_root_password = Str::password(length: 64, symbols: false);
$database->environment_id = $environment_id;
@@ -96,7 +95,7 @@ function create_standalone_mongodb($environment_id, StandaloneDocker|SwarmDocker
function create_standalone_mysql($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneMysql
{
$database = new StandaloneMysql;
- $database->uuid = (new Cuid2);
+ $database->uuid = new_public_id();
$database->name = 'mysql-database-'.$database->uuid;
$database->mysql_root_password = Str::password(length: 64, symbols: false);
$database->mysql_password = Str::password(length: 64, symbols: false);
@@ -114,7 +113,7 @@ function create_standalone_mysql($environment_id, StandaloneDocker|SwarmDocker $
function create_standalone_mariadb($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneMariadb
{
$database = new StandaloneMariadb;
- $database->uuid = (new Cuid2);
+ $database->uuid = new_public_id();
$database->name = 'mariadb-database-'.$database->uuid;
$database->mariadb_root_password = Str::password(length: 64, symbols: false);
$database->mariadb_password = Str::password(length: 64, symbols: false);
@@ -132,7 +131,7 @@ function create_standalone_mariadb($environment_id, StandaloneDocker|SwarmDocker
function create_standalone_keydb($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneKeydb
{
$database = new StandaloneKeydb;
- $database->uuid = (new Cuid2);
+ $database->uuid = new_public_id();
$database->name = 'keydb-database-'.$database->uuid;
$database->keydb_password = Str::password(length: 64, symbols: false);
$database->environment_id = $environment_id;
@@ -149,7 +148,7 @@ function create_standalone_keydb($environment_id, StandaloneDocker|SwarmDocker $
function create_standalone_dragonfly($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneDragonfly
{
$database = new StandaloneDragonfly;
- $database->uuid = (new Cuid2);
+ $database->uuid = new_public_id();
$database->name = 'dragonfly-database-'.$database->uuid;
$database->dragonfly_password = Str::password(length: 64, symbols: false);
$database->environment_id = $environment_id;
@@ -166,7 +165,7 @@ function create_standalone_dragonfly($environment_id, StandaloneDocker|SwarmDock
function create_standalone_clickhouse($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneClickhouse
{
$database = new StandaloneClickhouse;
- $database->uuid = (new Cuid2);
+ $database->uuid = new_public_id();
$database->name = 'clickhouse-database-'.$database->uuid;
$database->clickhouse_admin_password = Str::password(length: 64, symbols: false);
$database->environment_id = $environment_id;
diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php
index 2cf159bfd..1b389c77c 100644
--- a/bootstrap/helpers/docker.php
+++ b/bootstrap/helpers/docker.php
@@ -9,7 +9,6 @@ use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Spatie\Url\Url;
use Symfony\Component\Yaml\Yaml;
-use Visus\Cuid2\Cuid2;
function getCurrentApplicationContainerStatus(Server $server, int $id, ?int $pullRequestId = null, ?bool $includePullrequests = false): Collection
{
@@ -459,7 +458,7 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_
foreach ($domains as $loop => $domain) {
try {
if ($generate_unique_uuid) {
- $uuid = new Cuid2;
+ $uuid = new_public_id();
}
$url = Url::fromString($domain);
diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php
index 123cf906a..6632e1fd5 100644
--- a/bootstrap/helpers/parsers.php
+++ b/bootstrap/helpers/parsers.php
@@ -14,7 +14,6 @@ use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use Spatie\Url\Url;
use Symfony\Component\Yaml\Yaml;
-use Visus\Cuid2\Cuid2;
/**
* Validates a Docker Compose YAML string for command injection vulnerabilities.
@@ -1240,7 +1239,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$schema = $url->getScheme();
$portInt = $url->getPort();
$port = $portInt !== null ? ':'.$portInt : '';
- $random = new Cuid2;
+ $random = new_public_id();
$preview_fqdn = str_replace('{{random}}', $random, $template);
$preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn);
$preview_fqdn = str_replace('{{pr_id}}', $pullRequestId, $preview_fqdn);
diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php
index f2b672fef..c3f78ed9a 100644
--- a/bootstrap/helpers/shared.php
+++ b/bootstrap/helpers/shared.php
@@ -64,7 +64,6 @@ use PurplePixie\PhpDns\DNSQuery;
use PurplePixie\PhpDns\DNSTypes;
use Spatie\Url\Url;
use Symfony\Component\Yaml\Yaml;
-use Visus\Cuid2\Cuid2;
function base_configuration_dir(): string
{
@@ -115,6 +114,13 @@ function sanitize_string(?string $input = null): ?string
return $sanitized;
}
+function new_public_id(int $length = 24): string
+{
+ $length = max(1, $length);
+
+ return Str::lower(Str::random($length));
+}
+
/**
* Validate that a path or identifier is safe for use in shell commands.
*
@@ -455,7 +461,7 @@ function generate_random_name(?string $cuid = null): string
]
);
if (is_null($cuid)) {
- $cuid = new Cuid2;
+ $cuid = new_public_id();
}
return Str::kebab("{$generator->getName()}-$cuid");
@@ -491,7 +497,7 @@ function formatPrivateKey(string $privateKey)
function generate_application_name(string $git_repository, string $git_branch, ?string $cuid = null): string
{
if (is_null($cuid)) {
- $cuid = new Cuid2;
+ $cuid = new_public_id();
}
$repo_name = str_contains($git_repository, '/') ? last(explode('/', $git_repository)) : $git_repository;
@@ -3259,7 +3265,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
$template = $resource->preview_url_template;
$host = $url->getHost();
$schema = $url->getScheme();
- $random = new Cuid2;
+ $random = new_public_id();
$preview_fqdn = str_replace('{{random}}', $random, $template);
$preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn);
$preview_fqdn = str_replace('{{pr_id}}', $pull_request_id, $preview_fqdn);
diff --git a/database/factories/CloudProviderTokenFactory.php b/database/factories/CloudProviderTokenFactory.php
new file mode 100644
index 000000000..4da7a2d08
--- /dev/null
+++ b/database/factories/CloudProviderTokenFactory.php
@@ -0,0 +1,25 @@
+
+ */
+class CloudProviderTokenFactory extends Factory
+{
+ protected $model = CloudProviderToken::class;
+
+ public function definition(): array
+ {
+ return [
+ 'team_id' => Team::factory(),
+ 'provider' => 'hetzner',
+ 'token' => 'test-cloud-provider-token',
+ 'name' => fake()->words(3, true),
+ ];
+ }
+}
diff --git a/database/factories/PrivateKeyFactory.php b/database/factories/PrivateKeyFactory.php
new file mode 100644
index 000000000..51cfdcaa2
--- /dev/null
+++ b/database/factories/PrivateKeyFactory.php
@@ -0,0 +1,37 @@
+
+ */
+class PrivateKeyFactory extends Factory
+{
+ protected $model = PrivateKey::class;
+
+ public function definition(): array
+ {
+ return [
+ 'name' => fake()->words(2, true),
+ 'description' => fake()->sentence(),
+ 'private_key' => $this->privateKey(),
+ 'team_id' => Team::factory(),
+ 'is_git_related' => false,
+ ];
+ }
+
+ private function privateKey(): string
+ {
+ return '-----BEGIN OPENSSH PRIVATE KEY-----
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
+QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
+hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
+AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
+uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
+-----END OPENSSH PRIVATE KEY-----';
+ }
+}
diff --git a/resources/views/livewire/project/database/postgresql/general.blade.php b/resources/views/livewire/project/database/postgresql/general.blade.php
index 2f0b14e84..f81fa63fd 100644
--- a/resources/views/livewire/project/database/postgresql/general.blade.php
+++ b/resources/views/livewire/project/database/postgresql/general.blade.php
@@ -142,7 +142,8 @@
@forelse($initScripts ?? [] as $script)
-
+
@empty
No initialization scripts found.
@endforelse
diff --git a/tests/Feature/Api/CloudProviderTokenApiTest.php b/tests/Feature/Api/CloudProviderTokenApiTest.php
index da3acfd56..1a24b5eca 100644
--- a/tests/Feature/Api/CloudProviderTokenApiTest.php
+++ b/tests/Feature/Api/CloudProviderTokenApiTest.php
@@ -1,14 +1,23 @@
whereKey(0)->delete();
+ $settings = new InstanceSettings(['is_api_enabled' => true]);
+ $settings->id = 0;
+ $settings->save();
+ Once::flush();
+
// Create a team with owner
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
@@ -410,4 +419,33 @@ describe('POST /api/v1/cloud-tokens/{uuid}/validate', function () {
$response->assertStatus(200);
$response->assertJson(['valid' => true, 'message' => 'Token is valid.']);
});
+
+ test('writes an audit log entry when validating a stored token', function () {
+ $token = CloudProviderToken::factory()->create([
+ 'team_id' => $this->team->id,
+ 'provider' => 'hetzner',
+ 'name' => 'Audit Token',
+ ]);
+
+ Http::fake([
+ 'https://api.hetzner.cloud/v1/servers' => Http::response([], 200),
+ ]);
+
+ $auditChannel = Mockery::mock();
+ $auditChannel->shouldReceive('info')
+ ->once()
+ ->with('api.cloud_token.validated', Mockery::on(function (array $context) use ($token) {
+ return $context['cloud_token_uuid'] === $token->uuid
+ && $context['provider'] === 'hetzner'
+ && $context['valid'] === true;
+ }));
+
+ Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
+
+ $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->postJson("/api/v1/cloud-tokens/{$token->uuid}/validate")
+ ->assertOk();
+ });
});
diff --git a/tests/Feature/Api/HetznerApiTest.php b/tests/Feature/Api/HetznerApiTest.php
index b5950f9fc..728b99d0e 100644
--- a/tests/Feature/Api/HetznerApiTest.php
+++ b/tests/Feature/Api/HetznerApiTest.php
@@ -1,15 +1,23 @@
whereKey(0)->delete();
+ $settings = new InstanceSettings(['is_api_enabled' => true]);
+ $settings->id = 0;
+ $settings->save();
+ Once::flush();
+
// Create a team with owner
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
@@ -73,6 +81,27 @@ describe('GET /api/v1/hetzner/locations', function () {
$response->assertStatus(404);
});
+
+ test('member read token cannot use a stored cloud provider token', function () {
+ $member = User::factory()->create();
+ $this->team->members()->attach($member->id, ['role' => 'member']);
+ session(['currentTeam' => $this->team]);
+ $memberToken = $member->createToken('member-read', ['read'])->plainTextToken;
+
+ Http::fake([
+ 'https://api.hetzner.cloud/v1/locations*' => Http::response([
+ 'locations' => [['id' => 1, 'name' => 'nbg1']],
+ ], 200),
+ ]);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$memberToken,
+ 'Content-Type' => 'application/json',
+ ])->getJson('/api/v1/hetzner/locations?cloud_provider_token_id='.$this->hetznerToken->uuid);
+
+ $response->assertForbidden();
+ Http::assertNothingSent();
+ });
});
describe('GET /api/v1/hetzner/server-types', function () {
diff --git a/tests/Feature/Authorization/CloudProviderAuthorizationTest.php b/tests/Feature/Authorization/CloudProviderAuthorizationTest.php
index 46185e9bb..04c8d030c 100644
--- a/tests/Feature/Authorization/CloudProviderAuthorizationTest.php
+++ b/tests/Feature/Authorization/CloudProviderAuthorizationTest.php
@@ -7,8 +7,11 @@ use App\Models\PersonalAccessToken;
use App\Models\PrivateKey;
use App\Models\Team;
use App\Models\User;
+use App\Policies\CloudInitScriptPolicy;
+use App\Policies\CloudProviderTokenPolicy;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
@@ -131,6 +134,11 @@ test('admin can view any cloud init scripts', function () {
expect(auth()->user()->can('viewAny', CloudInitScript::class))->toBeTrue();
});
+test('cloud provider and cloud init policies are explicitly registered', function () {
+ expect(Gate::getPolicyFor(CloudProviderToken::class))->toBeInstanceOf(CloudProviderTokenPolicy::class)
+ ->and(Gate::getPolicyFor(CloudInitScript::class))->toBeInstanceOf(CloudInitScriptPolicy::class);
+});
+
// --- Personal Access Token (API Token) Policy ---
test('any user can create personal access token', function () {
diff --git a/tests/Feature/Mcp/McpEndpointTest.php b/tests/Feature/Mcp/McpEndpointTest.php
index ae0101547..b8511afef 100644
--- a/tests/Feature/Mcp/McpEndpointTest.php
+++ b/tests/Feature/Mcp/McpEndpointTest.php
@@ -6,6 +6,7 @@ use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\Log;
use Illuminate\Support\Once;
uses(RefreshDatabase::class);
@@ -64,6 +65,23 @@ function mcpToolJson($response): array
return json_decode($response->json('result.content.0.text'), true);
}
+function expectMcpAuditLog(array $expected): void
+{
+ $auditChannel = Mockery::mock();
+
+ Log::shouldReceive('channel')
+ ->with('audit')
+ ->once()
+ ->andReturn($auditChannel);
+
+ $auditChannel
+ ->shouldReceive('info')
+ ->once()
+ ->with('mcp.tool.called', Mockery::on(fn (array $context) => collect($expected)->every(
+ fn ($value, $key) => data_get($context, $key) === $value,
+ )));
+}
+
test('MCP endpoint returns 404 when the instance setting is disabled', function () {
InstanceSettings::query()->where('id', 0)->update(['is_mcp_server_enabled' => false]);
Once::flush();
@@ -193,6 +211,48 @@ test('tool calls fail when the token lacks the read ability', function () {
expect($response->json('result.content.0.text'))->toContain('Missing required permissions');
});
+test('MCP tools audit successful execution with the actual tool name', function () {
+ Project::create(['name' => 'Mine', 'team_id' => $this->team->id]);
+ $token = $this->user->createToken('mcp-read', ['read'])->plainTextToken;
+
+ expectMcpAuditLog([
+ 'tool' => 'list_projects',
+ 'team_id' => $this->team->id,
+ 'outcome' => 'success',
+ ]);
+
+ mcpCallTool($token, 'list_projects')->assertOk();
+});
+
+test('MCP tools audit denied execution after ability checks', function () {
+ $token = $this->user->createToken('mcp-no-abilities', [])->plainTextToken;
+
+ expectMcpAuditLog([
+ 'tool' => 'list_projects',
+ 'team_id' => $this->team->id,
+ 'outcome' => 'denied',
+ ]);
+
+ $response = mcpCallTool($token, 'list_projects');
+ $response->assertOk();
+ expect($response->json('result.isError'))->toBeTrue();
+});
+
+test('MCP tools audit execution errors after tool handling', function () {
+ $token = $this->user->createToken('mcp-read', ['read'])->plainTextToken;
+
+ expectMcpAuditLog([
+ 'tool' => 'get_server',
+ 'team_id' => $this->team->id,
+ 'outcome' => 'error',
+ 'resource_uuid' => 'missing-server',
+ ]);
+
+ $response = mcpCallTool($token, 'get_server', ['uuid' => 'missing-server']);
+ $response->assertOk();
+ expect($response->json('result.isError'))->toBeTrue();
+});
+
test('MCP rejects token when user no longer belongs to token team', function () {
Project::create(['name' => 'Hidden', 'team_id' => $this->team->id]);
$token = $this->user->createToken('mcp-read', ['read'])->plainTextToken;
diff --git a/tests/Feature/MutableLivewireComponentsAuthorizationTest.php b/tests/Feature/MutableLivewireComponentsAuthorizationTest.php
new file mode 100644
index 000000000..962628298
--- /dev/null
+++ b/tests/Feature/MutableLivewireComponentsAuthorizationTest.php
@@ -0,0 +1,30 @@
+toContain($needle);
+ }
+})->with([
+ 'storage resources' => [
+ 'app/Livewire/Storage/Resources.php',
+ ['AuthorizesRequests', "authorize('update'", "authorize('view'"],
+ ],
+ 'postgres init script editor' => [
+ 'app/Livewire/Project/Database/InitScript.php',
+ ['AuthorizesRequests', "authorize('update'"],
+ ],
+ 'execute container command' => [
+ 'app/Livewire/Project/Shared/ExecuteContainerCommand.php',
+ ['AuthorizesRequests', "authorize('view'", "authorize('canAccessTerminal'"],
+ ],
+ 'terminal' => [
+ 'app/Livewire/Project/Shared/Terminal.php',
+ ['AuthorizesRequests', "authorize('view'", "authorize('canAccessTerminal'"],
+ ],
+]);
diff --git a/tests/Feature/Security/AuditLogTest.php b/tests/Feature/Security/AuditLogTest.php
index 34e9168ec..ca1b7bd08 100644
--- a/tests/Feature/Security/AuditLogTest.php
+++ b/tests/Feature/Security/AuditLogTest.php
@@ -1,19 +1,36 @@
whereKey(0)->exists()) {
+ $settings = new InstanceSettings;
+ $settings->id = 0;
+ $settings->save();
+ }
+ Once::flush();
+
$team = Team::factory()->create();
$user = User::factory()->create();
$team->members()->attach($user->id, ['role' => 'owner']);
@@ -122,6 +139,89 @@ describe('audit channel helper', function () {
});
});
+describe('security UI audit logging', function () {
+ test('creating a cloud provider token from Livewire writes an audit entry', function () {
+ [$team] = makeAuditTeamUser();
+
+ Http::fake([
+ 'https://api.hetzner.cloud/v1/servers' => Http::response([], 200),
+ ]);
+
+ $auditChannel = Mockery::mock();
+ $auditChannel->shouldReceive('info')
+ ->once()
+ ->with('ui.cloud_token.created', Mockery::on(function (array $context) use ($team) {
+ return $context['team_id'] === $team->id
+ && $context['provider'] === 'hetzner'
+ && $context['cloud_token_name'] === 'UI Token';
+ }));
+
+ Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
+
+ Livewire::test(CloudProviderTokenForm::class)
+ ->set('provider', 'hetzner')
+ ->set('token', 'secret-token')
+ ->set('name', 'UI Token')
+ ->call('addToken');
+ });
+
+ test('deleting a cloud provider token from Livewire writes an audit entry', function () {
+ [$team] = makeAuditTeamUser();
+ $token = CloudProviderToken::factory()->create([
+ 'team_id' => $team->id,
+ 'provider' => 'hetzner',
+ 'name' => 'Delete Me',
+ ]);
+
+ $auditChannel = Mockery::mock();
+ $auditChannel->shouldReceive('info')
+ ->once()
+ ->with('ui.cloud_token.deleted', Mockery::on(function (array $context) use ($token) {
+ return $context['cloud_token_uuid'] === $token->uuid
+ && $context['cloud_token_name'] === 'Delete Me';
+ }));
+
+ Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
+
+ Livewire::test(CloudProviderTokens::class)
+ ->call('deleteToken', $token->id);
+ });
+
+ test('creating and deleting cloud-init scripts from Livewire write audit entries', function () {
+ [$team] = makeAuditTeamUser();
+
+ $auditChannel = Mockery::mock();
+ $auditChannel->shouldReceive('info')
+ ->once()
+ ->with('ui.cloud_init_script.created', Mockery::on(function (array $context) use ($team) {
+ return $context['team_id'] === $team->id
+ && $context['cloud_init_script_name'] === 'Bootstrap';
+ }));
+ $auditChannel->shouldReceive('info')
+ ->once()
+ ->with('ui.cloud_init_script.deleted', Mockery::on(function (array $context) {
+ return $context['cloud_init_script_name'] === 'Bootstrap';
+ }));
+
+ Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
+
+ Livewire::test(CloudInitScriptForm::class)
+ ->set('name', 'Bootstrap')
+ ->set('script', "#cloud-config\npackages: []")
+ ->call('save');
+
+ $script = CloudInitScript::where('team_id', $team->id)->firstOrFail();
+
+ Livewire::test(CloudInitScripts::class)
+ ->call('deleteScript', $script->id);
+ });
+
+ test('cloud provider token form does not contain debug ray calls', function () {
+ expect(file_get_contents(app_path('Livewire/Security/CloudProviderTokenForm.php')))
+ ->not->toContain('ray(');
+ });
+});
+
describe('webhook signature failure logging', function () {
test('GitHub manual webhook with bad signature logs to audit channel', function () {
$app = makeAuditApplication();
diff --git a/tests/Feature/SentinelPushDeduplicationTest.php b/tests/Feature/SentinelPushDeduplicationTest.php
index a7ecd5d4c..fb7b2df2e 100644
--- a/tests/Feature/SentinelPushDeduplicationTest.php
+++ b/tests/Feature/SentinelPushDeduplicationTest.php
@@ -8,6 +8,7 @@ use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
@@ -81,6 +82,23 @@ it('skips the job when the second push is identical', function () use ($running)
Queue::assertPushed(PushServerUpdateJob::class, 1);
});
+it('only audits sentinel pushes that dispatch a state update', function () use ($running) {
+ $auditChannel = Mockery::mock();
+ $auditChannel->shouldReceive('info')
+ ->once()
+ ->with('sentinel.metrics_pushed', Mockery::on(function (array $context) {
+ return $context['server_uuid'] === $this->server->uuid
+ && $context['team_id'] === $this->team->id;
+ }));
+
+ Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
+
+ pushSentinel($this->token, sentinelPayload($running()))->assertOk();
+ pushSentinel($this->token, sentinelPayload($running()))->assertOk();
+
+ Queue::assertPushed(PushServerUpdateJob::class, 1);
+});
+
it('updates the heartbeat even when the job is skipped', function () use ($running) {
pushSentinel($this->token, sentinelPayload($running()))->assertOk();
diff --git a/tests/Unit/PublicIdTest.php b/tests/Unit/PublicIdTest.php
new file mode 100644
index 000000000..027d46e57
--- /dev/null
+++ b/tests/Unit/PublicIdTest.php
@@ -0,0 +1,19 @@
+map(fn () => new_public_id());
+
+ expect($ids)->each
+ ->toBeString()
+ ->toHaveLength(24)
+ ->toMatch('/^[a-z0-9]+$/');
+
+ expect($ids->unique())->toHaveCount(100);
+});
+
+it('honors custom public id lengths', function () {
+ expect(new_public_id(32))
+ ->toBeString()
+ ->toHaveLength(32)
+ ->toMatch('/^[a-z0-9]+$/');
+});
From 5973bb4d4f3c236d76ac25cb77c22e5317d5379f Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Thu, 4 Jun 2026 11:24:19 +0200
Subject: [PATCH 24/30] fix(security): hide notification secrets from
non-admins
Prevent users without update permission from reading notification credentials and manual webhook secrets in Livewire state or rendered forms.
---
app/Livewire/Notifications/Discord.php | 4 +-
app/Livewire/Notifications/Pushover.php | 9 ++-
app/Livewire/Notifications/Slack.php | 4 +-
app/Livewire/Notifications/Telegram.php | 9 ++-
app/Livewire/Notifications/Webhook.php | 4 +-
app/Livewire/Project/Shared/Webhooks.php | 19 +++--
.../livewire/notifications/discord.blade.php | 12 ++-
.../livewire/notifications/pushover.blade.php | 21 ++++--
.../livewire/notifications/slack.blade.php | 12 ++-
.../livewire/notifications/telegram.blade.php | 21 ++++--
.../livewire/notifications/webhook.blade.php | 12 ++-
.../project/shared/webhooks.blade.php | 16 ++--
.../NotificationAuthorizationTest.php | 74 ++++++++++++++++++-
.../SharedResourceAuthorizationTest.php | 47 +++++++++++-
14 files changed, 219 insertions(+), 45 deletions(-)
diff --git a/app/Livewire/Notifications/Discord.php b/app/Livewire/Notifications/Discord.php
index ab3884320..59350a3e1 100644
--- a/app/Livewire/Notifications/Discord.php
+++ b/app/Livewire/Notifications/Discord.php
@@ -110,7 +110,9 @@ class Discord extends Component
refreshSession();
} else {
$this->discordEnabled = $this->settings->discord_enabled;
- $this->discordWebhookUrl = $this->settings->discord_webhook_url;
+ $this->discordWebhookUrl = auth()->user()->can('update', $this->settings)
+ ? $this->settings->discord_webhook_url
+ : null;
$this->deploymentSuccessDiscordNotifications = $this->settings->deployment_success_discord_notifications;
$this->deploymentFailureDiscordNotifications = $this->settings->deployment_failure_discord_notifications;
diff --git a/app/Livewire/Notifications/Pushover.php b/app/Livewire/Notifications/Pushover.php
index d79eea87b..f894c5005 100644
--- a/app/Livewire/Notifications/Pushover.php
+++ b/app/Livewire/Notifications/Pushover.php
@@ -113,8 +113,13 @@ class Pushover extends Component
refreshSession();
} else {
$this->pushoverEnabled = $this->settings->pushover_enabled;
- $this->pushoverUserKey = $this->settings->pushover_user_key;
- $this->pushoverApiToken = $this->settings->pushover_api_token;
+ if (auth()->user()->can('update', $this->settings)) {
+ $this->pushoverUserKey = $this->settings->pushover_user_key;
+ $this->pushoverApiToken = $this->settings->pushover_api_token;
+ } else {
+ $this->pushoverUserKey = null;
+ $this->pushoverApiToken = null;
+ }
$this->deploymentSuccessPushoverNotifications = $this->settings->deployment_success_pushover_notifications;
$this->deploymentFailurePushoverNotifications = $this->settings->deployment_failure_pushover_notifications;
diff --git a/app/Livewire/Notifications/Slack.php b/app/Livewire/Notifications/Slack.php
index f870b3986..58cab5494 100644
--- a/app/Livewire/Notifications/Slack.php
+++ b/app/Livewire/Notifications/Slack.php
@@ -110,7 +110,9 @@ class Slack extends Component
refreshSession();
} else {
$this->slackEnabled = $this->settings->slack_enabled;
- $this->slackWebhookUrl = $this->settings->slack_webhook_url;
+ $this->slackWebhookUrl = auth()->user()->can('update', $this->settings)
+ ? $this->settings->slack_webhook_url
+ : null;
$this->deploymentSuccessSlackNotifications = $this->settings->deployment_success_slack_notifications;
$this->deploymentFailureSlackNotifications = $this->settings->deployment_failure_slack_notifications;
diff --git a/app/Livewire/Notifications/Telegram.php b/app/Livewire/Notifications/Telegram.php
index fc3966cf6..78eb7ef9f 100644
--- a/app/Livewire/Notifications/Telegram.php
+++ b/app/Livewire/Notifications/Telegram.php
@@ -169,8 +169,13 @@ class Telegram extends Component
$this->settings->save();
} else {
$this->telegramEnabled = $this->settings->telegram_enabled;
- $this->telegramToken = $this->settings->telegram_token;
- $this->telegramChatId = $this->settings->telegram_chat_id;
+ if (auth()->user()->can('update', $this->settings)) {
+ $this->telegramToken = $this->settings->telegram_token;
+ $this->telegramChatId = $this->settings->telegram_chat_id;
+ } else {
+ $this->telegramToken = null;
+ $this->telegramChatId = null;
+ }
$this->deploymentSuccessTelegramNotifications = $this->settings->deployment_success_telegram_notifications;
$this->deploymentFailureTelegramNotifications = $this->settings->deployment_failure_telegram_notifications;
diff --git a/app/Livewire/Notifications/Webhook.php b/app/Livewire/Notifications/Webhook.php
index 630d422a9..4a67180ff 100644
--- a/app/Livewire/Notifications/Webhook.php
+++ b/app/Livewire/Notifications/Webhook.php
@@ -105,7 +105,9 @@ class Webhook extends Component
refreshSession();
} else {
$this->webhookEnabled = $this->settings->webhook_enabled;
- $this->webhookUrl = $this->settings->webhook_url;
+ $this->webhookUrl = auth()->user()->can('update', $this->settings)
+ ? $this->settings->webhook_url
+ : null;
$this->deploymentSuccessWebhookNotifications = $this->settings->deployment_success_webhook_notifications;
$this->deploymentFailureWebhookNotifications = $this->settings->deployment_failure_webhook_notifications;
diff --git a/app/Livewire/Project/Shared/Webhooks.php b/app/Livewire/Project/Shared/Webhooks.php
index eafc653d5..eb90262e9 100644
--- a/app/Livewire/Project/Shared/Webhooks.php
+++ b/app/Livewire/Project/Shared/Webhooks.php
@@ -34,19 +34,24 @@ class Webhooks extends Component
{
$this->deploywebhook = generateDeployWebhook($this->resource);
- $this->githubManualWebhookSecret = data_get($this->resource, 'manual_webhook_secret_github');
+ if ($this->canViewSecrets()) {
+ $this->githubManualWebhookSecret = data_get($this->resource, 'manual_webhook_secret_github');
+ $this->gitlabManualWebhookSecret = data_get($this->resource, 'manual_webhook_secret_gitlab');
+ $this->bitbucketManualWebhookSecret = data_get($this->resource, 'manual_webhook_secret_bitbucket');
+ $this->giteaManualWebhookSecret = data_get($this->resource, 'manual_webhook_secret_gitea');
+ }
+
$this->githubManualWebhook = generateGitManualWebhook($this->resource, 'github');
-
- $this->gitlabManualWebhookSecret = data_get($this->resource, 'manual_webhook_secret_gitlab');
$this->gitlabManualWebhook = generateGitManualWebhook($this->resource, 'gitlab');
-
- $this->bitbucketManualWebhookSecret = data_get($this->resource, 'manual_webhook_secret_bitbucket');
$this->bitbucketManualWebhook = generateGitManualWebhook($this->resource, 'bitbucket');
-
- $this->giteaManualWebhookSecret = data_get($this->resource, 'manual_webhook_secret_gitea');
$this->giteaManualWebhook = generateGitManualWebhook($this->resource, 'gitea');
}
+ public function canViewSecrets(): bool
+ {
+ return auth()->user()->can('update', $this->resource);
+ }
+
public function submit()
{
try {
diff --git a/resources/views/livewire/notifications/discord.blade.php b/resources/views/livewire/notifications/discord.blade.php
index 0e5406c78..64e445441 100644
--- a/resources/views/livewire/notifications/discord.blade.php
+++ b/resources/views/livewire/notifications/discord.blade.php
@@ -26,9 +26,15 @@
helper="If enabled, a ping (@here) will be sent to the notification when a critical event happens."
label="Ping Enabled" />
-
+ @can('update', $settings)
+
+ @else
+
+ @endcan
Notification Settings
diff --git a/resources/views/livewire/notifications/pushover.blade.php b/resources/views/livewire/notifications/pushover.blade.php
index 74cd9e8d2..4ea1a159c 100644
--- a/resources/views/livewire/notifications/pushover.blade.php
+++ b/resources/views/livewire/notifications/pushover.blade.php
@@ -24,12 +24,21 @@
-
-
+ @can('update', $settings)
+
+
+ @else
+
+
+ @endcan
Notification Settings
diff --git a/resources/views/livewire/notifications/slack.blade.php b/resources/views/livewire/notifications/slack.blade.php
index 14c7b3508..9ab752291 100644
--- a/resources/views/livewire/notifications/slack.blade.php
+++ b/resources/views/livewire/notifications/slack.blade.php
@@ -23,9 +23,15 @@
-
+ @can('update', $settings)
+
+ @else
+
+ @endcan
Notification Settings
diff --git a/resources/views/livewire/notifications/telegram.blade.php b/resources/views/livewire/notifications/telegram.blade.php
index f87a13c37..98ec128d5 100644
--- a/resources/views/livewire/notifications/telegram.blade.php
+++ b/resources/views/livewire/notifications/telegram.blade.php
@@ -24,12 +24,21 @@
-
-
+ @can('update', $settings)
+
+
+ @else
+
+
+ @endcan
Notification Settings
diff --git a/resources/views/livewire/notifications/webhook.blade.php b/resources/views/livewire/notifications/webhook.blade.php
index 7c32311bf..4bacb6091 100644
--- a/resources/views/livewire/notifications/webhook.blade.php
+++ b/resources/views/livewire/notifications/webhook.blade.php
@@ -28,9 +28,15 @@
-
+ @can('update', $settings)
+
+ @else
+
+ @endcan
Notification Settings
diff --git a/resources/views/livewire/project/shared/webhooks.blade.php b/resources/views/livewire/project/shared/webhooks.blade.php
index 24bba525a..bccc31383 100644
--- a/resources/views/livewire/project/shared/webhooks.blade.php
+++ b/resources/views/livewire/project/shared/webhooks.blade.php
@@ -22,9 +22,9 @@
helper="Need to set a secret to be able to use this webhook. It should match with the secret in GitHub."
label="GitHub Webhook Secret" id="githubManualWebhookSecret">
@else
-
+ label="GitHub Webhook Secret" value="Hidden (only admins can view)">
@endcan
@@ -39,9 +39,9 @@
helper="Need to set a secret to be able to use this webhook. It should match with the secret in GitLab."
label="GitLab Webhook Secret" id="gitlabManualWebhookSecret">
@else
-
+ label="GitLab Webhook Secret" value="Hidden (only admins can view)">
@endcan
@@ -51,9 +51,9 @@
helper="Need to set a secret to be able to use this webhook. It should match with the secret in Bitbucket."
label="Bitbucket Webhook Secret" id="bitbucketManualWebhookSecret">
@else
-
+ label="Bitbucket Webhook Secret" value="Hidden (only admins can view)">
@endcan
@@ -63,9 +63,9 @@
helper="Need to set a secret to be able to use this webhook. It should match with the secret in Gitea."
label="Gitea Webhook Secret" id="giteaManualWebhookSecret">
@else
-
+ label="Gitea Webhook Secret" value="Hidden (only admins can view)">
@endcan
@can('update', $resource)
diff --git a/tests/Feature/Authorization/NotificationAuthorizationTest.php b/tests/Feature/Authorization/NotificationAuthorizationTest.php
index 7a7ac94aa..aa9d1cbdc 100644
--- a/tests/Feature/Authorization/NotificationAuthorizationTest.php
+++ b/tests/Feature/Authorization/NotificationAuthorizationTest.php
@@ -15,7 +15,7 @@ use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
- InstanceSettings::updateOrCreate(['id' => 0]);
+ InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
$this->team = Team::factory()->create();
@@ -275,3 +275,75 @@ test('member cannot send test on any notification channel', function () {
expect($this->member->can('sendTest', $this->team->pushoverNotificationSettings))->toBeFalse();
expect($this->member->can('sendTest', $this->team->webhookNotificationSettings))->toBeFalse();
});
+
+test('member cannot view notification secrets', function (string $component, string $settingsRelation, array $secrets) {
+ $settings = $this->team->{$settingsRelation};
+ $settings->update($secrets);
+
+ $this->actingAs($this->member);
+ session(['currentTeam' => $this->team]);
+
+ $componentTest = Livewire::test($component);
+
+ foreach ($secrets as $column => $value) {
+ $property = str($column)->camel()->toString();
+
+ $componentTest
+ ->assertSet($property, null)
+ ->assertDontSee($value);
+ }
+
+ $componentTest->assertSee('Hidden (only admins can view)');
+})->with([
+ 'discord webhook' => [DiscordNotification::class, 'discordNotificationSettings', [
+ 'discord_webhook_url' => 'https://discord.com/api/webhooks/secret-member',
+ ]],
+ 'slack webhook' => [SlackNotification::class, 'slackNotificationSettings', [
+ 'slack_webhook_url' => 'https://hooks.slack.com/services/secret-member',
+ ]],
+ 'telegram token and chat id' => [TelegramNotification::class, 'telegramNotificationSettings', [
+ 'telegram_token' => 'telegram-secret-token',
+ 'telegram_chat_id' => 'telegram-secret-chat',
+ ]],
+ 'pushover credentials' => [PushoverNotification::class, 'pushoverNotificationSettings', [
+ 'pushover_user_key' => 'pushover-secret-user',
+ 'pushover_api_token' => 'pushover-secret-token',
+ ]],
+ 'generic webhook' => [WebhookNotification::class, 'webhookNotificationSettings', [
+ 'webhook_url' => 'https://example.com/secret-webhook',
+ ]],
+]);
+
+test('admin can view notification secrets', function (string $component, string $settingsRelation, array $secrets) {
+ $settings = $this->team->{$settingsRelation};
+ $settings->update($secrets);
+
+ $this->actingAs($this->admin);
+ session(['currentTeam' => $this->team]);
+
+ $componentTest = Livewire::test($component);
+
+ foreach ($secrets as $column => $value) {
+ $property = str($column)->camel()->toString();
+
+ $componentTest->assertSet($property, $value);
+ }
+})->with([
+ 'discord webhook' => [DiscordNotification::class, 'discordNotificationSettings', [
+ 'discord_webhook_url' => 'https://discord.com/api/webhooks/secret-admin',
+ ]],
+ 'slack webhook' => [SlackNotification::class, 'slackNotificationSettings', [
+ 'slack_webhook_url' => 'https://hooks.slack.com/services/secret-admin',
+ ]],
+ 'telegram token and chat id' => [TelegramNotification::class, 'telegramNotificationSettings', [
+ 'telegram_token' => 'telegram-admin-token',
+ 'telegram_chat_id' => 'telegram-admin-chat',
+ ]],
+ 'pushover credentials' => [PushoverNotification::class, 'pushoverNotificationSettings', [
+ 'pushover_user_key' => 'pushover-admin-user',
+ 'pushover_api_token' => 'pushover-admin-token',
+ ]],
+ 'generic webhook' => [WebhookNotification::class, 'webhookNotificationSettings', [
+ 'webhook_url' => 'https://example.com/admin-webhook',
+ ]],
+]);
diff --git a/tests/Feature/Authorization/SharedResourceAuthorizationTest.php b/tests/Feature/Authorization/SharedResourceAuthorizationTest.php
index 23f134458..57e46c418 100644
--- a/tests/Feature/Authorization/SharedResourceAuthorizationTest.php
+++ b/tests/Feature/Authorization/SharedResourceAuthorizationTest.php
@@ -20,7 +20,7 @@ use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
- InstanceSettings::updateOrCreate(['id' => 0]);
+ InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
$this->team = Team::factory()->create();
@@ -167,6 +167,51 @@ test('admin can update application webhooks', function () {
expect($this->admin->can('update', $this->application))->toBeTrue();
});
+test('member cannot view application webhook secrets', function () {
+ $this->application->update([
+ 'git_repository' => 'coollabsio/coolify',
+ 'git_branch' => 'main',
+ 'manual_webhook_secret_github' => 'github-secret-value',
+ 'manual_webhook_secret_gitlab' => 'gitlab-secret-value',
+ 'manual_webhook_secret_bitbucket' => 'bitbucket-secret-value',
+ 'manual_webhook_secret_gitea' => 'gitea-secret-value',
+ ]);
+
+ $this->actingAs($this->member);
+ session(['currentTeam' => $this->team]);
+
+ Livewire::test(Webhooks::class, ['resource' => $this->application->fresh()])
+ ->assertSet('githubManualWebhookSecret', null)
+ ->assertSet('gitlabManualWebhookSecret', null)
+ ->assertSet('bitbucketManualWebhookSecret', null)
+ ->assertSet('giteaManualWebhookSecret', null)
+ ->assertSee('Hidden (only admins can view)')
+ ->assertDontSee('github-secret-value')
+ ->assertDontSee('gitlab-secret-value')
+ ->assertDontSee('bitbucket-secret-value')
+ ->assertDontSee('gitea-secret-value');
+});
+
+test('admin can view application webhook secrets', function () {
+ $this->application->update([
+ 'git_repository' => 'coollabsio/coolify',
+ 'git_branch' => 'main',
+ 'manual_webhook_secret_github' => 'github-secret-value',
+ 'manual_webhook_secret_gitlab' => 'gitlab-secret-value',
+ 'manual_webhook_secret_bitbucket' => 'bitbucket-secret-value',
+ 'manual_webhook_secret_gitea' => 'gitea-secret-value',
+ ]);
+
+ $this->actingAs($this->admin);
+ session(['currentTeam' => $this->team]);
+
+ Livewire::test(Webhooks::class, ['resource' => $this->application->fresh()])
+ ->assertSet('githubManualWebhookSecret', 'github-secret-value')
+ ->assertSet('gitlabManualWebhookSecret', 'gitlab-secret-value')
+ ->assertSet('bitbucketManualWebhookSecret', 'bitbucket-secret-value')
+ ->assertSet('giteaManualWebhookSecret', 'gitea-secret-value');
+});
+
// --- Resource Limits (policy checks, mount requires full resource data) ---
test('member cannot update application resource limits', function () {
From 8a2373e49f9017d507165e382d65a02099f5505b Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Thu, 4 Jun 2026 11:37:16 +0200
Subject: [PATCH 25/30] fix(ui): standardize permission denial callouts
---
.../livewire/destination/new/docker.blade.php | 2 +-
.../shared/resource-operations.blade.php | 15 +---
.../livewire/project/shared/tags.blade.php | 4 +-
.../server/cloudflare-tunnel.blade.php | 13 ++-
.../views/livewire/server/proxy.blade.php | 2 +-
.../livewire/source/github/create.blade.php | 2 +-
.../views/livewire/storage/create.blade.php | 2 +-
.../livewire/subscription/index.blade.php | 2 +-
.../Feature/ResourcePermissionCalloutTest.php | 82 +++++++++++++++++++
9 files changed, 98 insertions(+), 26 deletions(-)
create mode 100644 tests/Feature/ResourcePermissionCalloutTest.php
diff --git a/resources/views/livewire/destination/new/docker.blade.php b/resources/views/livewire/destination/new/docker.blade.php
index 953c4d42e..2be64d289 100644
--- a/resources/views/livewire/destination/new/docker.blade.php
+++ b/resources/views/livewire/destination/new/docker.blade.php
@@ -18,7 +18,7 @@
@else
-
+
You don't have permission to create new destinations. Please contact your team administrator for access.
@endcan
diff --git a/resources/views/livewire/project/shared/resource-operations.blade.php b/resources/views/livewire/project/shared/resource-operations.blade.php
index 658a7cbd3..0c3c8885c 100644
--- a/resources/views/livewire/project/shared/resource-operations.blade.php
+++ b/resources/views/livewire/project/shared/resource-operations.blade.php
@@ -101,16 +101,10 @@
- @else
-
- You don't have permission to clone resources. Contact your team administrator to request access.
-
- @endcan
- Move Resource
- Transfer this resource between projects and environments.
+ Move Resource
+ Transfer this resource between projects and environments.
- @can('update', $resource)
@if ($projects->count() > 0)
@@ -160,9 +154,8 @@
@endif
@else
-
- You don't have permission to move resources between projects or environments. Contact your team
- administrator to request access.
+
+ You don't have permission to modify this resource. Contact your team administrator for access.
@endcan
diff --git a/resources/views/livewire/project/shared/tags.blade.php b/resources/views/livewire/project/shared/tags.blade.php
index 85208b75b..72b8214a9 100644
--- a/resources/views/livewire/project/shared/tags.blade.php
+++ b/resources/views/livewire/project/shared/tags.blade.php
@@ -10,8 +10,8 @@
Add
@else
-
- You don't have permission to manage tags. Contact your team administrator to request access.
+
+ You don't have permission to manage this resource. Contact your team administrator for access.
@endcan
@if (data_get($this->resource, 'tags') && count(data_get($this->resource, 'tags')) > 0)
diff --git a/resources/views/livewire/server/cloudflare-tunnel.blade.php b/resources/views/livewire/server/cloudflare-tunnel.blade.php
index 2ebac9d41..668fae8f1 100644
--- a/resources/views/livewire/server/cloudflare-tunnel.blade.php
+++ b/resources/views/livewire/server/cloudflare-tunnel.blade.php
@@ -71,6 +71,11 @@
@endif
@if (!$isCloudflareTunnelsEnabled && $server->isFunctional())
+ @cannot('update', $server)
+
+ You don't have permission to configure Cloudflare Tunnel for this server.
+
+ @endcannot
Automated
Continue
- @else
-
- You don't have permission to configure Cloudflare Tunnel for this server.
-
@endcan
@script
@@ -121,10 +122,6 @@
]" confirmationText="I manually configured Cloudflare Tunnel"
confirmationLabel="Please type the confirmation text to confirm that you manually configured Cloudflare Tunnel."
shortConfirmationLabel="Confirmation text" />
- @else
-
- You don't have permission to configure Cloudflare Tunnel for this server.
-
@endcan
@endif
diff --git a/resources/views/livewire/server/proxy.blade.php b/resources/views/livewire/server/proxy.blade.php
index 127f583d6..143149abb 100644
--- a/resources/views/livewire/server/proxy.blade.php
+++ b/resources/views/livewire/server/proxy.blade.php
@@ -174,7 +174,7 @@
--}}
@else
-
+
You don't have permission to configure proxy settings for this server.
@endcan
diff --git a/resources/views/livewire/source/github/create.blade.php b/resources/views/livewire/source/github/create.blade.php
index 9d5189b43..eedb8809c 100644
--- a/resources/views/livewire/source/github/create.blade.php
+++ b/resources/views/livewire/source/github/create.blade.php
@@ -63,7 +63,7 @@
@else
-
+
You don't have permission to create new GitHub Apps. Please contact your team administrator for access.
@endcan
\ No newline at end of file
diff --git a/resources/views/livewire/storage/create.blade.php b/resources/views/livewire/storage/create.blade.php
index 78ac717a1..32aeda095 100644
--- a/resources/views/livewire/storage/create.blade.php
+++ b/resources/views/livewire/storage/create.blade.php
@@ -24,7 +24,7 @@
@else
-
+
You don't have permission to create new S3 storage configurations. Please contact your team administrator for
access.
diff --git a/resources/views/livewire/subscription/index.blade.php b/resources/views/livewire/subscription/index.blade.php
index c78af77f9..dc18f54d4 100644
--- a/resources/views/livewire/subscription/index.blade.php
+++ b/resources/views/livewire/subscription/index.blade.php
@@ -54,7 +54,7 @@
Subscription
-
+
You are not an admin so you cannot manage your Team's subscription. If this does not make sense, please
contact us .
diff --git a/tests/Feature/ResourcePermissionCalloutTest.php b/tests/Feature/ResourcePermissionCalloutTest.php
new file mode 100644
index 000000000..36e8100c7
--- /dev/null
+++ b/tests/Feature/ResourcePermissionCalloutTest.php
@@ -0,0 +1,82 @@
+create();
+ $team = Team::factory()->create();
+ $user->teams()->attach($team, ['role' => $role]);
+
+ $server = Server::factory()->create(['team_id' => $team->id]);
+ $destination = $server->standaloneDockers()->firstOrFail();
+ $project = Project::factory()->create(['team_id' => $team->id]);
+ $environment = Environment::factory()->create(['project_id' => $project->id]);
+
+ test()->actingAs($user);
+ session(['currentTeam' => $team]);
+
+ return Application::factory()->create([
+ 'environment_id' => $environment->id,
+ 'destination_id' => $destination->id,
+ 'destination_type' => $destination->getMorphClass(),
+ ]);
+}
+
+it('shows one red insufficient permissions callout for resource operations when update is denied', function () {
+ $application = resourcePermissionCalloutApplicationFor('member');
+
+ $component = Livewire::test(ResourceOperations::class, ['resource' => $application])
+ ->assertSee('Insufficient Permissions')
+ ->assertSee('permission to modify this resource')
+ ->assertSee('team administrator for access')
+ ->assertDontSee('Access Restricted')
+ ->assertDontSee("You don't have permission to clone resources")
+ ->assertDontSee("You don't have permission to move resources");
+
+ expect(substr_count($component->html(), 'Insufficient Permissions'))->toBe(1)
+ ->and($component->html())->toContain('bg-red-50')
+ ->and($component->html())->not->toContain('bg-warning-50');
+});
+
+it('shows the red insufficient permissions callout for tags when update is denied', function () {
+ $application = resourcePermissionCalloutApplicationFor('member');
+
+ $component = Livewire::test(Tags::class, ['resource' => $application])
+ ->assertSee('Insufficient Permissions')
+ ->assertSee('permission to manage this resource')
+ ->assertSee('team administrator for access')
+ ->assertDontSee('Access Restricted')
+ ->assertDontSee("You don't have permission to manage tags");
+
+ expect(substr_count($component->html(), 'Insufficient Permissions'))->toBe(1)
+ ->and($component->html())->toContain('bg-red-50')
+ ->and($component->html())->not->toContain('bg-warning-50');
+});
+
+it('does not use yellow permission callouts in blade views', function () {
+ $offendingFiles = collect(new RecursiveIteratorIterator(new RecursiveDirectoryIterator(resource_path('views'))))
+ ->filter(fn (SplFileInfo $file) => $file->isFile() && $file->getExtension() === 'php')
+ ->filter(function (SplFileInfo $file) {
+ $contents = file_get_contents($file->getPathname());
+
+ return str_contains($contents, 'type="warning" title="Permission Required"')
+ || str_contains($contents, 'title="Access Restricted"');
+ })
+ ->map(fn (SplFileInfo $file) => str_replace(base_path().'/', '', $file->getPathname()))
+ ->values()
+ ->all();
+
+ expect($offendingFiles)->toBeEmpty();
+});
From 46a9578d69f9412f0276ad9ab5dfd8b987f34c94 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Mon, 15 Jun 2026 12:06:54 +0200
Subject: [PATCH 26/30] test: align mobile application actions expectations
---
tests/Feature/ResponsiveCheckboxLayoutTest.php | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/tests/Feature/ResponsiveCheckboxLayoutTest.php b/tests/Feature/ResponsiveCheckboxLayoutTest.php
index a044b79a6..9cbce97c9 100644
--- a/tests/Feature/ResponsiveCheckboxLayoutTest.php
+++ b/tests/Feature/ResponsiveCheckboxLayoutTest.php
@@ -92,11 +92,11 @@ it('renders responsive checkbox classes on the application configuration page',
$response->assertSee('', false);
$response->assertSee('', false);
$response->assertSee('', false);
- $response->assertSee('', false);
+ $response->assertSee('application-mobile-actions');
+ $response->assertDontSee('', false);
$response->assertSee('value="navigate|application|', false);
$response->assertSee('value="navigate|configuration|', false);
$response->assertSee('window.Livewire?.navigate ? window.Livewire.navigate(url) : window.location.href = url', false);
- $response->assertSee('value="action:force-deploy"', false);
$response->assertSee('application-mobile-stop-trigger');
$response->assertSee('application-mobile-deploy-trigger');
$response->assertSee('application-mobile-restart-trigger');
From d2deaa8363e0e38956670732b864c64fc749b8b7 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Mon, 15 Jun 2026 12:31:30 +0200
Subject: [PATCH 27/30] fix(auth): restrict Sentinel access and register S3
policy
---
app/Livewire/Server/Sentinel/Logs.php | 7 +++
app/Livewire/Server/Sentinel/Show.php | 7 +++
app/Policies/ServerPolicy.php | 8 +++
app/Providers/AuthServiceProvider.php | 5 ++
.../server/sidebar-sentinel.blade.php | 18 ++++---
.../views/livewire/server/navbar.blade.php | 2 +-
.../Authorization/ServerAuthorizationTest.php | 43 ++++++++++++++-
.../S3StoragePolicyRegistrationTest.php | 54 +++++++++++++++++++
tests/Unit/Policies/ServerPolicyTest.php | 12 +++++
9 files changed, 146 insertions(+), 10 deletions(-)
create mode 100644 tests/Feature/S3StoragePolicyRegistrationTest.php
diff --git a/app/Livewire/Server/Sentinel/Logs.php b/app/Livewire/Server/Sentinel/Logs.php
index 6619e101e..1190cd59a 100644
--- a/app/Livewire/Server/Sentinel/Logs.php
+++ b/app/Livewire/Server/Sentinel/Logs.php
@@ -3,11 +3,14 @@
namespace App\Livewire\Server\Sentinel;
use App\Models\Server;
+use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\View\View;
use Livewire\Component;
class Logs extends Component
{
+ use AuthorizesRequests;
+
public ?Server $server = null;
public array $parameters = [];
@@ -19,7 +22,11 @@ class Logs extends Component
$this->server = Server::ownedByCurrentTeam()->whereUuid(request()->server_uuid)->firstOrFail();
} catch (\Throwable $e) {
handleError($e, $this);
+
+ return;
}
+
+ $this->authorize('viewSentinel', $this->server);
}
public function render(): View
diff --git a/app/Livewire/Server/Sentinel/Show.php b/app/Livewire/Server/Sentinel/Show.php
index 7070a09ce..fc30994d6 100644
--- a/app/Livewire/Server/Sentinel/Show.php
+++ b/app/Livewire/Server/Sentinel/Show.php
@@ -3,11 +3,14 @@
namespace App\Livewire\Server\Sentinel;
use App\Models\Server;
+use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\View\View;
use Livewire\Component;
class Show extends Component
{
+ use AuthorizesRequests;
+
public ?Server $server = null;
public array $parameters = [];
@@ -19,7 +22,11 @@ class Show extends Component
$this->server = Server::ownedByCurrentTeam()->whereUuid(request()->server_uuid)->firstOrFail();
} catch (\Throwable $e) {
handleError($e, $this);
+
+ return;
}
+
+ $this->authorize('viewSentinel', $this->server);
}
public function render(): View
diff --git a/app/Policies/ServerPolicy.php b/app/Policies/ServerPolicy.php
index 32436987c..c1369ce67 100644
--- a/app/Policies/ServerPolicy.php
+++ b/app/Policies/ServerPolicy.php
@@ -79,6 +79,14 @@ class ServerPolicy
return $user->isAdminOfTeam($server->team_id);
}
+ /**
+ * Determine whether the user can view Sentinel configuration and logs.
+ */
+ public function viewSentinel(User $user, Server $server): bool
+ {
+ return $user->isAdminOfTeam($server->team_id);
+ }
+
/**
* Determine whether the user can manage CA certificates.
*/
diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php
index 1ae539174..5c1a79cf7 100644
--- a/app/Providers/AuthServiceProvider.php
+++ b/app/Providers/AuthServiceProvider.php
@@ -17,6 +17,7 @@ use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\PushoverNotificationSettings;
+use App\Models\S3Storage;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
@@ -51,6 +52,7 @@ use App\Policies\NotificationPolicy;
use App\Policies\PrivateKeyPolicy;
use App\Policies\ProjectPolicy;
use App\Policies\ResourceCreatePolicy;
+use App\Policies\S3StoragePolicy;
use App\Policies\ServerPolicy;
use App\Policies\ServiceApplicationPolicy;
use App\Policies\ServiceDatabasePolicy;
@@ -109,6 +111,9 @@ class AuthServiceProvider extends ServiceProvider
// Instance settings policy
InstanceSettings::class => InstanceSettingsPolicy::class,
+ // S3 storage policy
+ S3Storage::class => S3StoragePolicy::class,
+
// Team policy
Team::class => TeamPolicy::class,
diff --git a/resources/views/components/server/sidebar-sentinel.blade.php b/resources/views/components/server/sidebar-sentinel.blade.php
index 8125fe22c..421a1ec9d 100644
--- a/resources/views/components/server/sidebar-sentinel.blade.php
+++ b/resources/views/components/server/sidebar-sentinel.blade.php
@@ -1,10 +1,12 @@
diff --git a/resources/views/livewire/server/navbar.blade.php b/resources/views/livewire/server/navbar.blade.php
index b49c04f5f..463ecef3b 100644
--- a/resources/views/livewire/server/navbar.blade.php
+++ b/resources/views/livewire/server/navbar.blade.php
@@ -78,7 +78,7 @@
@endif
@endif
- @if ($server->isFunctional() && !$server->isSwarm() && !$server->settings->is_build_server)
+ @if ($server->isFunctional() && !$server->isSwarm() && !$server->settings->is_build_server && auth()->user()?->can('viewSentinel', $server))
diff --git a/tests/Feature/Authorization/ServerAuthorizationTest.php b/tests/Feature/Authorization/ServerAuthorizationTest.php
index 80b7aed40..ffcb4a1c6 100644
--- a/tests/Feature/Authorization/ServerAuthorizationTest.php
+++ b/tests/Feature/Authorization/ServerAuthorizationTest.php
@@ -1,5 +1,6 @@
0]);
+ $this->withoutMiddleware(PreventRequestsDuringMaintenance::class);
+
+ InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
$this->team = Team::factory()->create();
@@ -217,6 +220,44 @@ test('member can access server page', function () {
$this->get("/server/{$this->server->uuid}")->assertSuccessful();
});
+test('admin can access sentinel configuration and logs pages', function () {
+ $this->actingAs($this->admin);
+ session(['currentTeam' => $this->team]);
+
+ $this->get(route('server.sentinel', ['server_uuid' => $this->server->uuid]))
+ ->assertSuccessful();
+
+ $this->get(route('server.sentinel.logs', ['server_uuid' => $this->server->uuid]))
+ ->assertSuccessful();
+});
+
+test('member cannot access sentinel configuration and logs pages', function () {
+ $this->actingAs($this->member);
+ session(['currentTeam' => $this->team]);
+
+ $this->get(route('server.sentinel', ['server_uuid' => $this->server->uuid]))
+ ->assertForbidden();
+
+ $this->get(route('server.sentinel.logs', ['server_uuid' => $this->server->uuid]))
+ ->assertForbidden();
+});
+
+test('sentinel navigation is only visible to team admins', function () {
+ $this->actingAs($this->member);
+ session(['currentTeam' => $this->team]);
+
+ $this->get("/server/{$this->server->uuid}")
+ ->assertSuccessful()
+ ->assertDontSee(route('server.sentinel', ['server_uuid' => $this->server->uuid]));
+
+ $this->actingAs($this->admin);
+ session(['currentTeam' => $this->team]);
+
+ $this->get("/server/{$this->server->uuid}")
+ ->assertSuccessful()
+ ->assertSee(route('server.sentinel', ['server_uuid' => $this->server->uuid]));
+});
+
test('unauthenticated user cannot access server page', function () {
$this->get("/server/{$this->server->uuid}")->assertRedirect('/login');
});
diff --git a/tests/Feature/S3StoragePolicyRegistrationTest.php b/tests/Feature/S3StoragePolicyRegistrationTest.php
new file mode 100644
index 000000000..f75c58a85
--- /dev/null
+++ b/tests/Feature/S3StoragePolicyRegistrationTest.php
@@ -0,0 +1,54 @@
+toBeInstanceOf(S3StoragePolicy::class);
+});
+
+test('s3 storage create ability is enforced through the registered policy', function () {
+ $team = Team::factory()->create();
+
+ $admin = User::factory()->create();
+ $admin->teams()->attach($team, ['role' => 'admin']);
+
+ $member = User::factory()->create();
+ $member->teams()->attach($team, ['role' => 'member']);
+
+ $this->actingAs($admin);
+ session(['currentTeam' => $team]);
+
+ expect($admin->can('create', S3Storage::class))->toBeTrue()
+ ->and($member->can('create', S3Storage::class))->toBeFalse();
+});
+
+test('s3 storage validate connection ability is enforced through the registered policy', function () {
+ $team = Team::factory()->create();
+
+ $admin = User::factory()->create();
+ $admin->teams()->attach($team, ['role' => 'admin']);
+
+ $member = User::factory()->create();
+ $member->teams()->attach($team, ['role' => 'member']);
+
+ $storage = S3Storage::create([
+ 'team_id' => $team->id,
+ 'name' => 'Backups',
+ 'description' => 'Team backup storage',
+ 'region' => 'us-east-1',
+ 'key' => 'access-key',
+ 'secret' => 'secret-key',
+ 'bucket' => 'coolify-backups',
+ 'endpoint' => 'https://s3.us-east-1.amazonaws.com',
+ ]);
+
+ expect($admin->can('validateConnection', $storage))->toBeTrue()
+ ->and($member->can('validateConnection', $storage))->toBeFalse();
+});
diff --git a/tests/Unit/Policies/ServerPolicyTest.php b/tests/Unit/Policies/ServerPolicyTest.php
index afa64d090..d9084694a 100644
--- a/tests/Unit/Policies/ServerPolicyTest.php
+++ b/tests/Unit/Policies/ServerPolicyTest.php
@@ -152,6 +152,18 @@ it('allows team admin to manage sentinel, ca certificate, and view security', fu
$policy = new ServerPolicy;
expect($policy->manageSentinel($user, $server))->toBeTrue();
+ expect($policy->viewSentinel($user, $server))->toBeTrue();
expect($policy->manageCaCertificate($user, $server))->toBeTrue();
expect($policy->viewSecurity($user, $server))->toBeTrue();
});
+
+it('denies team member to view sentinel', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $server = Mockery::mock(Server::class)->makePartial();
+ $server->team_id = 1;
+
+ $policy = new ServerPolicy;
+ expect($policy->viewSentinel($user, $server))->toBeFalse();
+});
From 371eb1e38c3fdbb4e2b1b47a6d383a4da3eedcd3 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Mon, 15 Jun 2026 12:56:19 +0200
Subject: [PATCH 28/30] fix(upgrade): hide upgrade labels in collapsed sidebar
---
resources/css/utilities.css | 4 ++++
resources/views/livewire/upgrade.blade.php | 4 ++--
tests/Feature/UpgradeComponentTest.php | 26 +++++++++++++++++-----
3 files changed, 26 insertions(+), 8 deletions(-)
diff --git a/resources/css/utilities.css b/resources/css/utilities.css
index 170e6ac16..e789669e6 100644
--- a/resources/css/utilities.css
+++ b/resources/css/utilities.css
@@ -355,4 +355,8 @@
gap: 0;
margin-inline: auto;
}
+
+ .sidebar-collapsed .sidebar-collapsed-label {
+ display: none;
+ }
}
diff --git a/resources/views/livewire/upgrade.blade.php b/resources/views/livewire/upgrade.blade.php
index be2a7618a..2b986a33e 100644
--- a/resources/views/livewire/upgrade.blade.php
+++ b/resources/views/livewire/upgrade.blade.php
@@ -13,7 +13,7 @@
-
+
'4.0.0-beta.998']);
- InstanceSettings::create([
+ InstanceSettings::forceCreate([
'id' => 0,
'new_version_available' => true,
]);
@@ -34,8 +34,22 @@ it('initializes latest version during mount from cached versions data', function
->assertSee('4.0.0-beta.999');
});
+it('uses sidebar state css instead of nested alpine state for upgrade labels', function () {
+ $upgradeView = file_get_contents(resource_path('views/livewire/upgrade.blade.php'));
+ $utilitiesCss = file_get_contents(resource_path('css/utilities.css'));
+
+ expect($upgradeView)
+ ->toContain('class="text-left menu-item-label sidebar-collapsed-label"')
+ ->toContain('>In progress')
+ ->toContain('>Upgrade')
+ ->not->toContain(':class="collapsed && \'lg:hidden\'"')
+ ->and($utilitiesCss)
+ ->toContain('.sidebar-collapsed .sidebar-collapsed-label')
+ ->toContain('display: none;');
+});
+
it('falls back to 0.0.0 during mount when cached versions data is unavailable', function () {
- InstanceSettings::create([
+ InstanceSettings::forceCreate([
'id' => 0,
'new_version_available' => false,
]);
@@ -51,7 +65,7 @@ it('falls back to 0.0.0 during mount when cached versions data is unavailable',
it('clears stale upgrade availability when current version already matches latest version', function () {
config(['constants.coolify.version' => '4.0.0-beta.999']);
- InstanceSettings::create([
+ InstanceSettings::forceCreate([
'id' => 0,
'new_version_available' => true,
]);
@@ -71,12 +85,12 @@ it('clears stale upgrade availability when current version already matches lates
->assertSet('latestVersion', '4.0.0-beta.999')
->assertSet('isUpgradeAvailable', false);
- expect(InstanceSettings::findOrFail(0)->new_version_available)->toBeFalse();
+ expect((bool) InstanceSettings::findOrFail(0)->new_version_available)->toBeFalse();
});
it('clears stale upgrade availability when current version is newer than cached latest version', function () {
config(['constants.coolify.version' => '4.0.0-beta.1000']);
- InstanceSettings::create([
+ InstanceSettings::forceCreate([
'id' => 0,
'new_version_available' => true,
]);
@@ -96,5 +110,5 @@ it('clears stale upgrade availability when current version is newer than cached
->assertSet('latestVersion', '4.0.0-beta.999')
->assertSet('isUpgradeAvailable', false);
- expect(InstanceSettings::findOrFail(0)->new_version_available)->toBeFalse();
+ expect((bool) InstanceSettings::findOrFail(0)->new_version_available)->toBeFalse();
});
From dfd4d7e8022218f47f5cba6de4040a5fba68ce74 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Mon, 15 Jun 2026 13:17:53 +0200
Subject: [PATCH 29/30] feat(resource-details): make copy fields visible and
accessible
---
.../components/forms/copy-button.blade.php | 33 +++++++------
.../project/shared/resource-details.blade.php | 4 +-
.../Feature/ResourceDetailsVisibilityTest.php | 46 +++++++++++++++++++
3 files changed, 65 insertions(+), 18 deletions(-)
create mode 100644 tests/Feature/ResourceDetailsVisibilityTest.php
diff --git a/resources/views/components/forms/copy-button.blade.php b/resources/views/components/forms/copy-button.blade.php
index eb3f3d8a4..61233b2ca 100644
--- a/resources/views/components/forms/copy-button.blade.php
+++ b/resources/views/components/forms/copy-button.blade.php
@@ -2,24 +2,27 @@
@if ($label)
-
{{ $label }}
+
{{ $label }}
@endif
-
-
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">
-
-
-
-
-
-
-
+
copied = false, 1000)"
+ class="absolute right-2 top-1/2 -translate-y-1/2 rounded-sm p-1.5 text-neutral-500 transition-colors hover:text-neutral-700 focus-visible:ring-2 focus-visible:ring-coollabs focus-visible:ring-offset-2 dark:text-neutral-400 dark:hover:text-white dark:focus-visible:ring-warning dark:focus-visible:ring-offset-base"
+ title="Copy to clipboard"
+ aria-label="Copy to clipboard">
+
+
+
+
+
+
+
diff --git a/resources/views/livewire/project/shared/resource-details.blade.php b/resources/views/livewire/project/shared/resource-details.blade.php
index 3be82da12..62b04f2a0 100644
--- a/resources/views/livewire/project/shared/resource-details.blade.php
+++ b/resources/views/livewire/project/shared/resource-details.blade.php
@@ -1,6 +1,4 @@
-
-
Identifiers for this resource. Read-only
-
+
Resource
diff --git a/tests/Feature/ResourceDetailsVisibilityTest.php b/tests/Feature/ResourceDetailsVisibilityTest.php
new file mode 100644
index 000000000..762683447
--- /dev/null
+++ b/tests/Feature/ResourceDetailsVisibilityTest.php
@@ -0,0 +1,46 @@
+put('default', new MessageBag);
+ view()->share('errors', $errors);
+});
+
+it('keeps the resource details helper text visible below the modal header', function () {
+ $html = view('livewire.project.shared.resource-details', [
+ 'resource' => (object) [
+ 'name' => 'Crash Loop Example',
+ 'uuid' => 'crashloop',
+ ],
+ 'environment_uuid' => null,
+ 'environment_name' => null,
+ 'project_uuid' => null,
+ 'project_name' => null,
+ 'server_uuid' => null,
+ 'server_name' => null,
+ 'stack_applications' => [],
+ 'stack_databases' => [],
+ ])->render();
+
+ expect($html)
+ ->toContain('Identifiers for this resource. Read-only')
+ ->toContain('pt-1')
+ ->not->toContain('-mt-4');
+});
+
+it('renders copy fields as visible readonly controls with an accessible copy action', function () {
+ $html = Blade::render(' ');
+
+ expect($html)
+ ->toContain('label class="flex gap-1 items-center mb-1 text-sm font-medium text-black dark:text-white"')
+ ->toContain('readonly')
+ ->toContain('class="input pr-11 bg-white dark:bg-coolgray-100 dark:read-only:bg-coolgray-100 dark:read-only:text-white"')
+ ->toContain('aria-label="Copy to clipboard"')
+ ->toContain('title="Copy to clipboard"')
+ ->toContain('rounded-sm p-1.5 text-neutral-500 transition-colors hover:text-neutral-700 focus-visible:ring-2 focus-visible:ring-coollabs focus-visible:ring-offset-2 dark:text-neutral-400 dark:hover:text-white dark:focus-visible:ring-warning dark:focus-visible:ring-offset-base')
+ ->toContain('class="w-5 h-5 text-green-500"');
+});
From 96ea892748c6938171b2cbae99159a0e289c05a9 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Mon, 15 Jun 2026 13:25:31 +0200
Subject: [PATCH 30/30] fix(sidebar): remove theme switcher from sidebar navbar
---
resources/views/components/navbar.blade.php | 49 -------------------
.../SidebarNavigationPreferencesTest.php | 12 ++---
2 files changed, 5 insertions(+), 56 deletions(-)
diff --git a/resources/views/components/navbar.blade.php b/resources/views/components/navbar.blade.php
index c2ca28a2d..924147e4b 100644
--- a/resources/views/components/navbar.blade.php
+++ b/resources/views/components/navbar.blade.php
@@ -44,16 +44,6 @@
this.queryTheme();
this.checkZoom();
},
- setTheme(type) {
- this.theme = type;
- localStorage.setItem('theme', type);
- this.queryTheme();
- },
- cycleTheme() {
- const themes = ['light', 'system', 'dark'];
- const currentIndex = themes.indexOf(this.theme || localStorage.getItem('theme') || 'dark');
- this.setTheme(themes[(currentIndex + 1) % themes.length]);
- },
queryTheme() {
const darkModePreference = window.matchMedia('(prefers-color-scheme: dark)').matches;
const userSettings = localStorage.getItem('theme') || 'dark';
@@ -375,45 +365,6 @@
-
-
-
-
@if (isInstanceAdmin() && !isCloud())
@persist('upgrade')
diff --git a/tests/Feature/SidebarNavigationPreferencesTest.php b/tests/Feature/SidebarNavigationPreferencesTest.php
index f27eccd99..c341d9529 100644
--- a/tests/Feature/SidebarNavigationPreferencesTest.php
+++ b/tests/Feature/SidebarNavigationPreferencesTest.php
@@ -2,18 +2,16 @@
use App\Livewire\SettingsDropdown;
-it('keeps changelog and the theme switcher in the sidebar without the old preferences trigger', function () {
+it('keeps changelog in the sidebar without a dedicated theme switcher', function () {
$navbarView = file_get_contents(resource_path('views/components/navbar.blade.php'));
expect($navbarView)
->toContain(' ')
->not->toContain(' ')
- ->toContain('aria-label="Theme switcher"')
- ->toContain('aria-label="Use light theme"')
- ->toContain('aria-label="Use system theme"')
- ->toContain('aria-label="Use dark theme"')
- ->toContain('cycleTheme()')
- ->toContain("const themes = ['light', 'system', 'dark'];")
+ ->not->toContain('')
+ ->not->toContain('Click to change theme.')
+ ->not->toContain('cycleTheme()')
+ ->toContain('this.queryTheme();')
->toContain('pl-2 pr-3 items-start gap-3')
->toContain('class="flex min-w-0 flex-1 flex-col"')
->toContain('class="min-w-0 flex-1"')