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.
This commit is contained in:
Andras Bacsai 2026-06-04 11:03:06 +02:00
parent 9dca7ca351
commit 062ad57740
83 changed files with 824 additions and 278 deletions

View file

@ -18,6 +18,7 @@
use Illuminate\Console\Command;
use Illuminate\Mail\Message;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Str;
use Mail;
use function Laravel\Prompts\confirm;

View file

@ -30,7 +30,6 @@
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 @@ private function create_application(Request $request, $type)
$application->isConfigurationChanged(true);
if ($instantDeploy) {
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,
@ -1436,7 +1435,7 @@ private function create_application(Request $request, $type)
$application->isConfigurationChanged(true);
if ($instantDeploy) {
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,
@ -1645,7 +1644,7 @@ private function create_application(Request $request, $type)
$application->isConfigurationChanged(true);
if ($instantDeploy) {
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,
@ -1691,7 +1690,7 @@ private function create_application(Request $request, $type)
], 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 @@ private function create_application(Request $request, $type)
$application->isConfigurationChanged(true);
if ($instantDeploy) {
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,
@ -1809,7 +1808,7 @@ private function create_application(Request $request, $type)
], 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 @@ private function create_application(Request $request, $type)
$application->isConfigurationChanged(true);
if ($instantDeploy) {
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,
@ -2682,7 +2681,7 @@ public function update_by_uuid(Request $request)
]);
if ($instantDeploy) {
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,
@ -3589,7 +3588,7 @@ public function action_deploy(Request $request)
$this->authorize('deploy', $application);
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,
@ -3787,7 +3786,7 @@ public function action_restart(Request $request)
$this->authorize('deploy', $application);
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,

View file

@ -549,9 +549,18 @@ public function validateToken(Request $request)
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'],

View file

@ -15,7 +15,6 @@
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 @@ public function deploy_resource($resource, bool $force = false, int $pr = 0, ?st
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,

View file

@ -116,6 +116,7 @@ public function locations(Request $request)
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 @@ public function serverTypes(Request $request)
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 @@ public function images(Request $request)
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 @@ public function sshKeys(Request $request)
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 @@ public function createServer(Request $request)
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();

View file

@ -97,12 +97,12 @@ public function push(Request $request)
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);
}

View file

@ -10,7 +10,6 @@
use App\Models\ApplicationPreview;
use Exception;
use Illuminate\Http\Request;
use Visus\Cuid2\Cuid2;
class Bitbucket extends Controller
{
@ -141,7 +140,7 @@ public function manual(Request $request)
continue;
}
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,
deployment_uuid: $deployment_uuid,
@ -192,7 +191,7 @@ public function manual(Request $request)
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') {

View file

@ -11,7 +11,6 @@
use Exception;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Visus\Cuid2\Cuid2;
class Gitea extends Controller
{
@ -127,7 +126,7 @@ public function manual(Request $request)
continue;
}
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,
deployment_uuid: $deployment_uuid,
@ -194,7 +193,7 @@ public function manual(Request $request)
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') {

View file

@ -17,7 +17,6 @@
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 @@ public function manual(Request $request)
continue;
}
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,
deployment_uuid: $deployment_uuid,
@ -362,7 +361,7 @@ public function normal(Request $request)
continue;
}
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,
deployment_uuid: $deployment_uuid,

View file

@ -11,7 +11,6 @@
use Exception;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Visus\Cuid2\Cuid2;
class Gitlab extends Controller
{
@ -168,7 +167,7 @@ public function manual(Request $request)
continue;
}
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,
deployment_uuid: $deployment_uuid,
@ -236,7 +235,7 @@ public function manual(Request $request)
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') {

View file

@ -37,7 +37,6 @@
use Spatie\Url\Url;
use Symfony\Component\Yaml\Yaml;
use Throwable;
use Visus\Cuid2\Cuid2;
class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
{
@ -2207,7 +2206,7 @@ private function deploy_to_additional_destinations()
continue;
}
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
queue_application_deployment(
deployment_uuid: $deployment_uuid,
application: $this->application,

View file

@ -27,7 +27,6 @@
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Throwable;
use Visus\Cuid2\Cuid2;
class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
{
@ -309,7 +308,7 @@ public function handle(): void
// 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) {

View file

@ -14,7 +14,6 @@
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 @@ private function handleOpenAction(Application $application, ?GithubApp $githubAp
}
// Queue the deployment
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
queue_application_deployment(
application: $application,
pull_request_id: $this->pullRequestId,

View file

@ -13,7 +13,6 @@
use Illuminate\Support\Collection;
use Livewire\Attributes\Url;
use Livewire\Component;
use Visus\Cuid2\Cuid2;
class Index extends Component
{
@ -470,7 +469,7 @@ public function createNewProject()
$this->createdProject = Project::create([
'name' => 'My first project',
'team_id' => currentTeam()->id,
'uuid' => (string) new Cuid2,
'uuid' => new_public_id(),
]);
$this->currentState = 'create-resource';
}

View file

@ -9,7 +9,6 @@
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 @@ public function updatedServerId(): void
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();
}

View file

@ -4,7 +4,6 @@
// use Livewire\Component;
use Illuminate\View\Component;
use Visus\Cuid2\Cuid2;
class MonacoEditor extends Component
{
@ -40,7 +39,7 @@ public function __construct(
public function render()
{
if (is_null($this->id)) {
$this->id = new Cuid2;
$this->id = new_public_id();
}
if (is_null($this->name)) {

View file

@ -6,7 +6,6 @@
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 @@ public function submit()
'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();

View file

@ -13,7 +13,6 @@
use Livewire\Component;
use Livewire\Features\SupportEvents\Event;
use Spatie\Url\Url;
use Visus\Cuid2\Cuid2;
class General extends Component
{
@ -549,7 +548,7 @@ public function generateDomain(string $serviceName)
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;

View file

@ -7,7 +7,6 @@
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 @@ public function deploy(bool $force_rebuild = false)
protected function setDeploymentUuid()
{
$this->deploymentUuid = new Cuid2;
$this->deploymentUuid = new_public_id();
$this->parameters['deployment_uuid'] = $this->deploymentUuid;
}

View file

@ -9,7 +9,6 @@
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 @@ public function deploy(int $pull_request_id, ?string $pull_request_html_url = nu
protected function setDeploymentUuid()
{
$this->deployment_uuid = new Cuid2;
$this->deployment_uuid = new_public_id();
$this->parameters['deployment_uuid'] = $this->deployment_uuid;
}

View file

@ -6,7 +6,6 @@
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 @@ public function generate()
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 @@ public function generate()
$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);

View file

@ -6,7 +6,6 @@
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 @@ public function rollbackImage($commit)
$commit = validateGitRef($commit, 'rollback commit');
$deployment_uuid = new Cuid2;
$deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $this->application,

View file

@ -13,7 +13,6 @@
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 @@ public function mount($project_uuid)
->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 @@ public function clone(string $type)
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 @@ public function clone(string $type)
$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 @@ public function clone(string $type)
}
foreach ($databases as $database) {
$uuid = (string) new Cuid2;
$uuid = new_public_id();
$newDatabase = $database->replicate([
'id',
'created_at',
@ -229,7 +228,7 @@ public function clone(string $type)
$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 @@ public function clone(string $type)
}
foreach ($services as $service) {
$uuid = (string) new Cuid2;
$uuid = new_public_id();
$newService = $service->replicate([
'id',
'created_at',
@ -282,7 +281,7 @@ public function clone(string $type)
'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 @@ public function clone(string $type)
$scheduledBackups = $database->scheduledBackups()->get();
foreach ($scheduledBackups as $backup) {
$uuid = (string) new Cuid2;
$uuid = new_public_id();
$newBackup = $backup->replicate([
'id',
'created_at',

View file

@ -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 @@ public function mount()
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 @@ public function submit()
public function delete()
{
try {
$this->authorize('update', $this->database);
$this->dispatch('delete_init_script', $this->script);
} catch (Exception $e) {
return handleError($e, $this);

View file

@ -7,7 +7,6 @@
use App\Services\DockerImageParser;
use App\Support\ValidationPatterns;
use Livewire\Component;
use Visus\Cuid2\Cuid2;
class DockerImage extends Component
{
@ -130,7 +129,7 @@ public function submit()
$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',

View file

@ -4,7 +4,6 @@
use App\Models\Project;
use Livewire\Component;
use Visus\Cuid2\Cuid2;
class EmptyProject extends Component
{
@ -13,7 +12,7 @@ public function createEmptyProject()
$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]);

View file

@ -6,7 +6,6 @@
use App\Models\GithubApp;
use App\Models\Project;
use Livewire\Component;
use Visus\Cuid2\Cuid2;
class SimpleDockerfile extends Component
{
@ -48,7 +47,7 @@ public function submit()
$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',

View file

@ -8,7 +8,6 @@
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');

View file

@ -10,7 +10,6 @@
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 @@ public function redeploy(int $network_id, int $server_id)
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(

View file

@ -6,12 +6,15 @@
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 @@ public function mount()
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 @@ public function mount()
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 @@ public function mount()
} 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 @@ public function mount()
} 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 @@ public function updatedSelectedContainer()
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 @@ public function connectToContainer()
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 @@ public function connectToContainer()
throw new \RuntimeException('Invalid server configuration.');
}
$this->authorize('view', $server);
if ($server->isForceDisabled()) {
throw new \RuntimeException('Server is disabled.');
}

View file

@ -22,7 +22,6 @@
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 @@ public function cloneTo($destination_id)
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 @@ public function cloneTo($destination_id)
$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 @@ public function cloneTo($destination_id)
$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 @@ public function cloneTo($destination_id)
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 @@ public function cloneTo($destination_id)
'created_at',
'updated_at',
])->fill([
'uuid' => (string) new Cuid2,
'uuid' => new_public_id(),
'service_id' => $new_resource->id,
'team_id' => currentTeam()->id,
]);

View file

@ -5,11 +5,14 @@
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 @@ private function checkShellAvailability(Server $server, string $container): bool
#[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.');
}

View file

@ -7,7 +7,6 @@
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 @@ public function submit()
$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', [

View file

@ -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 @@ protected function rules(): array
{
return [
'name' => 'required|string|max:255',
'script' => ['required', 'string', new \App\Rules\ValidCloudInitYaml],
'script' => ['required', 'string', new ValidCloudInitYaml],
];
}
@ -68,17 +69,29 @@ public function save()
'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.';
}

View file

@ -36,9 +36,16 @@ public function deleteScript(int $scriptId)
$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);

View file

@ -54,7 +54,6 @@ private function validateToken(string $provider, string $token): bool
$response = Http::withHeaders([
'Authorization' => 'Bearer '.$token,
])->timeout(10)->get('https://api.hetzner.cloud/v1/servers');
ray($response);
return $response->successful();
}
@ -85,6 +84,13 @@ public function addToken()
'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

View file

@ -4,6 +4,7 @@
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 @@ public function validateToken(int $tokenId)
} 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 @@ public function validateToken(int $tokenId)
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 @@ private function validateHetznerToken(string $token): bool
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 @@ public function deleteToken(int $tokenId)
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);

View file

@ -5,6 +5,7 @@
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 @@ public function setCloudProviderToken($tokenId)
$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 @@ private function validateTokenForServer(CloudProviderToken $token): array
{
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 @@ private function validateTokenForServer(CloudProviderToken $token): array
// 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 @@ public function validateToken()
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 @@ public function validateToken()
} 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);
}

View file

@ -4,16 +4,21 @@
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 @@ public function mount(): void
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 @@ public function disableS3(int $backupId): void
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 @@ public function moveBackup(int $backupId): void
return;
}
$this->authorize('update', $newStorage);
$backup->update(['s3_storage_id' => $newStorage->id]);
unset($this->selectedStorages[$backupId]);

View file

@ -10,7 +10,6 @@
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Livewire\Component;
use Visus\Cuid2\Cuid2;
class InviteLink extends Component
{
@ -61,7 +60,7 @@ private function generateInviteLink(bool $sendEmail = false)
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();

View file

@ -7,15 +7,19 @@
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 @@ protected function ensureAbility(Request $request, string $ability = 'read'): ?R
return null;
}
$this->auditMcpTool($request, $tool, 'denied', [
'reason' => 'missing_ability',
'required_ability' => $ability,
]);
return Response::error("Missing required permissions: {$ability}");
}
@ -38,4 +47,28 @@ protected function resolveTeamId(Request $request): ?int
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,
]);
}
}

View file

@ -13,13 +13,14 @@
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 @@
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,

View file

@ -8,36 +8,36 @@
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 @@ public function handle(Request $request): Response
$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

View file

@ -7,46 +7,46 @@
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

View file

@ -9,26 +9,26 @@
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 @@ public function handle(Request $request): Response
];
}
return $this->respond([
return $this->mcpSuccess($request, $this->respond([
'coolify_version' => config('constants.coolify.version'),
'servers' => $servers,
'projects' => $projectSummaries,
@ -83,7 +83,7 @@ public function handle(Request $request): Response
'services' => $serviceCount,
'databases' => $databaseCount,
],
]);
]));
}
public function schema(JsonSchema $schema): array

View file

@ -8,36 +8,36 @@
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 @@ public function handle(Request $request): Response
$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

View file

@ -8,31 +8,31 @@
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 @@ public function handle(Request $request): Response
->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

View file

@ -8,31 +8,31 @@
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 @@ public function handle(Request $request): Response
$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

View file

@ -8,26 +8,26 @@
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 @@ public function handle(Request $request): Response
->values()
->all();
return $this->respond(
return $this->mcpSuccess($request, $this->respond(
$summaries,
[],
$this->paginationMeta('list_databases', $args, $total),
);
));
}
public function schema(JsonSchema $schema): array

View file

@ -8,26 +8,26 @@
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 @@ public function handle(Request $request): Response
->values()
->all();
return $this->respond(
return $this->mcpSuccess($request, $this->respond(
$summaries,
[],
$this->paginationMeta('list_projects', $args, $total),
);
));
}
public function schema(JsonSchema $schema): array

View file

@ -8,26 +8,26 @@
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 @@ public function handle(Request $request): Response
->values()
->all();
return $this->respond(
return $this->mcpSuccess($request, $this->respond(
$summaries,
[],
$this->paginationMeta('list_servers', $args, $total),
);
));
}
public function schema(JsonSchema $schema): array

View file

@ -8,26 +8,26 @@
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 @@ public function handle(Request $request): Response
->values()
->all();
return $this->respond(
return $this->mcpSuccess($request, $this->respond(
$summaries,
[],
$this->paginationMeta('list_services', $args, $total),
);
));
}
public function schema(JsonSchema $schema): array

View file

@ -23,7 +23,6 @@
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 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory =
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, '/');

View file

@ -5,7 +5,6 @@
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 @@ public function generate_preview_fqdn()
$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 @@ public function generate_preview_fqdn_compose()
$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);

View file

@ -4,7 +4,6 @@
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Visus\Cuid2\Cuid2;
abstract class BaseModel extends Model
{
@ -15,7 +14,7 @@ protected static function boot()
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();
}
});
}

View file

@ -2,8 +2,12 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class CloudProviderToken extends BaseModel
{
use HasFactory;
protected $fillable = [
'team_id',
'provider',

View file

@ -4,6 +4,7 @@
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 @@
)]
class PrivateKey extends BaseModel
{
use HasSafeStringAttribute, WithRateLimiting;
use HasFactory, HasSafeStringAttribute, WithRateLimiting;
protected $fillable = [
'name',

View file

@ -6,7 +6,6 @@
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 @@ protected static function booted()
Environment::create([
'name' => 'production',
'project_id' => $project->id,
'uuid' => (string) new Cuid2,
'uuid' => new_public_id(),
]);
});
static::deleting(function ($project) {

View file

@ -37,7 +37,6 @@
use Spatie\Url\Url;
use Stevebauman\Purify\Facades\Purify;
use Symfony\Component\Yaml\Yaml;
use Visus\Cuid2\Cuid2;
/**
* @property array{
@ -1041,7 +1040,7 @@ public function defaultStandaloneDockerAttributes(?int $id = null): array
{
$attributes = [
'name' => 'coolify',
'uuid' => (string) new Cuid2,
'uuid' => new_public_id(),
'network' => 'coolify',
'server_id' => $this->id,
];

View file

@ -16,7 +16,6 @@
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 @@ protected static function booted()
{
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 @@ public function saveComposeConfigs()
"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);

View file

@ -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<class-string, class-string>
*/
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,
];

View file

@ -6,7 +6,6 @@
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 @@ public function render(): View|Closure|string
// 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;

View file

@ -6,7 +6,6 @@
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 @@ public function render(): View|Closure|string
$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 @@ public function render(): View|Closure|string
// 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;

View file

@ -6,7 +6,6 @@
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 @@ public function render(): View|Closure|string
$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 @@ public function render(): View|Closure|string
// 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;

View file

@ -5,8 +5,8 @@
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 @@ public function render(): View|Closure|string
$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 @@ public function render(): View|Closure|string
// 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;

View file

@ -6,7 +6,6 @@
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 @@ public function render(): View|Closure|string
$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 @@ public function render(): View|Closure|string
// 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;

View file

@ -5,8 +5,8 @@
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 @@ public function render(): View|Closure|string
$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 @@ public function render(): View|Closure|string
// 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;

View file

@ -10,7 +10,6 @@
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,

View file

@ -17,12 +17,11 @@
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;

View file

@ -9,7 +9,6 @@
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);

View file

@ -14,7 +14,6 @@
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);

View file

@ -64,7 +64,6 @@
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);

View file

@ -0,0 +1,25 @@
<?php
namespace Database\Factories;
use App\Models\CloudProviderToken;
use App\Models\Team;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<CloudProviderToken>
*/
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),
];
}
}

View file

@ -0,0 +1,37 @@
<?php
namespace Database\Factories;
use App\Models\PrivateKey;
use App\Models\Team;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<PrivateKey>
*/
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-----';
}
}

View file

@ -142,7 +142,8 @@
</div>
<div class="flex flex-col gap-2">
@forelse($initScripts ?? [] as $script)
<livewire:project.database.init-script :script="$script" :wire:key="$script['index']" />
<livewire:project.database.init-script :database="$database" :script="$script"
:wire:key="$script['index']" />
@empty
<div>No initialization scripts found.</div>
@endforelse

View file

@ -1,14 +1,23 @@
<?php
use App\Models\CloudProviderToken;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Once;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::query()->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 @@
$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();
});
});

View file

@ -1,15 +1,23 @@
<?php
use App\Models\CloudProviderToken;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Once;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::query()->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 @@
$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 () {

View file

@ -7,8 +7,11 @@
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 @@
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 () {

View file

@ -6,6 +6,7 @@
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 @@ function mcpToolJson($response): array
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;

View file

@ -0,0 +1,30 @@
<?php
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('keeps mutable Livewire components behind authorization checks', function (string $path, array $requiredNeedles) {
$source = file_get_contents(base_path($path));
foreach ($requiredNeedles as $needle) {
expect($source)->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'"],
],
]);

View file

@ -1,19 +1,36 @@
<?php
use App\Livewire\Security\CloudInitScriptForm;
use App\Livewire\Security\CloudInitScripts;
use App\Livewire\Security\CloudProviderTokenForm;
use App\Livewire\Security\CloudProviderTokens;
use App\Models\Application;
use App\Models\CloudInitScript;
use App\Models\CloudProviderToken;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Once;
use Livewire\Livewire;
uses(RefreshDatabase::class);
function makeAuditTeamUser(): array
{
if (! InstanceSettings::query()->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 @@ function makeAuditApplication(string $repo = 'test-org/test-repo'): Application
});
});
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();

View file

@ -8,6 +8,7 @@
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 @@ function sentinelPayload(array $containers, ?float $diskPercentage = 42.0): arra
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();

View file

@ -0,0 +1,19 @@
<?php
it('generates lowercase URL-safe public ids without Cuid2', function () {
$ids = collect(range(1, 100))->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]+$/');
});