mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
Merge remote-tracking branch 'origin/next' into automation/sync-main-to-next
This commit is contained in:
@@ -1326,8 +1326,6 @@ class ApplicationsController extends Controller
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$this->authorize('create', Application::class);
|
||||
|
||||
// removeUnnecessaryFieldsFromRequest() strips force_domain_override before the docker_compose_domains checks run.
|
||||
$forceDomainOverride = $request->boolean('force_domain_override');
|
||||
|
||||
@@ -1479,6 +1477,7 @@ class ApplicationsController extends Controller
|
||||
if (! $environment) {
|
||||
return response()->json(['message' => 'Environment not found.'], 404);
|
||||
}
|
||||
$this->authorize('create', [Application::class, $environment]);
|
||||
$server = Server::whereTeamId($teamId)->whereUuid($serverUuid)->first();
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
@@ -6153,6 +6152,7 @@ class ApplicationsController extends Controller
|
||||
}
|
||||
|
||||
$this->authorize('update', $application);
|
||||
$this->authorize('create', [Application::class, $application->environment]);
|
||||
|
||||
if ($request->boolean('clone_volumes') && $response = $this->deployAbilityForbiddenResponse($request, 'Cloning volume data stops and starts the source resource, so it needs a token with the deploy permission.')) {
|
||||
return $response;
|
||||
|
||||
@@ -2093,9 +2093,6 @@ class DatabasesController extends Controller
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
// Use a generic authorization for database creation - using PostgreSQL as representative model
|
||||
$this->authorize('create', StandalonePostgresql::class);
|
||||
|
||||
if ($request->boolean('instant_deploy')) {
|
||||
abort_unless($request->user()->tokenCan('deploy') || $request->user()->tokenCan('root'), 403, 'Missing required permissions: deploy');
|
||||
}
|
||||
@@ -2140,6 +2137,8 @@ class DatabasesController extends Controller
|
||||
if (! $environment) {
|
||||
return response()->json(['message' => 'You need to provide a valid environment_name or environment_uuid.'], 422);
|
||||
}
|
||||
// StandalonePostgresql stands for every database type: they share one policy.
|
||||
$this->authorize('create', [StandalonePostgresql::class, $environment]);
|
||||
$server = Server::whereTeamId($teamId)->whereUuid($serverUuid)->first();
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
@@ -5238,6 +5237,7 @@ class DatabasesController extends Controller
|
||||
}
|
||||
|
||||
$this->authorize('update', $database);
|
||||
$this->authorize('create', [StandalonePostgresql::class, $database->environment]);
|
||||
|
||||
if ($request->boolean('clone_volumes') && $response = $this->deployAbilityForbiddenResponse($request, 'Cloning volume data stops and starts the source resource, so it needs a token with the deploy permission.')) {
|
||||
return $response;
|
||||
|
||||
@@ -13,6 +13,7 @@ use App\Models\ApplicationPreview;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\Tag;
|
||||
use App\Services\Access\AccessResolver;
|
||||
use App\Support\ResourceStartActivity;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -74,7 +75,12 @@ class DeployController extends Controller
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$deployments_per_server = ApplicationDeploymentQueue::ownedByTeam((int) $teamId)->whereIn('status', ['in_progress', 'queued'])->get()->sortBy('id');
|
||||
$query = ApplicationDeploymentQueue::ownedByTeam((int) $teamId)->whereIn('status', ['in_progress', 'queued']);
|
||||
if (! app(AccessResolver::class)->coversWholeTeam($request->user(), $teamId)) {
|
||||
// application_id is a string column, so compare with string IDs of the applications the actor can see.
|
||||
$query->whereIn('application_id', Application::ownedByCurrentTeamAPI($teamId)->pluck('id')->map(fn ($id) => (string) $id));
|
||||
}
|
||||
$deployments_per_server = $query->get()->sortBy('id');
|
||||
$deployments_per_server = $deployments_per_server->map(function ($deployment) {
|
||||
return $this->removeSensitiveData($deployment);
|
||||
});
|
||||
@@ -232,6 +238,11 @@ class DeployController extends Controller
|
||||
if (! $application || data_get($application->team(), 'id') !== (int) $teamId) {
|
||||
return response()->json(['message' => 'You do not have permission to cancel this deployment.'], 403);
|
||||
}
|
||||
// For an actor limited by access grants, deployments of applications outside the grants do not exist.
|
||||
// Soft-deleted applications stay included so a deploy that runs during a delete can still be cancelled.
|
||||
if (! app(AccessResolver::class)->coversWholeTeam($request->user(), $teamId)) {
|
||||
$this->authorize('deploy', $application);
|
||||
}
|
||||
|
||||
// Check if deployment can be cancelled (must be queued or in_progress)
|
||||
$cancellableStatuses = [
|
||||
|
||||
@@ -399,8 +399,6 @@ class ServicesController extends Controller
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$this->authorize('create', Service::class);
|
||||
|
||||
if ($request->boolean('instant_deploy')) {
|
||||
abort_unless($request->user()->tokenCan('deploy') || $request->user()->tokenCan('root'), 403, 'Missing required permissions: deploy');
|
||||
}
|
||||
@@ -483,6 +481,7 @@ class ServicesController extends Controller
|
||||
if (! $environment) {
|
||||
return response()->json(['message' => 'Environment not found.'], 404);
|
||||
}
|
||||
$this->authorize('create', [Service::class, $environment]);
|
||||
$server = Server::whereTeamId($teamId)->whereUuid($serverUuid)->first();
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
@@ -692,6 +691,7 @@ class ServicesController extends Controller
|
||||
if (! $environment) {
|
||||
return response()->json(['message' => 'Environment not found.'], 404);
|
||||
}
|
||||
$this->authorize('create', [Service::class, $environment]);
|
||||
$server = Server::whereTeamId($teamId)->whereUuid($serverUuid)->first();
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
@@ -3318,6 +3318,7 @@ class ServicesController extends Controller
|
||||
}
|
||||
|
||||
$this->authorize('update', $service);
|
||||
$this->authorize('create', [Service::class, $service->environment]);
|
||||
|
||||
if ($request->boolean('clone_volumes') && $response = $this->deployAbilityForbiddenResponse($request, 'Cloning volume data stops and starts the source resource, so it needs a token with the deploy permission.')) {
|
||||
return $response;
|
||||
|
||||
@@ -165,7 +165,9 @@ class SharedEnvironmentVariablesController extends Controller
|
||||
return $validated;
|
||||
}
|
||||
|
||||
$this->authorize('create', SharedEnvironmentVariable::class);
|
||||
if (in_array($type, ['team', 'server'], true)) {
|
||||
$this->authorize('create', SharedEnvironmentVariable::class);
|
||||
}
|
||||
|
||||
$scope = array_filter([
|
||||
'project_id' => $attributes['project_id'] ?? null,
|
||||
@@ -496,7 +498,7 @@ class SharedEnvironmentVariablesController extends Controller
|
||||
return $project;
|
||||
}
|
||||
|
||||
$this->authorize('view', $project);
|
||||
$this->authorize('update', $project);
|
||||
|
||||
return $this->createEnv($request, $teamId, 'project', ['project_id' => $project->id]);
|
||||
}
|
||||
@@ -617,7 +619,7 @@ class SharedEnvironmentVariablesController extends Controller
|
||||
return $environment;
|
||||
}
|
||||
|
||||
$this->authorize('view', $project);
|
||||
$this->authorize('view', $environment);
|
||||
|
||||
return $this->listEnvs($teamId, 'environment', ['environment_id' => $environment->id]);
|
||||
}
|
||||
@@ -658,7 +660,7 @@ class SharedEnvironmentVariablesController extends Controller
|
||||
return $environment;
|
||||
}
|
||||
|
||||
$this->authorize('view', $project);
|
||||
$this->authorize('update', $environment);
|
||||
|
||||
return $this->createEnv($request, $teamId, 'environment', ['environment_id' => $environment->id]);
|
||||
}
|
||||
@@ -699,7 +701,7 @@ class SharedEnvironmentVariablesController extends Controller
|
||||
return $environment;
|
||||
}
|
||||
|
||||
$this->authorize('view', $project);
|
||||
$this->authorize('view', $environment);
|
||||
|
||||
return $this->updateEnv(
|
||||
$request,
|
||||
@@ -745,7 +747,7 @@ class SharedEnvironmentVariablesController extends Controller
|
||||
return $environment;
|
||||
}
|
||||
|
||||
$this->authorize('view', $project);
|
||||
$this->authorize('view', $environment);
|
||||
|
||||
return $this->deleteEnv(
|
||||
$teamId,
|
||||
|
||||
@@ -15,6 +15,7 @@ use App\Http\Middleware\DecideWhatToDoWithUser;
|
||||
use App\Http\Middleware\EncryptCookies;
|
||||
use App\Http\Middleware\EnsureMcpEnabled;
|
||||
use App\Http\Middleware\EnsureTeamMcpEnabled;
|
||||
use App\Http\Middleware\EnsureTeamWideAccess;
|
||||
use App\Http\Middleware\EnsureTokenBelongsToCurrentTeamMember;
|
||||
use App\Http\Middleware\KeepConcurrentTeamSwitch;
|
||||
use App\Http\Middleware\PreventRequestsDuringMaintenance;
|
||||
@@ -116,6 +117,7 @@ class Kernel extends HttpKernel
|
||||
'can.create.resources' => CanCreateResources::class,
|
||||
'can.update.resource' => CanUpdateResource::class,
|
||||
'can.access.terminal' => CanAccessTerminal::class,
|
||||
'access.team-wide' => EnsureTeamWideAccess::class,
|
||||
'mcp.enabled' => EnsureMcpEnabled::class,
|
||||
'api.ip.allowed' => ApiIpAllowed::class,
|
||||
'mcp.team.enabled' => EnsureTeamMcpEnabled::class,
|
||||
|
||||
@@ -2,39 +2,49 @@
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Services\Access\AccessResolver;
|
||||
use Illuminate\Auth\AuthenticationException;
|
||||
use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility;
|
||||
|
||||
class ApiAbility extends CheckForAnyAbility
|
||||
{
|
||||
/**
|
||||
* Permissions that only admins/owners may use.
|
||||
* First path segment after /api/v1/ of the routes that work on projects and their resources.
|
||||
* A token or member limited to some projects, environments, or resources can use only these.
|
||||
* Everything else (servers, keys, git sources, S3, cloud providers, team, notifications, settings, ...) is team-level.
|
||||
*/
|
||||
private const MEMBER_DISALLOWED_ABILITIES = [
|
||||
'root',
|
||||
'write',
|
||||
'write:sensitive',
|
||||
public const SCOPED_ACCESS_ROUTES = [
|
||||
'applications',
|
||||
'services',
|
||||
'databases',
|
||||
'projects',
|
||||
'deploy',
|
||||
'read:sensitive',
|
||||
'deployments',
|
||||
'resources',
|
||||
'version',
|
||||
];
|
||||
|
||||
public const SCOPED_ACCESS_DENIED_MESSAGE = 'This API token is limited to selected projects, environments, or resources, so it cannot use this endpoint.';
|
||||
|
||||
public function handle($request, $next, ...$abilities)
|
||||
{
|
||||
try {
|
||||
$token = $request->user()->currentAccessToken();
|
||||
$teamId = data_get($token, 'team_id');
|
||||
$user = $request->user();
|
||||
$teamId = data_get($user?->currentAccessToken(), 'team_id');
|
||||
|
||||
if ($teamId !== null && ! $request->user()->isAdminOfTeam((int) $teamId)) {
|
||||
$disallowed = array_filter(self::MEMBER_DISALLOWED_ABILITIES, fn (string $ability) => $token->can($ability));
|
||||
if ($request->is('api/v1/*')
|
||||
&& ! in_array($request->segment(3), self::SCOPED_ACCESS_ROUTES, true)
|
||||
&& $teamId !== null
|
||||
&& ! app(AccessResolver::class)->coversWholeTeam($user, (int) $teamId)) {
|
||||
auditLog('api.auth.scope_denied', [
|
||||
'token_id' => $user->currentAccessToken()?->id,
|
||||
'path' => $request->path(),
|
||||
], 'warning');
|
||||
|
||||
if (! empty($disallowed)) {
|
||||
return response()->json([
|
||||
'message' => 'This API token has permissions ('.implode(', ', $disallowed).') that exceed your current role as a team member. Members are restricted to read-only API access. Please revoke this token and create a new one with only read permissions.',
|
||||
], 403);
|
||||
}
|
||||
return response()->json(['message' => self::SCOPED_ACCESS_DENIED_MESSAGE], 403);
|
||||
}
|
||||
|
||||
if ($request->user()->tokenCan('root')) {
|
||||
if ($user?->tokenCan('root')) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\Environment;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
@@ -16,7 +17,21 @@ class CanCreateResources
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! Gate::allows('createAnyResource')) {
|
||||
$environment = null;
|
||||
if ($request->route('environment_uuid')) {
|
||||
$environment = Environment::query()
|
||||
->where('uuid', $request->route('environment_uuid'))
|
||||
->whereRelation('project', fn ($query) => $query
|
||||
->where('uuid', $request->route('project_uuid'))
|
||||
->where('team_id', currentTeam()?->id))
|
||||
->first();
|
||||
if ($environment === null) {
|
||||
// The page redirects to the dashboard for an environment outside the current team.
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
||||
if (! Gate::allows('createAnyResource', [$environment])) {
|
||||
abort(403, 'You do not have permission to create resources.');
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Services\Access\AccessResolver;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Blocks team-level pages (servers, keys, sources, notifications, team settings, ...) for members
|
||||
* whose grants cover only some projects, environments, or resources of the session team.
|
||||
*/
|
||||
class EnsureTeamWideAccess
|
||||
{
|
||||
/**
|
||||
* @param Closure(Request): (Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
$teamId = $user?->currentTeam()?->id;
|
||||
|
||||
if ($teamId !== null && ! app(AccessResolver::class)->coversWholeTeam($user, $teamId)) {
|
||||
abort(403, 'Your access in this team is limited to some projects.');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,20 @@
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Enums\AccessLevel;
|
||||
use App\Models\PersonalAccessToken;
|
||||
use App\Models\User;
|
||||
use App\Services\Access\AccessResolver;
|
||||
use App\Services\Access\Grant;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Laravel\Sanctum\PersonalAccessToken as SanctumPersonalAccessToken;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureTokenBelongsToCurrentTeamMember
|
||||
{
|
||||
public const EXCEEDS_ACCESS_MESSAGE = 'This API token has more access than you have in this team. Revoke it and create a new token.';
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
@@ -26,15 +34,13 @@ class EnsureTokenBelongsToCurrentTeamMember
|
||||
return response()->json(['message' => 'Invalid token.'], 401);
|
||||
}
|
||||
|
||||
$role = $team->pivot?->role;
|
||||
// Match ApiAbility::MEMBER_DISALLOWED_ABILITIES — members are read-only.
|
||||
$elevated = $token->can('root')
|
||||
|| $token->can('write')
|
||||
|| $token->can('write:sensitive')
|
||||
|| $token->can('deploy')
|
||||
|| $token->can('read:sensitive');
|
||||
if (! self::tokenWithinCreatorAccess($user, $token, (int) $teamId)) {
|
||||
auditLog('api.auth.token_exceeds_access', [
|
||||
'token_id' => $token->getKey(),
|
||||
'team_id' => (int) $teamId,
|
||||
'abilities' => $token->abilities ?? [],
|
||||
], 'warning');
|
||||
|
||||
if ($elevated && ! in_array($role, ['admin', 'owner'], true)) {
|
||||
// MCP clients expect JSON-RPC envelopes (often only parsed on HTTP 200).
|
||||
// Keep REST API clients on plain 403 JSON.
|
||||
if ($request->is('mcp') || $request->is('mcp/*')) {
|
||||
@@ -43,14 +49,41 @@ class EnsureTokenBelongsToCurrentTeamMember
|
||||
'id' => $request->input('id'),
|
||||
'error' => [
|
||||
'code' => -32003,
|
||||
'message' => 'Missing required team role.',
|
||||
'message' => self::EXCEEDS_ACCESS_MESSAGE,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json(['message' => 'Missing required team role.'], 403);
|
||||
return response()->json(['message' => self::EXCEEDS_ACCESS_MESSAGE], 403);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* A token can never do more than its owner can do in the token team, also after the owner lost access.
|
||||
* Owners and admins hold every level. A member must hold every token ability on every token scope.
|
||||
*/
|
||||
public static function tokenWithinCreatorAccess(User $user, SanctumPersonalAccessToken $token, int $teamId): bool
|
||||
{
|
||||
if ($user->isAdminOfTeam($teamId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// The deploy canvas token is built in memory from the session user's role, never from user input.
|
||||
if (! $token->exists) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$abilities = array_values($token->abilities ?? []);
|
||||
$levels = AccessLevel::parseMany($abilities);
|
||||
// Abilities outside the access levels (write:sensitive, *) are for owners and admins only.
|
||||
if (count($levels) !== count(array_unique($abilities))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$grants = $token instanceof PersonalAccessToken ? AccessResolver::tokenGrants($token) : null;
|
||||
|
||||
return app(AccessResolver::class)->userHolds($user, $teamId, $grants ?? [Grant::team($levels)]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user