fix(api): enforce destination access and cleanup networks

Require admin team membership for destination mutations, return invalid-token
responses for tokenless requests, and remove standalone Docker networks when
deleting destinations.
This commit is contained in:
Andras Bacsai
2026-06-15 17:15:56 +02:00
parent 9665aa292c
commit 9e021c4037
3 changed files with 162 additions and 55 deletions
@@ -2,38 +2,37 @@
namespace App\Http\Controllers\Api;
use App\Actions\Destination\RemoveStandaloneDockerNetwork;
use App\Http\Controllers\Controller;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\SwarmDocker;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class DestinationsController extends Controller
{
private function transform($d): array
private function transform(StandaloneDocker|SwarmDocker $destination): array
{
return [
'id' => $d->id,
'uuid' => $d->uuid,
'name' => $d->name,
'network' => $d->network,
'type' => $d instanceof SwarmDocker ? 'swarm' : 'standalone',
'server_uuid' => $d->server?->uuid,
'created_at' => $d->created_at,
'updated_at' => $d->updated_at,
'uuid' => $destination->uuid,
'name' => $destination->name,
'network' => $destination->network,
'type' => $destination instanceof SwarmDocker ? 'swarm' : 'standalone',
'server_uuid' => $destination->server?->uuid,
'created_at' => $destination->created_at,
'updated_at' => $destination->updated_at,
];
}
/**
* Resolve the calling token's team id, or return a 403 response.
* Resolve the calling token's team id, or return an invalid-token response.
*/
private function teamIdOrAbort(): int|JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return response()->json(['message' => 'You are not allowed to access the API.'], 403);
return invalidTokenResponse();
}
return $teamId;
@@ -45,15 +44,21 @@ class DestinationsController extends Controller
* controller works on Coolify versions that pre-date that scope being added
* to the destination models (e.g. 4.0.0-beta.470).
*/
private function teamScopedDockers(int $teamId)
private function teamScopedDockers(int $teamId): array
{
return [
'standalone' => StandaloneDocker::whereHas('server', fn ($q) => $q->whereTeamId($teamId))->get(),
'swarm' => SwarmDocker::whereHas('server', fn ($q) => $q->whereTeamId($teamId))->get(),
'standalone' => StandaloneDocker::with('server:id,uuid')->whereHas('server', fn ($query) => $query->whereTeamId($teamId))->get(),
'swarm' => SwarmDocker::with('server:id,uuid')->whereHas('server', fn ($query) => $query->whereTeamId($teamId))->get(),
];
}
public function index(Request $request)
private function findDestinationForTeam(int $teamId, string $uuid): StandaloneDocker|SwarmDocker
{
return StandaloneDocker::with('server:id,uuid,team_id,ip,user,port,private_key_id')->whereHas('server', fn ($query) => $query->whereTeamId($teamId))->whereUuid($uuid)->first()
?? SwarmDocker::with('server:id,uuid,team_id')->whereHas('server', fn ($query) => $query->whereTeamId($teamId))->whereUuid($uuid)->firstOrFail();
}
public function index(Request $request): JsonResponse
{
$teamId = $this->teamIdOrAbort();
if (! is_int($teamId)) {
@@ -63,36 +68,38 @@ class DestinationsController extends Controller
return response()->json(
$sets['standalone']->concat($sets['swarm'])
->map(fn ($d) => $this->transform($d))
->map(fn ($destination) => $this->transform($destination))
->values()
);
}
public function index_by_server(Request $request, string $server_uuid)
public function index_by_server(Request $request, string $server_uuid): JsonResponse
{
$teamId = $this->teamIdOrAbort();
if (! is_int($teamId)) {
return $teamId;
}
$server = Server::whereTeamId($teamId)->whereUuid($server_uuid)->firstOrFail();
$server = Server::with(['standaloneDockers.server:id,uuid', 'swarmDockers.server:id,uuid'])
->whereTeamId($teamId)
->whereUuid($server_uuid)
->firstOrFail();
$list = $server->standaloneDockers->concat($server->swarmDockers);
return response()->json($list->map(fn ($d) => $this->transform($d))->values());
return response()->json($list->map(fn ($destination) => $this->transform($destination))->values());
}
public function show(Request $request, string $uuid)
public function show(Request $request, string $uuid): JsonResponse
{
$teamId = $this->teamIdOrAbort();
if (! is_int($teamId)) {
return $teamId;
}
$d = StandaloneDocker::whereHas('server', fn ($q) => $q->whereTeamId($teamId))->whereUuid($uuid)->first()
?? SwarmDocker::whereHas('server', fn ($q) => $q->whereTeamId($teamId))->whereUuid($uuid)->firstOrFail();
$destination = $this->findDestinationForTeam($teamId, $uuid);
return response()->json($this->transform($d));
return response()->json($this->transform($destination));
}
public function create(Request $request, string $server_uuid)
public function create(Request $request, string $server_uuid): JsonResponse
{
$teamId = $this->teamIdOrAbort();
if (! is_int($teamId)) {
@@ -107,18 +114,22 @@ class DestinationsController extends Controller
$server = Server::whereTeamId($teamId)->whereUuid($server_uuid)->firstOrFail();
$allowed = ['name', 'network', 'type'];
$extra = array_diff(array_keys($request->all()), $allowed);
if (! empty($extra)) {
return response()->json(['message' => 'Unknown fields', 'fields' => array_values($extra)], 422);
}
$validator = Validator::make($request->all(), [
$validator = customApiValidator($request->all(), [
'name' => 'nullable|string|max:255',
'network' => ['required', 'string', 'max:255', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/'],
'type' => 'nullable|in:standalone,swarm',
]);
if ($validator->fails()) {
return response()->json(['message' => 'Validation failed', 'errors' => $validator->errors()], 422);
$extra = array_diff(array_keys($request->all()), $allowed);
if ($validator->fails() || ! empty($extra)) {
$errors = $validator->errors();
if (! empty($extra)) {
foreach ($extra as $field) {
$errors->add($field, 'This field is not allowed.');
}
}
return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422);
}
$expectedType = $server->isSwarm() ? 'swarm' : 'standalone';
@@ -130,52 +141,80 @@ class DestinationsController extends Controller
$name = $request->input('name') ?: ($server->name.'-'.$request->input('network'));
$class = $type === 'swarm' ? SwarmDocker::class : StandaloneDocker::class;
$this->authorize('create', $class);
$exists = $class::where('server_id', $server->id)->where('network', $request->input('network'))->exists();
if ($exists) {
return response()->json(['message' => 'A destination with this network already exists on the server.'], 409);
}
$d = $class::create([
$destination = $class::create([
'name' => $name,
'network' => $request->input('network'),
'server_id' => $server->id,
]);
return response()->json(['uuid' => $d->uuid], 201);
auditLog('api.destination.created', [
'team_id' => $teamId,
'destination_uuid' => $destination->uuid,
'destination_name' => $destination->name,
'destination_type' => $type,
'server_uuid' => $server->uuid,
]);
return response()->json($this->transform($destination->load('server:id,uuid')), 201);
}
public function delete(Request $request, string $uuid)
public function delete(Request $request, string $uuid): JsonResponse
{
$teamId = $this->teamIdOrAbort();
if (! is_int($teamId)) {
return $teamId;
}
$d = StandaloneDocker::whereHas('server', fn ($q) => $q->whereTeamId($teamId))->whereUuid($uuid)->first()
?? SwarmDocker::whereHas('server', fn ($q) => $q->whereTeamId($teamId))->whereUuid($uuid)->firstOrFail();
$destination = $this->findDestinationForTeam($teamId, $uuid);
$this->authorize('delete', $destination);
// Guard against deleting destinations with attached resources. attachedTo()
// is recent on the destination models; fall back to a manual check for
// older Coolify versions (e.g. 4.0.0-beta.470).
if (method_exists($d, 'attachedTo')) {
if ($d->attachedTo()) {
if (method_exists($destination, 'attachedTo')) {
if ($destination->attachedTo()) {
return response()->json(['message' => 'Destination has attached resources, detach first.'], 409);
}
} else {
$hasAttached = $d->applications()->exists()
|| $d->postgresqls()->exists()
|| (method_exists($d, 'mysqls') && $d->mysqls()->exists())
|| (method_exists($d, 'mariadbs') && $d->mariadbs()->exists())
|| (method_exists($d, 'mongodbs') && $d->mongodbs()->exists())
|| (method_exists($d, 'redis') && $d->redis()->exists())
|| (method_exists($d, 'keydbs') && $d->keydbs()->exists())
|| (method_exists($d, 'dragonflies') && $d->dragonflies()->exists())
|| (method_exists($d, 'clickhouses') && $d->clickhouses()->exists())
|| (method_exists($d, 'services') && $d->services()->exists());
$hasAttached = $destination->applications()->exists()
|| $destination->postgresqls()->exists()
|| (method_exists($destination, 'mysqls') && $destination->mysqls()->exists())
|| (method_exists($destination, 'mariadbs') && $destination->mariadbs()->exists())
|| (method_exists($destination, 'mongodbs') && $destination->mongodbs()->exists())
|| (method_exists($destination, 'redis') && $destination->redis()->exists())
|| (method_exists($destination, 'keydbs') && $destination->keydbs()->exists())
|| (method_exists($destination, 'dragonflies') && $destination->dragonflies()->exists())
|| (method_exists($destination, 'clickhouses') && $destination->clickhouses()->exists())
|| (method_exists($destination, 'services') && $destination->services()->exists());
if ($hasAttached) {
return response()->json(['message' => 'Destination has attached resources, detach first.'], 409);
}
}
$d->delete();
if ($destination instanceof StandaloneDocker) {
app(RemoveStandaloneDockerNetwork::class)->handle($destination);
}
$destinationUuid = $destination->uuid;
$destinationName = $destination->name;
$destinationType = $destination instanceof SwarmDocker ? 'swarm' : 'standalone';
$serverUuid = $destination->server?->uuid;
$destination->delete();
auditLog('api.destination.deleted', [
'team_id' => $teamId,
'destination_uuid' => $destinationUuid,
'destination_name' => $destinationName,
'destination_type' => $destinationType,
'server_uuid' => $serverUuid,
]);
return response()->json(['message' => 'Deleted.']);
}