Merge remote-tracking branch 'origin/next' into automation/sync-main-to-next

This commit is contained in:
Andras Bacsai
2026-10-26 12:40:59 +01:00
134 changed files with 4031 additions and 1325 deletions
+64
View File
@@ -0,0 +1,64 @@
<?php
namespace App\Enums;
enum AccessLevel: string
{
case Read = 'read';
case ReadSensitive = 'read:sensitive';
case Deploy = 'deploy';
case Write = 'write';
case Delete = 'delete';
case Root = 'root';
/**
* The levels that a grant of this level also gives.
*
* Write includes ReadSensitive because a user who can change a resource
* can always read its secrets back through the compose file or a deploy.
*
* @return list<self>
*/
public function includes(): array
{
return match ($this) {
self::Root => self::cases(),
self::Write => [self::Write, self::Deploy, self::ReadSensitive, self::Read],
self::Delete => [self::Delete, self::Read],
self::Deploy => [self::Deploy, self::Read],
self::ReadSensitive => [self::ReadSensitive, self::Read],
self::Read => [self::Read],
};
}
public function label(): string
{
return match ($this) {
self::Read => 'Read',
self::ReadSensitive => 'Read secrets',
self::Deploy => 'Deploy',
self::Write => 'Write',
self::Delete => 'Delete',
self::Root => 'Full access',
};
}
/**
* Parse stored level or token ability strings. Unknown values are ignored.
*
* @param iterable<string> $values
* @return list<self>
*/
public static function parseMany(iterable $values): array
{
$levels = [];
foreach ($values as $value) {
$level = self::tryFrom((string) $value);
if ($level !== null && ! in_array($level, $levels, true)) {
$levels[] = $level;
}
}
return $levels;
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace App\Enums;
enum AccessScope: string
{
case Team = 'team';
case Project = 'project';
case Environment = 'environment';
case Resource = 'resource';
}
@@ -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;
+12 -1
View File
@@ -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,
+2
View File
@@ -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,
+26 -16
View File
@@ -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);
}
+16 -1
View File
@@ -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)]);
}
}
+13 -3
View File
@@ -7,7 +7,9 @@ use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\TeamInvitation;
use App\Services\Access\AccessResolver;
use Illuminate\Support\Collection;
use Livewire\Attributes\Locked;
use Livewire\Component;
class Dashboard extends Component
@@ -20,16 +22,24 @@ class Dashboard extends Component
public Collection $privateKeys;
/**
* Members limited to some projects do not see team-level sections such as servers.
*/
#[Locked]
public bool $showsTeamSections = true;
public function mount()
{
if ($this->prefersCanvasView('dashboard')) {
$this->showsTeamSections = app(AccessResolver::class)->coversWholeTeam(auth()->user(), currentTeam()->id);
if ($this->showsTeamSections && $this->prefersCanvasView('dashboard')) {
$this->redirectRoute('dashboard.canvas', navigate: true);
return;
}
$this->privateKeys = PrivateKey::ownedByCurrentTeamCached();
$this->servers = Server::ownedByCurrentTeamCached();
$this->privateKeys = $this->showsTeamSections ? PrivateKey::ownedByCurrentTeamCached() : collect();
$this->servers = $this->showsTeamSections ? Server::ownedByCurrentTeamCached() : collect();
$this->projects = Project::ownedByCurrentTeam()
->with(['environments:id,uuid,name,project_id'])
->withCount([
+2 -1
View File
@@ -36,7 +36,8 @@ class ActiveDeployments extends Component
$baseQuery = ApplicationDeploymentQueue::query()
->with(['application.environment.project'])
->ownedByTeam(currentTeam()->id);
->ownedByTeam(currentTeam()->id)
->visibleToCurrentActor();
$this->activeDeployments = (clone $baseQuery)
->whereIn('status', [
+1
View File
@@ -31,6 +31,7 @@ class DeploymentsIndicator extends Component
return ApplicationDeploymentQueue::with(['application.environment.project'])
->ownedByTeam(currentTeam()->id)
->whereIn('status', ['in_progress', 'queued'])
->visibleToCurrentActor()
->orderBy('id')
->get([
'id',
+405 -379
View File
@@ -19,6 +19,7 @@ use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Models\StandaloneSqlite;
use App\Services\Access\AccessResolver;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Livewire\Component;
@@ -256,390 +257,402 @@ class GlobalSearch extends Component
private function loadSearchableItems()
{
// Try to get from Redis cache first
$cacheKey = self::getCacheKey(auth()->user()->currentTeam()->id);
$teamWide = $this->actorCoversWholeTeam();
$resources = Cache::remember($cacheKey, 300, function () {
$items = collect();
$team = auth()->user()->currentTeam();
// Get all applications
$applications = Application::ownedByCurrentTeam()
->with(['environment.project', 'previews:id,application_id,pull_request_id'])
->get()
->map(function ($app) {
// Collect all FQDNs from the application
$fqdns = collect([]);
// For regular applications
if ($app->fqdn) {
$fqdns = collect(explode(',', $app->fqdn))->map(fn ($fqdn) => trim($fqdn));
}
// For docker compose based applications
if ($app->build_pack === 'dockercompose' && $app->docker_compose_domains) {
try {
$composeDomains = json_decode($app->docker_compose_domains, true);
if (is_array($composeDomains)) {
foreach ($composeDomains as $serviceName => $domains) {
if (is_array($domains)) {
$fqdns = $fqdns->merge($domains);
}
}
}
} catch (\Exception $e) {
// Ignore JSON parsing errors
}
}
$fqdnsString = $fqdns->implode(' ');
// Add PR search terms if preview is enabled
$prSearchTerms = '';
if ($app->preview_enabled ?? false) {
$prIds = collect($app->previews ?? [])
->pluck('pull_request_id')
->map(fn ($id) => "pr-{$id} pr{$id} {$id}")
->implode(' ');
$prSearchTerms = $prIds;
}
return [
'id' => $app->id,
'name' => $app->name,
'type' => 'application',
'uuid' => $app->uuid,
'description' => $app->description,
'link' => $app->link(),
'project' => $app->environment->project->name ?? null,
'environment' => $app->environment->name ?? null,
'fqdns' => $fqdns->take(2)->implode(', '), // Show first 2 FQDNs in UI
'search_text' => strtolower($app->name.' '.$app->description.' '.$fqdnsString.' '.$app->uuid.' '.$prSearchTerms.' application applications app apps'),
];
});
// Get all services
$services = Service::ownedByCurrentTeam()
->with(['environment.project', 'applications', 'databases'])
->get()
->map(function ($service) {
// Collect all FQDNs from service applications
$fqdns = collect([]);
foreach ($service->applications as $app) {
if ($app->fqdn) {
$appFqdns = collect(explode(',', $app->fqdn))->map(fn ($fqdn) => trim($fqdn));
$fqdns = $fqdns->merge($appFqdns);
}
}
$fqdnsString = $fqdns->implode(' ');
// Collect service component names for container search
$serviceAppNames = collect($service->applications ?? [])->pluck('name')->implode(' ');
$serviceDbNames = collect($service->databases ?? [])->pluck('name')->implode(' ');
return [
'id' => $service->id,
'name' => $service->name,
'type' => 'service',
'uuid' => $service->uuid,
'description' => $service->description,
'link' => $service->link(),
'project' => $service->environment->project->name ?? null,
'environment' => $service->environment->name ?? null,
'fqdns' => $fqdns->take(2)->implode(', '), // Show first 2 FQDNs in UI
'search_text' => strtolower($service->name.' '.$service->description.' '.$fqdnsString.' '.$service->uuid.' '.$serviceAppNames.' '.$serviceDbNames.' service services'),
];
});
// Get all standalone databases
$databases = collect();
// PostgreSQL
$databases = $databases->merge(
StandalonePostgresql::ownedByCurrentTeam()
->with(['environment.project'])
->get()
->map(function ($db) {
return [
'id' => $db->id,
'name' => $db->name,
'type' => 'database',
'subtype' => 'postgresql',
'uuid' => $db->uuid,
'description' => $db->description,
'link' => $db->link(),
'project' => $db->environment->project->name ?? null,
'environment' => $db->environment->name ?? null,
'search_text' => strtolower($db->name.' '.$db->uuid.' postgresql '.$db->description.' database databases db'),
];
})
);
// MySQL
$databases = $databases->merge(
StandaloneMysql::ownedByCurrentTeam()
->with(['environment.project'])
->get()
->map(function ($db) {
return [
'id' => $db->id,
'name' => $db->name,
'type' => 'database',
'subtype' => 'mysql',
'uuid' => $db->uuid,
'description' => $db->description,
'link' => $db->link(),
'project' => $db->environment->project->name ?? null,
'environment' => $db->environment->name ?? null,
'search_text' => strtolower($db->name.' '.$db->uuid.' mysql '.$db->description.' database databases db'),
];
})
);
// MariaDB
$databases = $databases->merge(
StandaloneMariadb::ownedByCurrentTeam()
->with(['environment.project'])
->get()
->map(function ($db) {
return [
'id' => $db->id,
'name' => $db->name,
'type' => 'database',
'subtype' => 'mariadb',
'uuid' => $db->uuid,
'description' => $db->description,
'link' => $db->link(),
'project' => $db->environment->project->name ?? null,
'environment' => $db->environment->name ?? null,
'search_text' => strtolower($db->name.' '.$db->uuid.' mariadb '.$db->description.' database databases db'),
];
})
);
// MongoDB
$databases = $databases->merge(
StandaloneMongodb::ownedByCurrentTeam()
->with(['environment.project'])
->get()
->map(function ($db) {
return [
'id' => $db->id,
'name' => $db->name,
'type' => 'database',
'subtype' => 'mongodb',
'uuid' => $db->uuid,
'description' => $db->description,
'link' => $db->link(),
'project' => $db->environment->project->name ?? null,
'environment' => $db->environment->name ?? null,
'search_text' => strtolower($db->name.' '.$db->uuid.' mongodb '.$db->description.' database databases db'),
];
})
);
// Redis
$databases = $databases->merge(
StandaloneRedis::ownedByCurrentTeam()
->with(['environment.project'])
->get()
->map(function ($db) {
return [
'id' => $db->id,
'name' => $db->name,
'type' => 'database',
'subtype' => 'redis',
'uuid' => $db->uuid,
'description' => $db->description,
'link' => $db->link(),
'project' => $db->environment->project->name ?? null,
'environment' => $db->environment->name ?? null,
'search_text' => strtolower($db->name.' '.$db->uuid.' redis '.$db->description.' database databases db'),
];
})
);
// KeyDB
$databases = $databases->merge(
StandaloneKeydb::ownedByCurrentTeam()
->with(['environment.project'])
->get()
->map(function ($db) {
return [
'id' => $db->id,
'name' => $db->name,
'type' => 'database',
'subtype' => 'keydb',
'uuid' => $db->uuid,
'description' => $db->description,
'link' => $db->link(),
'project' => $db->environment->project->name ?? null,
'environment' => $db->environment->name ?? null,
'search_text' => strtolower($db->name.' '.$db->uuid.' keydb '.$db->description.' database databases db'),
];
})
);
// Dragonfly
$databases = $databases->merge(
StandaloneDragonfly::ownedByCurrentTeam()
->with(['environment.project'])
->get()
->map(function ($db) {
return [
'id' => $db->id,
'name' => $db->name,
'type' => 'database',
'subtype' => 'dragonfly',
'uuid' => $db->uuid,
'description' => $db->description,
'link' => $db->link(),
'project' => $db->environment->project->name ?? null,
'environment' => $db->environment->name ?? null,
'search_text' => strtolower($db->name.' '.$db->uuid.' dragonfly '.$db->description.' database databases db'),
];
})
);
// Clickhouse
$databases = $databases->merge(
StandaloneClickhouse::ownedByCurrentTeam()
->with(['environment.project'])
->get()
->map(function ($db) {
return [
'id' => $db->id,
'name' => $db->name,
'type' => 'database',
'subtype' => 'clickhouse',
'uuid' => $db->uuid,
'description' => $db->description,
'link' => $db->link(),
'project' => $db->environment->project->name ?? null,
'environment' => $db->environment->name ?? null,
'search_text' => strtolower($db->name.' '.$db->uuid.' clickhouse '.$db->description.' database databases db'),
];
})
);
// SQLite
$databases = $databases->merge(
StandaloneSqlite::ownedByCurrentTeam()
->with(['environment.project'])
->get()
->map(function ($db) {
return [
'id' => $db->id,
'name' => $db->name,
'type' => 'database',
'subtype' => 'sqlite',
'uuid' => $db->uuid,
'description' => $db->description,
'link' => $db->link(),
'project' => $db->environment->project->name ?? null,
'environment' => $db->environment->name ?? null,
'search_text' => strtolower($db->name.' '.$db->uuid.' sqlite '.$db->description.' database databases db'),
];
})
);
// Get all servers
$servers = Server::ownedByCurrentTeam()
->get()
->map(function ($server) {
return [
'id' => $server->id,
'name' => $server->name,
'type' => 'server',
'uuid' => $server->uuid,
'description' => $server->description,
'link' => $server->url(),
'project' => null,
'environment' => null,
'search_text' => strtolower($server->name.' '.$server->ip.' '.$server->description.' server servers'),
];
});
// Get all projects
$projects = Project::ownedByCurrentTeam()
->withCount(['environments', 'applications', 'services'])
->get()
->map(function ($project) {
$resourceCount = $project->applications_count + $project->services_count;
$resourceSummary = $resourceCount > 0
? "{$resourceCount} resource".($resourceCount !== 1 ? 's' : '')
: 'No resources';
return [
'id' => $project->id,
'name' => $project->name,
'type' => 'project',
'uuid' => $project->uuid,
'description' => $project->description,
'link' => $project->navigateTo(),
'project' => null,
'environment' => null,
'resource_count' => $resourceSummary,
'environment_count' => $project->environments_count,
'search_text' => strtolower($project->name.' '.$project->description.' project projects'),
];
});
// Get all environments
$environments = Environment::ownedByCurrentTeam()
->with('project')
->withCount(['applications', 'services'])
->get()
->map(function ($environment) {
$resourceCount = $environment->applications_count + $environment->services_count;
$resourceSummary = $resourceCount > 0
? "{$resourceCount} resource".($resourceCount !== 1 ? 's' : '')
: 'No resources';
// Build description with project context
$descriptionParts = [];
if ($environment->project) {
$descriptionParts[] = "Project: {$environment->project->name}";
}
if ($environment->description) {
$descriptionParts[] = $environment->description;
}
if (empty($descriptionParts)) {
$descriptionParts[] = $resourceSummary;
}
return [
'id' => $environment->id,
'name' => $environment->name,
'type' => 'environment',
'uuid' => $environment->uuid,
'description' => implode(' • ', $descriptionParts),
'link' => route('project.resource.index', [
'project_uuid' => $environment->project->uuid,
'environment_uuid' => $environment->uuid,
]),
'project' => $environment->project->name ?? null,
'environment' => null,
'resource_count' => $resourceSummary,
'search_text' => strtolower($environment->name.' '.$environment->description.' '.$environment->project->name.' environment'),
];
});
// Merge all collections
$items = $items->merge($applications)
->merge($services)
->merge($databases)
->merge($servers)
->merge($projects)
->merge($environments);
return $items->toArray();
});
// The team cache holds every resource of the team, so members limited to some projects
// never read or write it. Their index is built from the queries the access grants filter.
$resources = $teamWide
? Cache::remember(self::getCacheKey(auth()->user()->currentTeam()->id), 300, fn () => $this->searchableResources(includeServers: true))
: $this->searchableResources(includeServers: false);
// Navigation depends on the user's permissions, so it is not part of the team-wide cache
$this->allSearchableItems = $this->navigationItems()->merge($resources)->values()->all();
$this->allSearchableItems = $this->navigationItems($teamWide)->merge($resources)->values()->all();
}
private function navigationItems(): Collection
private function actorCoversWholeTeam(): bool
{
return app(AccessResolver::class)->coversWholeTeam(auth()->user(), auth()->user()->currentTeam()->id);
}
/**
* @return list<array<string, mixed>>
*/
private function searchableResources(bool $includeServers): array
{
$items = collect();
// Get all applications
$applications = Application::ownedByCurrentTeam()
->with(['environment.project', 'previews:id,application_id,pull_request_id'])
->get()
->map(function ($app) {
// Collect all FQDNs from the application
$fqdns = collect([]);
// For regular applications
if ($app->fqdn) {
$fqdns = collect(explode(',', $app->fqdn))->map(fn ($fqdn) => trim($fqdn));
}
// For docker compose based applications
if ($app->build_pack === 'dockercompose' && $app->docker_compose_domains) {
try {
$composeDomains = json_decode($app->docker_compose_domains, true);
if (is_array($composeDomains)) {
foreach ($composeDomains as $serviceName => $domains) {
if (is_array($domains)) {
$fqdns = $fqdns->merge($domains);
}
}
}
} catch (\Exception $e) {
// Ignore JSON parsing errors
}
}
$fqdnsString = $fqdns->implode(' ');
// Add PR search terms if preview is enabled
$prSearchTerms = '';
if ($app->preview_enabled ?? false) {
$prIds = collect($app->previews ?? [])
->pluck('pull_request_id')
->map(fn ($id) => "pr-{$id} pr{$id} {$id}")
->implode(' ');
$prSearchTerms = $prIds;
}
return [
'id' => $app->id,
'name' => $app->name,
'type' => 'application',
'uuid' => $app->uuid,
'description' => $app->description,
'link' => $app->link(),
'project' => $app->environment->project->name ?? null,
'environment' => $app->environment->name ?? null,
'fqdns' => $fqdns->take(2)->implode(', '), // Show first 2 FQDNs in UI
'search_text' => strtolower($app->name.' '.$app->description.' '.$fqdnsString.' '.$app->uuid.' '.$prSearchTerms.' application applications app apps'),
];
});
// Get all services
$services = Service::ownedByCurrentTeam()
->with(['environment.project', 'applications', 'databases'])
->get()
->map(function ($service) {
// Collect all FQDNs from service applications
$fqdns = collect([]);
foreach ($service->applications as $app) {
if ($app->fqdn) {
$appFqdns = collect(explode(',', $app->fqdn))->map(fn ($fqdn) => trim($fqdn));
$fqdns = $fqdns->merge($appFqdns);
}
}
$fqdnsString = $fqdns->implode(' ');
// Collect service component names for container search
$serviceAppNames = collect($service->applications ?? [])->pluck('name')->implode(' ');
$serviceDbNames = collect($service->databases ?? [])->pluck('name')->implode(' ');
return [
'id' => $service->id,
'name' => $service->name,
'type' => 'service',
'uuid' => $service->uuid,
'description' => $service->description,
'link' => $service->link(),
'project' => $service->environment->project->name ?? null,
'environment' => $service->environment->name ?? null,
'fqdns' => $fqdns->take(2)->implode(', '), // Show first 2 FQDNs in UI
'search_text' => strtolower($service->name.' '.$service->description.' '.$fqdnsString.' '.$service->uuid.' '.$serviceAppNames.' '.$serviceDbNames.' service services'),
];
});
// Get all standalone databases
$databases = collect();
// PostgreSQL
$databases = $databases->merge(
StandalonePostgresql::ownedByCurrentTeam()
->with(['environment.project'])
->get()
->map(function ($db) {
return [
'id' => $db->id,
'name' => $db->name,
'type' => 'database',
'subtype' => 'postgresql',
'uuid' => $db->uuid,
'description' => $db->description,
'link' => $db->link(),
'project' => $db->environment->project->name ?? null,
'environment' => $db->environment->name ?? null,
'search_text' => strtolower($db->name.' '.$db->uuid.' postgresql '.$db->description.' database databases db'),
];
})
);
// MySQL
$databases = $databases->merge(
StandaloneMysql::ownedByCurrentTeam()
->with(['environment.project'])
->get()
->map(function ($db) {
return [
'id' => $db->id,
'name' => $db->name,
'type' => 'database',
'subtype' => 'mysql',
'uuid' => $db->uuid,
'description' => $db->description,
'link' => $db->link(),
'project' => $db->environment->project->name ?? null,
'environment' => $db->environment->name ?? null,
'search_text' => strtolower($db->name.' '.$db->uuid.' mysql '.$db->description.' database databases db'),
];
})
);
// MariaDB
$databases = $databases->merge(
StandaloneMariadb::ownedByCurrentTeam()
->with(['environment.project'])
->get()
->map(function ($db) {
return [
'id' => $db->id,
'name' => $db->name,
'type' => 'database',
'subtype' => 'mariadb',
'uuid' => $db->uuid,
'description' => $db->description,
'link' => $db->link(),
'project' => $db->environment->project->name ?? null,
'environment' => $db->environment->name ?? null,
'search_text' => strtolower($db->name.' '.$db->uuid.' mariadb '.$db->description.' database databases db'),
];
})
);
// MongoDB
$databases = $databases->merge(
StandaloneMongodb::ownedByCurrentTeam()
->with(['environment.project'])
->get()
->map(function ($db) {
return [
'id' => $db->id,
'name' => $db->name,
'type' => 'database',
'subtype' => 'mongodb',
'uuid' => $db->uuid,
'description' => $db->description,
'link' => $db->link(),
'project' => $db->environment->project->name ?? null,
'environment' => $db->environment->name ?? null,
'search_text' => strtolower($db->name.' '.$db->uuid.' mongodb '.$db->description.' database databases db'),
];
})
);
// Redis
$databases = $databases->merge(
StandaloneRedis::ownedByCurrentTeam()
->with(['environment.project'])
->get()
->map(function ($db) {
return [
'id' => $db->id,
'name' => $db->name,
'type' => 'database',
'subtype' => 'redis',
'uuid' => $db->uuid,
'description' => $db->description,
'link' => $db->link(),
'project' => $db->environment->project->name ?? null,
'environment' => $db->environment->name ?? null,
'search_text' => strtolower($db->name.' '.$db->uuid.' redis '.$db->description.' database databases db'),
];
})
);
// KeyDB
$databases = $databases->merge(
StandaloneKeydb::ownedByCurrentTeam()
->with(['environment.project'])
->get()
->map(function ($db) {
return [
'id' => $db->id,
'name' => $db->name,
'type' => 'database',
'subtype' => 'keydb',
'uuid' => $db->uuid,
'description' => $db->description,
'link' => $db->link(),
'project' => $db->environment->project->name ?? null,
'environment' => $db->environment->name ?? null,
'search_text' => strtolower($db->name.' '.$db->uuid.' keydb '.$db->description.' database databases db'),
];
})
);
// Dragonfly
$databases = $databases->merge(
StandaloneDragonfly::ownedByCurrentTeam()
->with(['environment.project'])
->get()
->map(function ($db) {
return [
'id' => $db->id,
'name' => $db->name,
'type' => 'database',
'subtype' => 'dragonfly',
'uuid' => $db->uuid,
'description' => $db->description,
'link' => $db->link(),
'project' => $db->environment->project->name ?? null,
'environment' => $db->environment->name ?? null,
'search_text' => strtolower($db->name.' '.$db->uuid.' dragonfly '.$db->description.' database databases db'),
];
})
);
// Clickhouse
$databases = $databases->merge(
StandaloneClickhouse::ownedByCurrentTeam()
->with(['environment.project'])
->get()
->map(function ($db) {
return [
'id' => $db->id,
'name' => $db->name,
'type' => 'database',
'subtype' => 'clickhouse',
'uuid' => $db->uuid,
'description' => $db->description,
'link' => $db->link(),
'project' => $db->environment->project->name ?? null,
'environment' => $db->environment->name ?? null,
'search_text' => strtolower($db->name.' '.$db->uuid.' clickhouse '.$db->description.' database databases db'),
];
})
);
// SQLite
$databases = $databases->merge(
StandaloneSqlite::ownedByCurrentTeam()
->with(['environment.project'])
->get()
->map(function ($db) {
return [
'id' => $db->id,
'name' => $db->name,
'type' => 'database',
'subtype' => 'sqlite',
'uuid' => $db->uuid,
'description' => $db->description,
'link' => $db->link(),
'project' => $db->environment->project->name ?? null,
'environment' => $db->environment->name ?? null,
'search_text' => strtolower($db->name.' '.$db->uuid.' sqlite '.$db->description.' database databases db'),
];
})
);
// Get all servers
$servers = ($includeServers ? Server::ownedByCurrentTeam()->get() : collect())
->map(function ($server) {
return [
'id' => $server->id,
'name' => $server->name,
'type' => 'server',
'uuid' => $server->uuid,
'description' => $server->description,
'link' => $server->url(),
'project' => null,
'environment' => null,
'search_text' => strtolower($server->name.' '.$server->ip.' '.$server->description.' server servers'),
];
});
// Get all projects
$projects = Project::ownedByCurrentTeam()
->withCount(['environments', 'applications', 'services'])
->get()
->map(function ($project) {
$resourceCount = $project->applications_count + $project->services_count;
$resourceSummary = $resourceCount > 0
? "{$resourceCount} resource".($resourceCount !== 1 ? 's' : '')
: 'No resources';
return [
'id' => $project->id,
'name' => $project->name,
'type' => 'project',
'uuid' => $project->uuid,
'description' => $project->description,
'link' => $project->navigateTo(),
'project' => null,
'environment' => null,
'resource_count' => $resourceSummary,
'environment_count' => $project->environments_count,
'search_text' => strtolower($project->name.' '.$project->description.' project projects'),
];
});
// Get all environments
$environments = Environment::ownedByCurrentTeam()
->with('project')
->withCount(['applications', 'services'])
->get()
->map(function ($environment) {
$resourceCount = $environment->applications_count + $environment->services_count;
$resourceSummary = $resourceCount > 0
? "{$resourceCount} resource".($resourceCount !== 1 ? 's' : '')
: 'No resources';
// Build description with project context
$descriptionParts = [];
if ($environment->project) {
$descriptionParts[] = "Project: {$environment->project->name}";
}
if ($environment->description) {
$descriptionParts[] = $environment->description;
}
if (empty($descriptionParts)) {
$descriptionParts[] = $resourceSummary;
}
return [
'id' => $environment->id,
'name' => $environment->name,
'type' => 'environment',
'uuid' => $environment->uuid,
'description' => implode(' • ', $descriptionParts),
'link' => route('project.resource.index', [
'project_uuid' => $environment->project->uuid,
'environment_uuid' => $environment->uuid,
]),
'project' => $environment->project->name ?? null,
'environment' => null,
'resource_count' => $resourceSummary,
'search_text' => strtolower($environment->name.' '.$environment->description.' '.$environment->project->name.' environment'),
];
});
// Merge all collections
$items = $items->merge($applications)
->merge($services)
->merge($databases)
->merge($servers)
->merge($projects)
->merge($environments);
return $items->toArray();
}
private function navigationItems(bool $teamWide): Collection
{
$user = auth()->user();
$team = $user->currentTeam();
@@ -934,6 +947,19 @@ class GlobalSearch extends Component
);
}
if (! $teamWide) {
$projectPages = [
route('dashboard'),
route('project.index'),
route('shared-variables.project.index'),
route('shared-variables.environment.index'),
route('profile'),
route('profile.appearance'),
route('security.api-tokens'),
];
$navigation = $navigation->whereIn('link', $projectPages)->values();
}
return $navigation;
}
+1 -1
View File
@@ -72,7 +72,7 @@ class Index extends Component
'settingsHref' => auth()->user()->can('update', $project)
? route('project.edit', ['project_uuid' => $project->uuid])
: null,
'addResourceHref' => $firstEnvironment && auth()->user()->can('createAnyResource')
'addResourceHref' => $firstEnvironment && auth()->user()->can('createAnyResource', [$firstEnvironment])
? route('project.resource.create', [
'project_uuid' => $project->uuid,
'environment_uuid' => $firstEnvironment->uuid,
+1 -2
View File
@@ -32,8 +32,6 @@ class DockerCompose extends Component
public function submit()
{
try {
$this->authorize('create', Service::class);
$this->validate([
'dockerComposeRaw' => 'required',
]);
@@ -44,6 +42,7 @@ class DockerCompose extends Component
$project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail();
$environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail();
$this->authorize('create', [Service::class, $environment]);
$destination_uuid = $this->query['destination'] ?? null;
$destination = find_resource_destination_for_current_team($destination_uuid);
+1 -2
View File
@@ -83,8 +83,6 @@ class DockerImage extends Component
public function submit()
{
$this->authorize('create', Application::class);
$this->validate([
'imageName' => ValidationPatterns::dockerImageNameRules(required: true),
'imageTag' => ValidationPatterns::dockerImageTagRules(),
@@ -123,6 +121,7 @@ class DockerImage extends Component
$project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail();
$environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail();
$this->authorize('create', [Application::class, $environment]);
// Append @sha256 to image name if using digest and not already present
$imageName = $parser->getFullImageNameWithoutTag();
@@ -189,8 +189,6 @@ class GithubPrivateRepository extends Component
public function submit()
{
try {
$this->authorize('create', Application::class);
// Validate git repository parts and branch
$validator = validator([
'selected_repository_owner' => $this->selected_repository_owner,
@@ -217,6 +215,7 @@ class GithubPrivateRepository extends Component
$project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail();
$environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail();
$this->authorize('create', [Application::class, $environment]);
$application_init = [
'name' => generate_application_name($this->selected_repository_owner.'/'.$this->selected_repository_repo, $this->selected_branch_name),
@@ -128,7 +128,9 @@ class GithubPrivateRepositoryDeployKey extends Component
public function setPrivateKey($private_key_id)
{
$this->authorize('create', Application::class);
$project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail();
$environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail();
$this->authorize('create', [Application::class, $environment]);
$privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($private_key_id);
$this->private_key_id = $privateKey->id;
@@ -149,8 +151,6 @@ class GithubPrivateRepositoryDeployKey extends Component
public function submit()
{
$this->authorize('create', Application::class);
$privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id);
$this->validate();
@@ -169,6 +169,7 @@ class GithubPrivateRepositoryDeployKey extends Component
$project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail();
$environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail();
$this->authorize('create', [Application::class, $environment]);
if ($this->git_source === 'other') {
$application_init = [
'name' => generate_random_name(),
@@ -161,8 +161,6 @@ class GitlabPrivateRepository extends Component
public function submit()
{
try {
$this->authorize('create', Application::class);
$validator = validator([
'selected_repository_path' => $this->selected_repository_path,
'selected_branch_name' => $this->selected_branch_name,
@@ -186,6 +184,7 @@ class GitlabPrivateRepository extends Component
$project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail();
$environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail();
$this->authorize('create', [Application::class, $environment]);
$gitlab_app = $this->getGitlabApp();
@@ -277,8 +277,6 @@ class PublicGitRepository extends Component
public function submit()
{
try {
$this->authorize('create', Application::class);
$this->validate();
// Additional validation for git repository and branch
@@ -313,6 +311,7 @@ class PublicGitRepository extends Component
$project = Project::ownedByCurrentTeam()->where('uuid', $project_uuid)->firstOrFail();
$environment = $project->environments()->where('uuid', $environment_uuid)->firstOrFail();
$this->authorize('create', [Application::class, $environment]);
if ($this->git_source === 'other') {
$application_init = [
@@ -32,8 +32,6 @@ CMD ["nginx", "-g", "daemon off;"]
public function submit()
{
$this->authorize('create', Application::class);
$this->validate([
'dockerfile' => 'required',
]);
@@ -46,6 +44,7 @@ CMD ["nginx", "-g", "daemon off;"]
$project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail();
$environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail();
$this->authorize('create', [Application::class, $environment]);
$port = get_port_from_dockerfile($this->dockerfile);
if (! $port) {
+1 -2
View File
@@ -17,8 +17,6 @@ class Create extends Component
public function mount()
{
$this->authorize('createAnyResource');
$type = str(request()->query('type'));
$destination_uuid = request()->query('destination');
$database_image = request()->query('database_image');
@@ -32,6 +30,7 @@ class Create extends Component
if (! $environment) {
return redirect()->route('dashboard');
}
$this->authorize('createAnyResource', [$environment]);
if (isset($type) && isset($destination_uuid)) {
$destination = find_resource_destination_for_current_team($destination_uuid);
if (! $destination) {
@@ -300,7 +300,7 @@ class All extends Component
private function canViewEnvironmentValues(): bool
{
return auth()->user()?->can('manageEnvironment', $this->resource) ?? false;
return auth()->user()?->can('viewSensitive', $this->resource) ?? false;
}
private function nullLockedValues($envs)
@@ -846,8 +846,7 @@ class All extends Component
}
// Otherwise keep order from docker-compose file
// Compose content is visible only to users who can edit the resource.
$canViewValues = auth()->user()?->can('update', $this->resource) ?? false;
$canViewValues = $this->canViewEnvironmentValues();
return $hardcodedVars->map(fn (array $variable): array => [
...$variable,
@@ -161,7 +161,7 @@ class Show extends Component
*/
public function loadValues(): void
{
$this->authorize('update', $this->env);
$this->authorize('viewSensitive', $this->env);
if ($this->valuesLoaded) {
return;
@@ -283,7 +283,7 @@ class Show extends Component
private function valuesHiddenForUser(): bool
{
return auth()->user()?->cannot('update', $this->env) ?? true;
return auth()->user()?->cannot('viewSensitive', $this->env) ?? true;
}
public function checkEnvs()
+4 -5
View File
@@ -60,11 +60,10 @@ class Show extends Component
public function render(): View
{
$canUpdateProject = auth()->user()->can('update', $this->project);
$canCreateResource = auth()->user()->can('createAnyResource');
$user = auth()->user();
return view('livewire.project.show', [
'environmentsJs' => $this->project->environments->map(function (Environment $environment) use ($canCreateResource, $canUpdateProject): array {
'environmentsJs' => $this->project->environments->map(function (Environment $environment) use ($user): array {
$resourceCount = collect([
$environment->applications_count,
$environment->services_count,
@@ -88,13 +87,13 @@ class Show extends Component
'project_uuid' => $this->project->uuid,
'environment_uuid' => $environment->uuid,
]),
'settingsHref' => $canUpdateProject
'settingsHref' => $user->can('update', $environment)
? route('project.environment.edit', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $environment->uuid,
])
: null,
'addResourceHref' => $canCreateResource
'addResourceHref' => $user->can('createAnyResource', [$environment])
? route('project.resource.create', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $environment->uuid,
+125 -20
View File
@@ -2,9 +2,17 @@
namespace App\Livewire\Security;
use App\Enums\AccessLevel;
use App\Enums\AccessScope;
use App\Models\Environment;
use App\Models\Project;
use App\Services\Access\AccessResolver;
use App\Services\Access\Grant;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Laravel\Sanctum\PersonalAccessToken;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Locked;
use Livewire\Component;
@@ -26,6 +34,18 @@ class ApiTokens extends Component
public array $permissions = ['read'];
/**
* 'team' = every project and resource of the team (scopes null), 'selected' = only $selectedScopes.
*/
public string $scopeMode = 'team';
/**
* Picked scopes as "project:<uuid>", "environment:<uuid>", or "resource:<uuid>".
*
* @var list<string>
*/
public array $selectedScopes = [];
public array $expirationOptions = [
7 => '7 days',
30 => '30 days',
@@ -46,9 +66,49 @@ class ApiTokens extends Component
#[Locked]
public bool $canUseSensitivePermissions = false;
#[Locked]
public bool $canUseDeletePermissions = false;
#[Locked]
public bool $canUseTeamScope = false;
public function render()
{
return view('livewire.security.api-tokens');
return view('livewire.security.api-tokens', [
'scopeLabels' => collect($this->scopeOptions)->reject(fn (array $option) => $option['header'] ?? false)->pluck('label', 'value'),
]);
}
/**
* Projects, environments, and resources of the current team that the user can see, for the scope picker.
*
* @return list<array{value: string, label: string, header?: bool}>
*/
#[Computed]
public function scopeOptions(): array
{
$projects = Project::query()->where('team_id', currentTeam()->id)->orderBy('name')->get(['id', 'uuid', 'name']);
$environments = Environment::query()->whereIn('project_id', $projects->pluck('id'))->get(['id', 'uuid', 'name', 'project_id']);
$projectNames = $projects->pluck('name', 'id');
$environmentPaths = $environments->mapWithKeys(fn (Environment $environment) => [
$environment->id => $projectNames[$environment->project_id].' / '.$environment->name,
]);
$resources = collect(AccessResolver::RESOURCE_MODELS)
->flatMap(fn (string $model) => $model::query()->whereIn('environment_id', $environments->pluck('id'))->get(['uuid', 'name', 'environment_id']))
->map(fn ($resource) => ['value' => "resource:{$resource->uuid}", 'label' => $environmentPaths[$resource->environment_id].' / '.$resource->name])
->sortBy('label', SORT_NATURAL | SORT_FLAG_CASE)
->values();
return [
...($projects->isEmpty() ? [] : [['value' => '__projects', 'label' => 'Projects', 'header' => true]]),
...$projects->map(fn (Project $project) => ['value' => "project:{$project->uuid}", 'label' => $project->name])->all(),
...($environments->isEmpty() ? [] : [['value' => '__environments', 'label' => 'Environments', 'header' => true]]),
...$environments->map(fn (Environment $environment) => ['value' => "environment:{$environment->uuid}", 'label' => $environmentPaths[$environment->id]])
->sortBy('label', SORT_NATURAL | SORT_FLAG_CASE)->values()->all(),
...($resources->isEmpty() ? [] : [['value' => '__resources', 'label' => 'Resources', 'header' => true]]),
...$resources->all(),
];
}
public function mount()
@@ -57,6 +117,9 @@ class ApiTokens extends Component
$this->canUseWritePermissions = auth()->user()->can('useWritePermissions', PersonalAccessToken::class);
$this->canUseDeployPermissions = auth()->user()->can('useDeployPermissions', PersonalAccessToken::class);
$this->canUseSensitivePermissions = auth()->user()->can('useSensitivePermissions', PersonalAccessToken::class);
$this->canUseDeletePermissions = auth()->user()->can('useDeletePermissions', PersonalAccessToken::class);
$this->canUseTeamScope = app(AccessResolver::class)->coversWholeTeam(auth()->user(), currentTeam()->id);
$this->scopeMode = $this->canUseTeamScope ? 'team' : 'selected';
$this->getTokens();
}
@@ -99,6 +162,13 @@ class ApiTokens extends Component
return;
}
if ($permissionToUpdate == 'delete' && ! auth()->user()->can('useDeletePermissions', PersonalAccessToken::class)) {
$this->dispatch('error', 'You do not have permission to use delete permissions.');
$this->permissions = array_diff($this->permissions, ['delete']);
return;
}
if ($permissionToUpdate == 'root' && in_array('root', $this->permissions, true)) {
$this->permissions = ['root'];
} elseif ($permissionToUpdate == 'read:sensitive' && ! in_array('read', $this->permissions, true)) {
@@ -120,35 +190,45 @@ class ApiTokens extends Component
'description' => 'required|min:3|max:255',
'expiresInDays' => 'nullable|integer|in:7,30,60,90,365',
'permissions' => 'required|array|min:1',
'permissions.*' => ['required', 'string', Rule::in(['read', 'read:sensitive', 'write', 'write:sensitive', 'deploy', 'root'])],
'permissions.*' => ['required', 'string', Rule::in(['read', 'read:sensitive', 'write', 'write:sensitive', 'deploy', 'delete', 'root'])],
'scopeMode' => ['required', Rule::in(['team', 'selected'])],
'selectedScopes' => ['exclude_unless:scopeMode,selected', 'required', 'array', 'min:1'],
'selectedScopes.*' => ['string', 'regex:/^(project|environment|resource):[A-Za-z0-9_-]+$/'],
], [
'selectedScopes.required' => 'Select at least one project, environment, or resource.',
]);
$permissions = array_values($validated['permissions']);
$teamId = currentTeam()->id;
$scopes = $this->scopeMode === 'selected' ? $this->parseSelectedScopes($teamId) : null;
// Re-evaluate policies fresh against the current authenticated user.
// Never trust $this->canUse* booleans — they come from the Livewire
// snapshot which can be replayed from another user's session.
if (in_array('root', $permissions, true) && ! auth()->user()->can('useRootPermissions', PersonalAccessToken::class)) {
throw new \Exception('You do not have permission to create tokens with root permissions.');
// Never trust the $this->canUse* booleans: check the current user's access in the team again.
$resolver = app(AccessResolver::class);
$isAdmin = auth()->user()->isAdminOfTeam($teamId);
if (in_array('write:sensitive', $permissions, true) && ! $isAdmin) {
throw new \Exception('You do not have permission to create tokens with write:sensitive permissions.');
}
if (array_intersect(['write', 'write:sensitive'], $permissions) && ! auth()->user()->can('useWritePermissions', PersonalAccessToken::class)) {
throw new \Exception('You do not have permission to create tokens with write permissions.');
}
if (in_array('deploy', $permissions, true) && ! auth()->user()->can('useDeployPermissions', PersonalAccessToken::class)) {
throw new \Exception('You do not have permission to create tokens with deploy permissions.');
}
if (in_array('read:sensitive', $permissions, true) && ! auth()->user()->can('useSensitivePermissions', PersonalAccessToken::class)) {
throw new \Exception('You do not have permission to create tokens with read:sensitive permissions.');
$levels = AccessLevel::parseMany($permissions);
$grants = $scopes === null
? [Grant::team($levels)]
: array_map(fn (Grant $scope) => new Grant($scope->scope, $scope->uuid, $levels), $scopes);
if (! $resolver->userHolds(auth()->user(), $teamId, $grants)) {
throw new \Exception('A token cannot have more access than you have. Choose fewer permissions or narrower projects, environments, or resources.');
}
$expiresAt = filled($validated['expiresInDays'] ?? null) ? now()->addDays((int) $validated['expiresInDays']) : null;
$token = auth()->user()->createToken($this->description, $permissions, $expiresAt);
$storedScopes = $scopes === null ? null : array_map(fn (Grant $scope) => ['scope' => $scope->scope->value, 'uuid' => $scope->uuid], $scopes);
// One transaction, so a token never exists team-wide when saving its scopes fails.
$token = DB::transaction(function () use ($permissions, $expiresAt, $teamId, $storedScopes) {
$token = auth()->user()->createToken($this->description, $permissions, $expiresAt, $teamId);
$token->accessToken->forceFill(['scopes' => $storedScopes])->save();
return $token;
});
auditLog('ui.api_token.created', [
'team_id' => currentTeam()->id,
'team_id' => $teamId,
'api_token_name' => $this->description,
'abilities' => $permissions,
'scopes' => $storedScopes,
'expires_at' => $expiresAt?->toIso8601String(),
]);
$this->getTokens();
@@ -159,6 +239,31 @@ class ApiTokens extends Component
}
}
/**
* @return list<Grant> scopes without levels; a token's levels are its abilities
*/
private function parseSelectedScopes(int $teamId): array
{
$resolver = app(AccessResolver::class);
$scopes = [];
foreach (array_unique($this->selectedScopes) as $selected) {
[$scope, $uuid] = explode(':', $selected, 2);
$grant = new Grant(AccessScope::from($scope), $uuid, []);
$layer = $resolver->resolve($teamId, [new Grant($grant->scope, $uuid, [AccessLevel::Read])]);
$existsInTeam = match ($grant->scope) {
AccessScope::Project => $layer->visibleProjectIds() !== [],
AccessScope::Environment => $layer->fullEnvironmentIds() !== [],
default => $layer->resourceUuids() !== [],
};
if (! $existsInTeam) {
throw new \Exception('A selected project, environment, or resource does not exist in this team.');
}
$scopes[] = $grant;
}
return $scopes;
}
public function revoke(int $id)
{
try {
+1 -1
View File
@@ -69,7 +69,7 @@ class Member extends Component
}
$teamId = currentTeam()->id;
DB::transaction(function () use ($teamId): void {
$this->member->teams()->updateExistingPivot($teamId, ['role' => Role::MEMBER->value]);
$this->member->teams()->updateExistingPivot($teamId, ['role' => Role::MEMBER->value, 'access' => null]);
RevokeUserTeamTokens::forUserTeam($this->member, $teamId);
});
$this->auditRoleUpdate($teamId, Role::MEMBER);
+243
View File
@@ -0,0 +1,243 @@
<?php
namespace App\Livewire\Team\Member;
use App\Enums\AccessLevel;
use App\Enums\AccessScope;
use App\Enums\Role;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Scopes\VisibleThroughAccessGrants;
use App\Models\Team;
use App\Models\User;
use App\Services\Access\AccessResolver;
use App\Services\Access\Grant;
use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Locked;
use Livewire\Attributes\On;
use Livewire\Component;
/**
* Edits the access grants (`team_user.access`) of one member with the `member` role.
*
* The members page renders one editor; a member row opens it with the `open-member-access` event.
* A row target is `team`, or `<scope>:<uuid>` for a project, environment, or resource of the team.
*/
class Access extends Component
{
use AuthorizesRequests;
#[Locked]
public int $teamId;
#[Locked]
public ?int $memberId = null;
public bool $open = false;
/** @var list<array{key: string, target: string, levels: array<string, bool>}> */
public array $grants = [];
public function mount(): void
{
$team = currentTeam();
$this->authorize('manageMembers', $team);
$this->teamId = $team->id;
}
#[On('open-member-access')]
public function openFor(int $memberId): void
{
$this->authorize('manageMembers', Team::findOrFail($this->teamId));
$this->memberId = $memberId;
$this->resetValidation();
$stored = $this->member()->pivot->access;
$grants = $stored === null ? AccessResolver::defaultMemberGrants() : Grant::listFrom($stored);
$this->grants = array_map(
fn (Grant $grant) => $this->row($grant->scope === AccessScope::Team ? 'team' : "{$grant->scope->value}:{$grant->uuid}", $grant->levels),
$grants,
);
$this->open = true;
}
public function addGrant(): void
{
$this->grants[] = $this->row('', []);
}
public function removeGrant(string $key): void
{
$this->grants = array_values(array_filter($this->grants, fn (array $row) => $row['key'] !== $key));
}
public function applyPreset(string $preset, ?string $projectUuid = null): void
{
$this->grants = match ($preset) {
'viewer' => [$this->row('team', [AccessLevel::Read])],
'developer' => [$this->row("project:{$projectUuid}", [AccessLevel::Write, AccessLevel::Deploy])],
'operator' => [$this->row('team', [AccessLevel::Write, AccessLevel::Deploy, AccessLevel::Delete])],
'full' => [$this->row('team', [AccessLevel::Root])],
default => $this->grants,
};
$this->resetValidation();
}
public function save(): void
{
$this->authorize('manageMembers', Team::findOrFail($this->teamId));
$member = $this->member();
$validTargets = collect($this->scopeOptions)->reject(fn (array $option) => $option['header'] ?? false)->pluck('value')->all();
$grants = [];
$errors = [];
foreach ($this->grants as $index => $row) {
$target = (string) ($row['target'] ?? '');
$levels = array_values(array_filter(AccessLevel::cases(), fn (AccessLevel $level) => (bool) ($row['levels'][$level->name] ?? false)));
if (! in_array($target, $validTargets, true)) {
$errors["grants.{$index}.target"] = 'Choose a scope in this team.';
}
if ($levels === []) {
$errors["grants.{$index}.levels"] = 'Choose at least one level.';
}
if (isset($errors["grants.{$index}.target"]) || isset($errors["grants.{$index}.levels"])) {
continue;
}
[$scope, $uuid] = $target === 'team' ? ['team', null] : explode(':', $target, 2);
$grants[] = new Grant(AccessScope::from($scope), $uuid, $levels);
}
if ($errors !== []) {
throw ValidationException::withMessages($errors);
}
$member->teams()->updateExistingPivot($this->teamId, ['access' => json_encode(Grant::listToArray($grants))]);
app(AccessResolver::class)->flush();
auditLog('ui.team_member.access_updated', [
'team_id' => $this->teamId,
'member_id' => $member->id,
'member_name' => $member->name,
'member_email' => $member->email,
'access' => Grant::listToArray($grants),
]);
$this->open = false;
$this->dispatch('success', 'Access saved.');
$this->dispatch('reloadWindow');
}
/**
* Every project, environment, and resource of the team, for the scope picker.
* Queries skip the access-grant scope: only owners and admins open this editor, and they see the whole team.
*
* @return list<array{value: string, label: string, header?: bool}>
*/
#[Computed]
public function scopeOptions(): array
{
$projects = Project::withoutGlobalScope(VisibleThroughAccessGrants::class)
->where('team_id', $this->teamId)
->orderBy('name')
->get(['id', 'uuid', 'name']);
$environments = Environment::withoutGlobalScope(VisibleThroughAccessGrants::class)
->whereIn('project_id', $projects->pluck('id'))
->get(['id', 'uuid', 'name', 'project_id']);
$projectNames = $projects->pluck('name', 'id');
$environmentPaths = $environments->mapWithKeys(fn (Environment $environment) => [
$environment->id => $projectNames[$environment->project_id].' / '.$environment->name,
]);
$resources = collect(AccessResolver::RESOURCE_MODELS)
->flatMap(fn (string $model) => $model::withoutGlobalScope(VisibleThroughAccessGrants::class)
->whereIn('environment_id', $environments->pluck('id'))
->get(['uuid', 'name', 'environment_id']))
->map(fn ($resource) => ['value' => "resource:{$resource->uuid}", 'label' => $environmentPaths[$resource->environment_id].' / '.$resource->name])
->sortBy('label', SORT_NATURAL | SORT_FLAG_CASE)
->values();
return [
['value' => '__team', 'label' => 'Team', 'header' => true],
['value' => 'team', 'label' => 'Whole team'],
...($projects->isEmpty() ? [] : [['value' => '__projects', 'label' => 'Projects', 'header' => true]]),
...$projects->map(fn (Project $project) => ['value' => "project:{$project->uuid}", 'label' => $project->name])->all(),
...($environments->isEmpty() ? [] : [['value' => '__environments', 'label' => 'Environments', 'header' => true]]),
...$environments->map(fn (Environment $environment) => ['value' => "environment:{$environment->uuid}", 'label' => $environmentPaths[$environment->id]])
->sortBy('label', SORT_NATURAL | SORT_FLAG_CASE)->values()->all(),
...($resources->isEmpty() ? [] : [['value' => '__resources', 'label' => 'Resources', 'header' => true]]),
...$resources->all(),
];
}
/**
* Short description of a member's access for the members list.
*/
public static function summary(mixed $storedAccess): string
{
if ($storedAccess === null) {
return 'Team-wide read (default)';
}
$grants = array_filter(Grant::listFrom($storedAccess), fn (Grant $grant) => $grant->levels !== []);
if ($grants === []) {
return 'No access';
}
$parts = [];
foreach ($grants as $grant) {
if ($grant->scope === AccessScope::Team) {
$parts[] = 'Team: '.implode(', ', array_map(fn (AccessLevel $level) => Str::lower($level->label()), $grant->levels));
}
}
$counts = array_count_values(array_map(fn (Grant $grant) => $grant->scope->value, $grants));
foreach ([AccessScope::Project, AccessScope::Environment, AccessScope::Resource] as $scope) {
if (isset($counts[$scope->value])) {
$parts[] = $counts[$scope->value].' '.Str::plural($scope->value, $counts[$scope->value]);
}
}
return implode(' + ', $parts);
}
public function render(): View
{
if ($this->memberId === null) {
return view('livewire.team.member.access', ['member' => null]);
}
$member = $this->member();
return view('livewire.team.member.access', [
'member' => $member,
'team' => Team::findOrFail($this->teamId),
'currentSummary' => self::summary($member->pivot->access),
'levels' => AccessLevel::cases(),
'projectOptions' => collect($this->scopeOptions)->filter(fn (array $option) => str_starts_with($option['value'], 'project:'))->values(),
]);
}
private function member(): User
{
$member = Team::findOrFail($this->teamId)->members()->whereKey($this->memberId)->first();
abort_if($member === null || $member->pivot->role !== Role::MEMBER->value, 404);
return $member;
}
/**
* @param list<AccessLevel> $levels
* @return array{key: string, target: string, levels: array<string, bool>}
*/
private function row(string $target, array $levels): array
{
return [
'key' => Str::random(10),
'target' => $target,
'levels' => collect(AccessLevel::cases())->mapWithKeys(fn (AccessLevel $level) => [$level->name => in_array($level, $levels, true)])->all(),
];
}
}
+27 -31
View File
@@ -2,24 +2,15 @@
namespace App\Mcp\Concerns;
use App\Http\Middleware\EnsureTokenBelongsToCurrentTeamMember;
use App\Models\Server;
use App\Services\Access\AccessResolver;
use Illuminate\Database\Eloquent\Builder;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
trait ResolvesTeam
{
/**
* Abilities that team members must not exercise (parity with ApiAbility).
*
* @var array<int, string>
*/
private const MEMBER_DISALLOWED_ABILITIES = [
'root',
'write',
'write:sensitive',
'deploy',
'read:sensitive',
];
protected function ensureAbility(Request $request, string $ability = 'read', ?string $tool = null): ?Response
{
$user = $request->user();
@@ -30,30 +21,20 @@ trait ResolvesTeam
}
$token = $user->currentAccessToken();
if (! $token) {
if (! $token || $token->team_id === null) {
$this->auditMcpTool($request, $tool, 'denied', ['reason' => 'invalid_token']);
return Response::error('Invalid token.');
}
$teamId = $token->team_id;
if ($teamId !== null) {
// Fresh pivot lookup (avoid stale $user->teams cache after role changes).
$role = $user->teams()->where('teams.id', $teamId)->first()?->pivot?->role;
$isAdminOrOwner = in_array($role, ['admin', 'owner'], true);
// Same check as api.token.team, for tools that run without the HTTP middleware.
if (! EnsureTokenBelongsToCurrentTeamMember::tokenWithinCreatorAccess($user, $token, (int) $token->team_id)) {
$this->auditMcpTool($request, $tool, 'denied', [
'reason' => 'token_exceeds_access',
'required_ability' => $ability,
]);
if (! $isAdminOrOwner) {
$tokenAbilities = $token->abilities ?? [];
$disallowed = array_intersect($tokenAbilities, self::MEMBER_DISALLOWED_ABILITIES);
if ($disallowed !== [] || in_array($ability, self::MEMBER_DISALLOWED_ABILITIES, true)) {
$this->auditMcpTool($request, $tool, 'denied', [
'reason' => 'member_role',
'required_ability' => $ability,
]);
return Response::error('Missing required team role.');
}
}
return Response::error(EnsureTokenBelongsToCurrentTeamMember::EXCEEDS_ACCESS_MESSAGE);
}
if ($token->can('root') || $token->can($ability)) {
@@ -93,6 +74,21 @@ trait ResolvesTeam
return (int) $teamId;
}
/**
* Servers of the team, or none for a token or member limited to some projects, environments, or resources.
*
* @return Builder<Server>
*/
protected function visibleServers(Request $request, int $teamId): Builder
{
$query = Server::whereTeamId($teamId);
if (! app(AccessResolver::class)->coversWholeTeam($request->user(), $teamId)) {
$query->whereRaw('1 = 0');
}
return $query;
}
protected function mcpSuccess(Request $request, Response $response, array $context = []): Response
{
$this->auditMcpTool($request, $this->name ?? null, 'success', $context);
@@ -32,7 +32,7 @@ class InfrastructureOverviewResource extends Resource
return Response::error('Invalid token.');
}
$servers = Server::whereTeamId($teamId)
$servers = $this->visibleServers($request, $teamId)
->with('settings:id,server_id,is_reachable,is_usable')
->get()
->map(fn (Server $s) => [
+59 -1
View File
@@ -83,7 +83,9 @@ use App\Mcp\Tools\ManageStorage;
use App\Mcp\Tools\ManageTags;
use App\Mcp\Tools\RollbackApplication;
use App\Mcp\Tools\SearchResources;
use App\Services\Access\AccessResolver;
use Laravel\Mcp\Server;
use Laravel\Mcp\Server\ServerContext;
use Laravel\Mcp\Server\Tool;
class CoolifyServer extends Server
@@ -117,7 +119,7 @@ Lifecycle (requires token ability **deploy**):
- control (start|stop|restart; stop needs confirm=true)
- deploy, cancel_deployment, rollback_application
Changes (requires token ability **write**): manage_* and create_* tools. They run the same REST API routes, with the same validation and permissions. Destructive actions need confirm=true: ask the user first.
Changes (requires token ability **write**, or **delete** for delete actions): manage_* and create_* tools. They run the same REST API routes, with the same validation and permissions. Destructive actions need confirm=true: ask the user first.
Prompts: troubleshoot_application, explain_failed_deploy.
Resources: coolify://overview, coolify://application/{uuid}.
@@ -210,8 +212,64 @@ MD;
GetServerMetrics::class,
];
/**
* Tools for team-level objects. Tokens and members limited to some projects, environments,
* or resources do not get them (same rule as ApiAbility::SCOPED_ACCESS_ROUTES for REST).
*
* @var array<int, class-string<Tool>>
*/
public const TEAM_LEVEL_TOOLS = [
GetCurrentTeam::class,
ListTeamMembers::class,
ListServers::class,
GetServer::class,
GetServerDomains::class,
GetServerResources::class,
GetServerMetrics::class,
ListDestinations::class,
GetDestination::class,
ListTags::class,
ListGithubApps::class,
ListGithubRepositories::class,
ListGithubBranches::class,
ListAuditEvents::class,
ManageTags::class,
ManageServer::class,
ManageProxy::class,
DockerCleanup::class,
ManageDestination::class,
ManageRegistry::class,
ManagePrivateKey::class,
ManageS3Storage::class,
ManageGitSource::class,
CloudProvider::class,
ManageNotifications::class,
];
protected array $tools = self::TOOLS;
/**
* @return array<int, class-string<Tool>>
*/
public static function toolsForCurrentActor(): array
{
$user = request()->user();
$teamId = $user?->currentAccessToken()?->team_id;
if ($user !== null && $teamId !== null && app(AccessResolver::class)->coversWholeTeam($user, (int) $teamId)) {
return self::TOOLS;
}
return array_values(array_diff(self::TOOLS, self::TEAM_LEVEL_TOOLS));
}
public function createContext(): ServerContext
{
$this->tools = self::toolsForCurrentActor();
return parent::createContext();
}
protected array $resources = [
InfrastructureOverviewResource::class,
ApplicationResource::class,
+1 -1
View File
@@ -51,7 +51,7 @@ class CloudProvider extends RestActionTool
'get_token' => ['method' => 'GET', 'path' => 'cloud-tokens/{uuid}', 'ability' => 'read', 'description' => 'one cloud provider token (no token value)'],
'create_token' => ['method' => 'POST', 'path' => 'cloud-tokens', 'ability' => 'write', 'description' => 'add a provider token; it is checked with the provider first (data: provider hetzner|digitalocean|vultr|hostinger, token, name)'],
'update_token' => ['method' => 'PATCH', 'path' => 'cloud-tokens/{uuid}', 'ability' => 'write', 'description' => 'rename a provider token (data: name)'],
'delete_token' => ['method' => 'DELETE', 'path' => 'cloud-tokens/{uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete a provider token that no server uses'],
'delete_token' => ['method' => 'DELETE', 'path' => 'cloud-tokens/{uuid}', 'ability' => 'delete', 'confirm' => true, 'description' => 'delete a provider token that no server uses'],
'validate_token' => ['method' => 'POST', 'path' => 'cloud-tokens/{uuid}/validate', 'ability' => 'write', 'description' => 'check a stored provider token with the provider'],
'catalog' => ['method' => 'GET', 'paths' => self::CATALOG_PATHS, 'ability' => 'read', 'description' => 'read a provider catalog; resource is <provider>_<catalog>, for example hetzner_locations (data: cloud_provider_token_uuid)'],
'provision_server' => ['method' => 'POST', 'paths' => self::PROVISION_PATHS, 'ability' => 'write', 'confirm' => true, 'description' => 'create a new paid server at the provider and add it to Coolify; resource is the provider (data: cloud_provider_token_uuid, private_key_uuid, name, and the provider fields, for example location, server_type, image for hetzner; a cloud-init or post-install script or extra SSH keys need deploy ability)'],
+1 -1
View File
@@ -157,7 +157,7 @@ class CoolifyHelp extends Tool
*/
private function visibleTools(): array
{
return collect(CoolifyServer::TOOLS)
return collect(CoolifyServer::toolsForCurrentActor())
->map(fn (string $class): Tool => app($class))
->filter(fn (Tool $tool): bool => $tool->eligibleForRegistration())
->mapWithKeys(fn (Tool $tool): array => [$tool->name() => [
+2 -3
View File
@@ -9,7 +9,6 @@ use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Collection;
@@ -40,7 +39,7 @@ class GetInfrastructureOverview extends Tool
return $this->mcpError($request, 'Invalid token.');
}
$servers = Server::whereTeamId($teamId)
$servers = $this->visibleServers($request, $teamId)
->select('id', 'name', 'uuid', 'ip', 'description')
->with('settings:id,server_id,is_reachable,is_usable')
->get();
@@ -56,7 +55,7 @@ class GetInfrastructureOverview extends Tool
->values()
->all();
$unreachableServers = Server::whereTeamId($teamId)
$unreachableServers = $this->visibleServers($request, $teamId)
->whereHas('settings', fn ($q) => $q->where('is_reachable', false))
->count();
+3 -2
View File
@@ -2,6 +2,7 @@
namespace App\Mcp\Tools;
use App\Enums\AccessLevel;
use App\Mcp\Concerns\BuildsResponse;
use App\Mcp\Concerns\ResolvesTeam;
use App\Models\Environment;
@@ -46,7 +47,7 @@ class ListSharedEnvKeys extends Tool
if ($scope === 'project') {
$project = Project::where('team_id', $teamId)->where('uuid', $uuid)->first();
if (! $project) {
if (! $project || ! $request->user()->canAccess(AccessLevel::Read, $project)) {
return $this->mcpError($request, "Project [{$uuid}] not found.", ['resource_uuid' => $uuid]);
}
@@ -78,7 +79,7 @@ class ListSharedEnvKeys extends Tool
}
}
if (! $environment || (int) $environment->project?->team_id !== $teamId) {
if (! $environment || (int) $environment->project?->team_id !== $teamId || ! $request->user()->canAccess(AccessLevel::Read, $environment)) {
return $this->mcpError($request, "Environment [{$uuid}] not found.", ['resource_uuid' => $uuid]);
}
+1 -1
View File
@@ -50,7 +50,7 @@ class ListUnhealthyResources extends Tool
$args = $this->paginationArgs($request);
// --- Servers (small set; filter via settings) ---
$unhealthyServers = Server::whereTeamId($teamId)
$unhealthyServers = $this->visibleServers($request, $teamId)
->with('settings:id,server_id,is_reachable,is_usable')
->whereHas('settings', function ($q) {
$q->where('is_reachable', false)->orWhere('is_usable', false);
+3 -3
View File
@@ -11,17 +11,17 @@ class ManageApplication extends RestActionTool
{
protected string $name = 'manage_application';
protected string $description = 'Update, delete, clone, or move an application of the authenticated team, or add or remove an additional server destination. Requires write ability; delete, instant_deploy, manual webhook secrets, and clone_volumes also need deploy. Same validation and permissions as the REST API.';
protected string $description = 'Update, delete, clone, or move an application of the authenticated team, or add or remove an additional server destination. Requires write ability, and delete ability for delete; instant_deploy, manual webhook secrets, and clone_volumes also need deploy. Same validation and permissions as the REST API.';
protected function actions(): array
{
return [
'update' => ['method' => 'PATCH', 'path' => 'applications/{uuid}', 'ability' => 'write', 'description' => 'update application settings (data: same fields as the REST update, e.g. name, domains, git_branch, ports_exposes, build_command; instant_deploy and manual_webhook_secret_* need deploy ability)'],
'delete' => ['method' => 'DELETE', 'path' => 'applications/{uuid}', 'ability' => 'deploy', 'confirm' => true, 'description' => 'delete the application and its containers (data: delete_volumes, delete_connected_networks, delete_configurations, docker_cleanup)'],
'delete' => ['method' => 'DELETE', 'path' => 'applications/{uuid}', 'ability' => 'delete', 'confirm' => true, 'description' => 'delete the application and its containers (data: delete_volumes, delete_connected_networks, delete_configurations, docker_cleanup)'],
'clone' => ['method' => 'POST', 'path' => 'applications/{uuid}/clone', 'ability' => 'write', 'description' => 'clone the application to a destination of the team (data: destination_uuid, name, clone_volumes; clone_volumes needs deploy ability)'],
'move' => ['method' => 'POST', 'path' => 'applications/{uuid}/move', 'ability' => 'write', 'confirm' => true, 'description' => 'move the application to another environment of the team (data: environment_uuid)'],
'add_server' => ['method' => 'POST', 'path' => 'applications/{uuid}/destinations', 'ability' => 'write', 'description' => 'attach an additional destination on another server (data: destination_uuid)'],
'remove_server' => ['method' => 'DELETE', 'path' => 'applications/{uuid}/destinations/{destination_uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'detach an additional destination'],
'remove_server' => ['method' => 'DELETE', 'path' => 'applications/{uuid}/destinations/{destination_uuid}', 'ability' => 'delete', 'confirm' => true, 'description' => 'detach an additional destination'],
];
}
+2 -2
View File
@@ -18,8 +18,8 @@ class ManageBackup extends RestActionTool
return [
'create' => ['method' => 'POST', 'path' => 'databases/{uuid}/backups', 'ability' => 'write', 'description' => 'create a backup schedule (data: frequency, enabled, save_s3, s3_storage_uuid(s), retention fields, backup_now ...; backup_now needs deploy ability)'],
'update' => ['method' => 'PATCH', 'path' => 'databases/{uuid}/backups/{scheduled_backup_uuid}', 'ability' => 'write', 'description' => 'update a backup schedule; data.backup_now=true runs a backup now and needs deploy ability'],
'delete' => ['method' => 'DELETE', 'path' => 'databases/{uuid}/backups/{scheduled_backup_uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete a backup schedule and all its backup files (data.delete_s3=true also deletes S3 copies)'],
'delete_execution' => ['method' => 'DELETE', 'path' => 'databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete one backup execution and its file (data.delete_s3=true also deletes the S3 copy)'],
'delete' => ['method' => 'DELETE', 'path' => 'databases/{uuid}/backups/{scheduled_backup_uuid}', 'ability' => 'delete', 'confirm' => true, 'description' => 'delete a backup schedule and all its backup files (data.delete_s3=true also deletes S3 copies)'],
'delete_execution' => ['method' => 'DELETE', 'path' => 'databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}', 'ability' => 'delete', 'confirm' => true, 'description' => 'delete one backup execution and its file (data.delete_s3=true also deletes the S3 copy)'],
];
}
+2 -2
View File
@@ -11,13 +11,13 @@ class ManageDatabase extends RestActionTool
{
protected string $name = 'manage_database';
protected string $description = 'Update, delete, clone, move, or import a backup into a standalone database of the authenticated team. Requires write ability; delete and import also need deploy, and so do instant_deploy, changing is_public, and clone_volumes. Same validation and permissions as the REST API.';
protected string $description = 'Update, delete, clone, move, or import a backup into a standalone database of the authenticated team. Requires write ability, and delete ability for delete; import also needs deploy, and so do instant_deploy, changing is_public, and clone_volumes. Same validation and permissions as the REST API.';
protected function actions(): array
{
return [
'update' => ['method' => 'PATCH', 'path' => 'databases/{uuid}', 'ability' => 'write', 'description' => 'update database settings (data: name, description, image, is_public, public_port, public_port_timeout, limits_*, type credentials and *_conf fields, redact_all_env_values_in_logs, instant_deploy; instant_deploy and changing is_public need deploy ability)'],
'delete' => ['method' => 'DELETE', 'path' => 'databases/{uuid}', 'ability' => 'deploy', 'confirm' => true, 'description' => 'delete the database (data: delete_volumes, delete_connected_networks, delete_configurations, docker_cleanup; all default true)'],
'delete' => ['method' => 'DELETE', 'path' => 'databases/{uuid}', 'ability' => 'delete', 'confirm' => true, 'description' => 'delete the database (data: delete_volumes, delete_connected_networks, delete_configurations, docker_cleanup; all default true)'],
'clone' => ['method' => 'POST', 'path' => 'databases/{uuid}/clone', 'ability' => 'write', 'confirm' => true, 'description' => 'clone the database to a destination of the team (data: destination_uuid, name, clone_volumes; clone_volumes stops the source database while volumes are copied and needs deploy ability)'],
'move' => ['method' => 'POST', 'path' => 'databases/{uuid}/move', 'ability' => 'write', 'confirm' => true, 'description' => 'move the database to another environment of the team (data: environment_uuid)'],
'import' => ['method' => 'POST', 'path' => 'databases/{uuid}/imports', 'ability' => 'deploy', 'confirm' => true, 'description' => 'import a backup into the database, can overwrite data (data: source s3 | server, s3_storage_uuid, path, dump_all, replace_existing, keep_owners, restore_mysql_users, sqlite_database); check progress with get_database_import'],
+1 -1
View File
@@ -18,7 +18,7 @@ class ManageDestination extends RestActionTool
return [
'create' => ['method' => 'POST', 'path' => 'servers/{server_uuid}/destinations', 'ability' => 'write', 'description' => 'create a destination on a server (data: network, name, type standalone|swarm)'],
'update' => ['method' => 'PATCH', 'path' => 'destinations/{uuid}', 'ability' => 'write', 'description' => 'rename a destination (data: name)'],
'delete' => ['method' => 'DELETE', 'path' => 'destinations/{uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete a destination without resources and remove its Docker network'],
'delete' => ['method' => 'DELETE', 'path' => 'destinations/{uuid}', 'ability' => 'delete', 'confirm' => true, 'description' => 'delete a destination without resources and remove its Docker network'],
];
}
+1 -1
View File
@@ -35,7 +35,7 @@ class ManageEnv extends RestActionTool
'application' => 'applications/{uuid}/envs/{env_uuid}',
'database' => 'databases/{uuid}/envs/{env_uuid}',
'service' => 'services/{uuid}/envs/{env_uuid}',
], 'ability' => 'write', 'confirm' => true, 'description' => 'delete a variable by UUID'],
], 'ability' => 'delete', 'confirm' => true, 'description' => 'delete a variable by UUID'],
];
}
+1 -1
View File
@@ -19,7 +19,7 @@ class ManageGitSource extends RestActionTool
'list' => ['method' => 'GET', 'paths' => ['github' => 'github-apps', 'gitlab' => 'gitlab-apps'], 'ability' => 'read', 'description' => 'git apps of the team and system-wide apps'],
'create' => ['method' => 'POST', 'paths' => ['github' => 'github-apps', 'gitlab' => 'gitlab-apps'], 'ability' => 'write', 'description' => 'add a git app (github data: name, html_url, api_url, app_id, installation_id, client_id, client_secret, webhook_secret, private_key_uuid, organization, custom_user, custom_port; gitlab data: name, html_url, api_url, group_name, client_id, client_secret, webhook_token, redirect_uri, custom_user, custom_port)'],
'update' => ['method' => 'PATCH', 'paths' => ['github' => 'github-apps/{source_id}', 'gitlab' => 'gitlab-apps/{source_id}'], 'ability' => 'write', 'description' => 'update a git app of the team (data: same fields as create, all optional)'],
'delete' => ['method' => 'DELETE', 'paths' => ['github' => 'github-apps/{source_id}', 'gitlab' => 'gitlab-apps/{source_id}'], 'ability' => 'write', 'confirm' => true, 'description' => 'delete a git app of the team that no application uses'],
'delete' => ['method' => 'DELETE', 'paths' => ['github' => 'github-apps/{source_id}', 'gitlab' => 'gitlab-apps/{source_id}'], 'ability' => 'delete', 'confirm' => true, 'description' => 'delete a git app of the team that no application uses'],
];
}
+2 -2
View File
@@ -11,14 +11,14 @@ class ManagePreview extends RestActionTool
{
protected string $name = 'manage_preview';
protected string $description = 'Create (and deploy), update the domains of, or delete a pull request preview deployment of an application of the authenticated team. create and delete require deploy ability, update requires write. Same validation and permissions as the REST API.';
protected string $description = 'Create (and deploy), update the domains of, or delete a pull request preview deployment of an application of the authenticated team. create requires deploy ability, update requires write, delete requires delete. Same validation and permissions as the REST API.';
protected function actions(): array
{
return [
'create' => ['method' => 'POST', 'path' => 'applications/{uuid}/previews', 'ability' => 'deploy', 'description' => 'open or redeploy a preview (data: pull_request_id, pull_request_html_url, git_type, commit, docker_tag, force, instant_deploy)'],
'update' => ['method' => 'PATCH', 'path' => 'applications/{uuid}/previews/{pull_request_id}', 'ability' => 'write', 'description' => 'replace preview domains (data: domains, or docker_compose_domains for compose apps, force_domain_override)'],
'delete' => ['method' => 'DELETE', 'path' => 'applications/{uuid}/previews/{pull_request_id}', 'ability' => 'deploy', 'confirm' => true, 'description' => 'delete the preview and its containers'],
'delete' => ['method' => 'DELETE', 'path' => 'applications/{uuid}/previews/{pull_request_id}', 'ability' => 'delete', 'confirm' => true, 'description' => 'delete the preview and its containers'],
];
}
+1 -1
View File
@@ -20,7 +20,7 @@ class ManagePrivateKey extends RestActionTool
'get' => ['method' => 'GET', 'path' => 'security/keys/{uuid}', 'ability' => 'read', 'description' => 'one private key (no key material)'],
'create' => ['method' => 'POST', 'path' => 'security/keys', 'ability' => 'write', 'description' => 'add a private key (data: private_key PEM or base64, name, description)'],
'update' => ['method' => 'PATCH', 'path' => 'security/keys/{uuid}', 'ability' => 'write', 'description' => 'update a private key (data: name, description; private_key only to replace the key)'],
'delete' => ['method' => 'DELETE', 'path' => 'security/keys/{uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete a private key that is not in use'],
'delete' => ['method' => 'DELETE', 'path' => 'security/keys/{uuid}', 'ability' => 'delete', 'confirm' => true, 'description' => 'delete a private key that is not in use'],
];
}
+2 -2
View File
@@ -18,10 +18,10 @@ class ManageProject extends RestActionTool
return [
'create' => ['method' => 'POST', 'path' => 'projects', 'ability' => 'write', 'description' => 'create a project (data: name, description)'],
'update' => ['method' => 'PATCH', 'path' => 'projects/{uuid}', 'ability' => 'write', 'description' => 'update a project (data: name, description)'],
'delete' => ['method' => 'DELETE', 'path' => 'projects/{uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete an empty project'],
'delete' => ['method' => 'DELETE', 'path' => 'projects/{uuid}', 'ability' => 'delete', 'confirm' => true, 'description' => 'delete an empty project'],
'create_environment' => ['method' => 'POST', 'path' => 'projects/{uuid}/environments', 'ability' => 'write', 'description' => 'create an environment (data: name)'],
'update_environment' => ['method' => 'PATCH', 'path' => 'projects/{uuid}/environments/{environment_name_or_uuid}', 'ability' => 'write', 'description' => 'rename an environment (data: name)'],
'delete_environment' => ['method' => 'DELETE', 'path' => 'projects/{uuid}/environments/{environment_name_or_uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete an empty environment'],
'delete_environment' => ['method' => 'DELETE', 'path' => 'projects/{uuid}/environments/{environment_name_or_uuid}', 'ability' => 'delete', 'confirm' => true, 'description' => 'delete an empty environment'],
];
}
+1 -1
View File
@@ -19,7 +19,7 @@ class ManageRegistry extends RestActionTool
'list' => ['method' => 'GET', 'path' => 'servers/{uuid}/registries', 'ability' => 'read:sensitive', 'description' => 'registry logins on the server (registry and username only)'],
'create' => ['method' => 'POST', 'path' => 'servers/{uuid}/registries', 'ability' => 'deploy', 'description' => 'log in to a registry on the server (data: registry, username, password)'],
'check' => ['method' => 'POST', 'path' => 'servers/{uuid}/registries/{registry}/check', 'ability' => 'write', 'description' => 'check that a registry login works'],
'delete' => ['method' => 'DELETE', 'path' => 'servers/{uuid}/registries/{registry}', 'ability' => 'write', 'confirm' => true, 'description' => 'log out of a registry; private image pulls can fail afterwards'],
'delete' => ['method' => 'DELETE', 'path' => 'servers/{uuid}/registries/{registry}', 'ability' => 'delete', 'confirm' => true, 'description' => 'log out of a registry; private image pulls can fail afterwards'],
];
}
+1 -1
View File
@@ -20,7 +20,7 @@ class ManageS3Storage extends RestActionTool
'get' => ['method' => 'GET', 'path' => 's3-storages/{uuid}', 'ability' => 'read', 'description' => 'one S3 storage'],
'create' => ['method' => 'POST', 'path' => 's3-storages', 'ability' => 'write', 'description' => 'add an S3 storage (data: name, endpoint, bucket, region, key, secret, description, is_usable)'],
'update' => ['method' => 'PATCH', 'path' => 's3-storages/{uuid}', 'ability' => 'write', 'description' => 'update an S3 storage (data: same fields as create, all optional)'],
'delete' => ['method' => 'DELETE', 'path' => 's3-storages/{uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete an S3 storage'],
'delete' => ['method' => 'DELETE', 'path' => 's3-storages/{uuid}', 'ability' => 'delete', 'confirm' => true, 'description' => 'delete an S3 storage'],
'validate' => ['method' => 'POST', 'path' => 's3-storages/{uuid}/validate', 'ability' => 'write', 'description' => 'test the connection and save the result'],
];
}
+1 -1
View File
@@ -27,7 +27,7 @@ class ManageScheduledTask extends RestActionTool
'delete' => ['method' => 'DELETE', 'paths' => [
'application' => 'applications/{uuid}/scheduled-tasks/{task_uuid}',
'service' => 'services/{uuid}/scheduled-tasks/{task_uuid}',
], 'ability' => 'write', 'confirm' => true, 'description' => 'delete a scheduled task'],
], 'ability' => 'delete', 'confirm' => true, 'description' => 'delete a scheduled task'],
'execute' => ['method' => 'POST', 'paths' => [
'application' => 'applications/{uuid}/scheduled-tasks/{task_uuid}/execute',
'service' => 'services/{uuid}/scheduled-tasks/{task_uuid}/execute',
+2 -2
View File
@@ -11,14 +11,14 @@ class ManageServer extends RestActionTool
{
protected string $name = 'manage_server';
protected string $description = 'Add, update, delete, or validate a server of the authenticated team. Requires write ability; changing proxy_type, deleting with force or delete_from_provider, and validating with install also need deploy. Same validation and permissions as the REST API.';
protected string $description = 'Add, update, delete, or validate a server of the authenticated team. Requires write ability, and delete ability for delete; changing proxy_type, deleting with force or delete_from_provider, and validating with install also need deploy. Same validation and permissions as the REST API.';
protected function actions(): array
{
return [
'create' => ['method' => 'POST', 'path' => 'servers', 'ability' => 'write', 'description' => 'add a server (data: ip, private_key_uuid, name, description, port, user, server_role, proxy_type, instant_validate)'],
'update' => ['method' => 'PATCH', 'path' => 'servers/{uuid}', 'ability' => 'write', 'description' => 'update a server (data: name, description, ip, port, user, private_key_uuid, server_role, proxy_type, concurrent_builds, dynamic_timeout, deployment_queue_limit, connection_timeout, is_terminal_enabled, server_disk_usage_*; changing proxy_type needs deploy ability)'],
'delete' => ['method' => 'DELETE', 'path' => 'servers/{uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete a server without resources (the Coolify host cannot be deleted)'],
'delete' => ['method' => 'DELETE', 'path' => 'servers/{uuid}', 'ability' => 'delete', 'confirm' => true, 'description' => 'delete a server without resources (the Coolify host cannot be deleted)'],
'validate' => ['method' => 'POST', 'path' => 'servers/{uuid}/validate', 'ability' => 'write', 'description' => 'start a server validation (data: install=true also installs Docker and needs deploy ability)'],
];
}
+2 -2
View File
@@ -11,13 +11,13 @@ class ManageService extends RestActionTool
{
protected string $name = 'manage_service';
protected string $description = 'Update, delete, clone, or move a service of the authenticated team, update one of its applications or databases, or import a backup into a service database. Requires write ability; delete and import_database also need deploy, and so do instant_deploy, changing is_public of a service database, and clone_volumes. Same validation and permissions as the REST API.';
protected string $description = 'Update, delete, clone, or move a service of the authenticated team, update one of its applications or databases, or import a backup into a service database. Requires write ability, and delete ability for delete; import_database also needs deploy, and so do instant_deploy, changing is_public of a service database, and clone_volumes. Same validation and permissions as the REST API.';
protected function actions(): array
{
return [
'update' => ['method' => 'PATCH', 'path' => 'services/{uuid}', 'ability' => 'write', 'description' => 'update the service (data: name, description, docker_compose_raw base64, connect_to_docker_network, urls, force_domain_override, is_container_label_escape_enabled, redact_all_env_values_in_logs, instant_deploy; instant_deploy needs deploy ability)'],
'delete' => ['method' => 'DELETE', 'path' => 'services/{uuid}', 'ability' => 'deploy', 'confirm' => true, 'description' => 'delete the service (data: delete_volumes, delete_connected_networks, delete_configurations, docker_cleanup, delete_from_coolify_only)'],
'delete' => ['method' => 'DELETE', 'path' => 'services/{uuid}', 'ability' => 'delete', 'confirm' => true, 'description' => 'delete the service (data: delete_volumes, delete_connected_networks, delete_configurations, docker_cleanup, delete_from_coolify_only)'],
'clone' => ['method' => 'POST', 'path' => 'services/{uuid}/clone', 'ability' => 'write', 'confirm' => true, 'description' => 'clone the service to a destination of the team (data: destination_uuid, name, clone_volumes; clone_volumes stops the source service while volumes are copied and needs deploy ability)'],
'move' => ['method' => 'POST', 'path' => 'services/{uuid}/move', 'ability' => 'write', 'confirm' => true, 'description' => 'move the service to another environment of the team (data: environment_uuid)'],
'update_application' => ['method' => 'PATCH', 'path' => 'services/{uuid}/applications/{app_uuid}', 'ability' => 'write', 'description' => 'update a service application (data: url, noindex_domains, human_name, description, image, exclude_from_status, is_log_drain_enabled, is_gzip_enabled, traefik_certificate_resolver, is_stripprefix_enabled, is_force_https_enabled, max_restart_count)'],
+1 -1
View File
@@ -33,7 +33,7 @@ class ManageSharedEnv extends RestActionTool
'project' => 'projects/{uuid}/envs/{env_id}',
'environment' => 'projects/{uuid}/environments/{environment_name_or_uuid}/envs/{env_id}',
'server' => 'servers/{uuid}/envs/{env_id}',
], 'ability' => 'write', 'confirm' => true, 'description' => 'delete a shared variable'],
], 'ability' => 'delete', 'confirm' => true, 'description' => 'delete a shared variable'],
];
}
+2 -2
View File
@@ -18,9 +18,9 @@ class ManageStorage extends RestActionTool
return [
'create' => ['method' => 'POST', 'paths' => $this->paths('storages'), 'ability' => 'write', 'description' => 'create a storage (data: type persistent|file, name, mount_path, content, is_directory, is_host_file, fs_path; services also resource_uuid; file content and an fs_path outside the resource directory need deploy ability)'],
'update' => ['method' => 'PATCH', 'paths' => $this->paths('storages'), 'ability' => 'write', 'description' => 'update a storage (data: uuid of the storage, type, name, mount_path, content, is_preview_suffix_enabled; content of a file outside the resource directory needs deploy ability)'],
'delete' => ['method' => 'DELETE', 'paths' => $this->paths('storages/{storage_uuid}'), 'ability' => 'write', 'confirm' => true, 'description' => 'delete a storage'],
'delete' => ['method' => 'DELETE', 'paths' => $this->paths('storages/{storage_uuid}'), 'ability' => 'delete', 'confirm' => true, 'description' => 'delete a storage'],
'set_backup' => ['method' => 'PUT', 'paths' => $this->paths('storages/{storage_uuid}/backups'), 'ability' => 'write', 'description' => 'create or replace the volume backup schedule (data: frequency, enabled, save_s3, s3_storage_uuid(s), stop_during_backup, retention fields ...; stop_during_backup needs deploy ability)'],
'remove_backup' => ['method' => 'DELETE', 'paths' => $this->paths('storages/{storage_uuid}/backups'), 'ability' => 'write', 'confirm' => true, 'description' => 'delete the volume backup schedule and its local and S3 archives'],
'remove_backup' => ['method' => 'DELETE', 'paths' => $this->paths('storages/{storage_uuid}/backups'), 'ability' => 'delete', 'confirm' => true, 'description' => 'delete the volume backup schedule and its local and S3 archives'],
'run_backup' => ['method' => 'POST', 'paths' => $this->paths('storages/{storage_uuid}/backups/run'), 'ability' => 'deploy', 'confirm' => true, 'description' => 'run the volume backup now (can stop the resource when stop_during_backup is set)'],
];
}
+2 -2
View File
@@ -18,7 +18,7 @@ class ManageTags extends RestActionTool
return [
'create' => ['method' => 'POST', 'path' => 'tags', 'ability' => 'write', 'description' => 'create a team tag (data: name)'],
'update' => ['method' => 'PATCH', 'path' => 'tags/{tag_uuid}', 'ability' => 'write', 'description' => 'rename a team tag (data: name)'],
'delete' => ['method' => 'DELETE', 'path' => 'tags/{tag_uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete a team tag and detach it from all resources'],
'delete' => ['method' => 'DELETE', 'path' => 'tags/{tag_uuid}', 'ability' => 'delete', 'confirm' => true, 'description' => 'delete a team tag and detach it from all resources'],
'attach' => ['method' => 'POST', 'paths' => [
'application' => 'applications/{uuid}/tags',
'database' => 'databases/{uuid}/tags',
@@ -28,7 +28,7 @@ class ManageTags extends RestActionTool
'application' => 'applications/{uuid}/tags/{tag_uuid}',
'database' => 'databases/{uuid}/tags/{tag_uuid}',
'service' => 'services/{uuid}/tags/{tag_uuid}',
], 'ability' => 'write', 'description' => 'detach a tag from the resource'],
], 'ability' => 'delete', 'description' => 'detach a tag from the resource'],
];
}
+1 -2
View File
@@ -7,7 +7,6 @@ use App\Mcp\Concerns\ResolvesTeam;
use App\Models\Application;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
@@ -82,7 +81,7 @@ class SearchResources extends Tool
}
if (in_array('server', $typesList, true)) {
Server::whereTeamId($teamId)
$this->visibleServers($request, $teamId)
->where(function ($q) use ($like, $needle) {
$q->whereRaw('LOWER(name) LIKE ?', [$like])
->orWhere('uuid', $needle)
+2 -1
View File
@@ -23,6 +23,7 @@ use App\Traits\HasNoindexDomains;
use App\Traits\HasSafeStringAttribute;
use App\Traits\HasSecretManager;
use App\Traits\ReleasesManagedDnsRecords;
use App\Traits\ScopedByAccessGrants;
use Database\Factories\ApplicationFactory;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -139,7 +140,7 @@ use Symfony\Component\Yaml\Yaml;
class Application extends BaseModel
{
/** @use HasFactory<ApplicationFactory> */
use Auditable, ClearsGlobalSearchCache, HasComposeVolumeWarnings, HasConfiguration, HasFactory, HasMaintenancePage, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, HasSecretManager, ReleasesManagedDnsRecords, SoftDeletes;
use Auditable, ClearsGlobalSearchCache, HasComposeVolumeWarnings, HasConfiguration, HasFactory, HasMaintenancePage, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, HasSecretManager, ReleasesManagedDnsRecords, ScopedByAccessGrants, SoftDeletes;
public const MAX_DOCKER_COMPOSE_SIZE_BYTES = 5 * 1024 * 1024;
+15
View File
@@ -4,6 +4,7 @@ namespace App\Models;
use App\Casts\EncryptedArrayCast;
use App\Enums\ApplicationDeploymentStatus;
use App\Services\Access\AccessResolver;
use App\Services\Security\SensitiveDataRedactor;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
@@ -49,6 +50,20 @@ use OpenApi\Attributes as OA;
)]
class ApplicationDeploymentQueue extends Model
{
/**
* Only deployments of applications the authenticated member or API token can see.
*/
public function scopeVisibleToCurrentActor(Builder $query): Builder
{
foreach (app(AccessResolver::class)->currentLayers() as $layer) {
if (! $layer->coversWholeTeam()) {
return $query->whereIn('application_id', Application::query()->pluck('id')->map(fn ($id) => (string) $id));
}
}
return $query;
}
/**
* Log redaction variants of remote secrets. Kept in memory only, never saved.
*
+4 -2
View File
@@ -2,6 +2,7 @@
namespace App\Models;
use App\Services\Access\AccessResolver;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use OpenApi\Attributes as OA;
@@ -150,10 +151,11 @@ class ApplicationSetting extends Model
*/
public static function isContainerNamePrefixInUse(string $prefix, Server $server, ?int $ignoreApplicationId = null): bool
{
return $server->applications()->contains(function (Application $application) use ($prefix, $ignoreApplicationId) {
// Hidden applications of a scoped member still count, or two names on one server could collide.
return app(AccessResolver::class)->withoutVisibilityScope(fn () => $server->applications()->contains(function (Application $application) use ($prefix, $ignoreApplicationId) {
return $application->id !== $ignoreApplicationId
&& in_array($prefix, [$application->uuid, $application->settings->custom_container_name_prefix, $application->settings->custom_internal_name], true);
});
}));
}
public function stopGracePeriodSeconds(): int
+2 -1
View File
@@ -5,6 +5,7 @@ namespace App\Models;
use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute;
use App\Traits\ScopedByAccessGrants;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use OpenApi\Attributes as OA;
@@ -24,7 +25,7 @@ use OpenApi\Attributes as OA;
class Environment extends BaseModel
{
use Auditable, HasFactory;
use ClearsGlobalSearchCache;
use ClearsGlobalSearchCache, ScopedByAccessGrants;
use HasSafeStringAttribute;
protected $fillable = [
+2
View File
@@ -13,12 +13,14 @@ class PersonalAccessToken extends SanctumPersonalAccessToken
'expires_at',
'api_token_expiration_warning_sent_at',
'team_id',
'scopes',
];
protected function casts(): array
{
return [
'api_token_expiration_warning_sent_at' => 'datetime',
'scopes' => 'array',
];
}
}
+2 -1
View File
@@ -5,6 +5,7 @@ namespace App\Models;
use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute;
use App\Traits\ScopedByAccessGrants;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Support\Collection;
use OpenApi\Attributes as OA;
@@ -22,7 +23,7 @@ use OpenApi\Attributes as OA;
class Project extends BaseModel
{
use Auditable, HasFactory;
use ClearsGlobalSearchCache;
use ClearsGlobalSearchCache, ScopedByAccessGrants;
use HasSafeStringAttribute;
protected $fillable = [
@@ -0,0 +1,43 @@
<?php
namespace App\Models\Scopes;
use App\Models\Environment;
use App\Models\Project;
use App\Services\Access\AccessResolver;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
/**
* Hides projects, environments, and resources that the authenticated member or API token has no grant for.
*
* It filters only when the actor has no team-wide grant, so owners, admins, team-wide members and tokens,
* queue workers, and console commands see the same rows as before.
*/
class VisibleThroughAccessGrants implements Scope
{
public function apply(Builder $builder, Model $model): void
{
$resolver = app(AccessResolver::class);
if ($resolver->isResolving()) {
return;
}
foreach ($resolver->currentLayers() as $layer) {
if ($layer->coversWholeTeam()) {
continue;
}
if ($model instanceof Project) {
$builder->whereIn($model->qualifyColumn('id'), $layer->visibleProjectIds());
} elseif ($model instanceof Environment) {
$builder->whereIn($model->qualifyColumn('id'), $layer->visibleEnvironmentIds());
} else {
$builder->where(fn (Builder $query) => $query
->whereIn($model->qualifyColumn('environment_id'), $layer->fullEnvironmentIds())
->orWhereIn($model->qualifyColumn('uuid'), $layer->resourceUuids()));
}
}
}
}
+2 -1
View File
@@ -12,6 +12,7 @@ use App\Traits\HasComposeVolumeWarnings;
use App\Traits\HasMaintenancePage;
use App\Traits\HasSafeStringAttribute;
use App\Traits\HasSecretManager;
use App\Traits\ScopedByAccessGrants;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -46,7 +47,7 @@ use OpenApi\Attributes as OA;
)]
class Service extends BaseModel
{
use Auditable, ClearsGlobalSearchCache, HasComposeVolumeWarnings, HasFactory, HasMaintenancePage, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
use Auditable, ClearsGlobalSearchCache, HasComposeVolumeWarnings, HasFactory, HasMaintenancePage, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes;
private static $parserVersion = '5';
+2 -1
View File
@@ -9,6 +9,7 @@ use App\Traits\HasLogRedactionSetting;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
use App\Traits\HasSecretManager;
use App\Traits\ScopedByAccessGrants;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
@@ -16,7 +17,7 @@ use Throwable;
class StandaloneClickhouse extends BaseModel
{
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes;
protected array $auditExclude = ['last_online_at'];
+2 -1
View File
@@ -9,13 +9,14 @@ use App\Traits\HasLogRedactionSetting;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
use App\Traits\HasSecretManager;
use App\Traits\ScopedByAccessGrants;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneDragonfly extends BaseModel
{
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes;
protected $fillable = [
'uuid',
+2 -1
View File
@@ -9,13 +9,14 @@ use App\Traits\HasLogRedactionSetting;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
use App\Traits\HasSecretManager;
use App\Traits\ScopedByAccessGrants;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneKeydb extends BaseModel
{
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes;
protected $fillable = [
'uuid',
+2 -1
View File
@@ -9,6 +9,7 @@ use App\Traits\HasLogRedactionSetting;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
use App\Traits\HasSecretManager;
use App\Traits\ScopedByAccessGrants;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\MorphTo;
@@ -16,7 +17,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneMariadb extends BaseModel
{
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes;
protected $fillable = [
'uuid',
+2 -1
View File
@@ -9,13 +9,14 @@ use App\Traits\HasLogRedactionSetting;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
use App\Traits\HasSecretManager;
use App\Traits\ScopedByAccessGrants;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneMongodb extends BaseModel
{
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes;
protected $fillable = [
'uuid',
+2 -1
View File
@@ -9,13 +9,14 @@ use App\Traits\HasLogRedactionSetting;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
use App\Traits\HasSecretManager;
use App\Traits\ScopedByAccessGrants;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneMysql extends BaseModel
{
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes;
protected $fillable = [
'uuid',
+2 -1
View File
@@ -9,13 +9,14 @@ use App\Traits\HasLogRedactionSetting;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
use App\Traits\HasSecretManager;
use App\Traits\ScopedByAccessGrants;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
class StandalonePostgresql extends BaseModel
{
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes;
protected $fillable = [
'uuid',
+2 -1
View File
@@ -9,13 +9,14 @@ use App\Traits\HasLogRedactionSetting;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
use App\Traits\HasSecretManager;
use App\Traits\ScopedByAccessGrants;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneRedis extends BaseModel
{
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes;
protected array $auditExclude = ['last_online_at'];
+2 -1
View File
@@ -9,6 +9,7 @@ use App\Traits\HasLogRedactionSetting;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
use App\Traits\HasSecretManager;
use App\Traits\ScopedByAccessGrants;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
@@ -17,7 +18,7 @@ use InvalidArgumentException;
class StandaloneSqlite extends BaseModel
{
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes;
public const DATA_DIRECTORY = '/var/lib/sqlite';
+1 -1
View File
@@ -337,7 +337,7 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen
public function members()
{
return $this->belongsToMany(User::class, 'team_user', 'team_id', 'user_id')->withPivot('role');
return $this->belongsToMany(User::class, 'team_user', 'team_id', 'user_id')->withPivot('role', 'access');
}
public function subscription()
+12 -1
View File
@@ -3,14 +3,17 @@
namespace App\Models;
use App\Actions\User\RevokeUserTeamTokens;
use App\Enums\AccessLevel;
use App\Jobs\UpdateStripeCustomerEmailJob;
use App\Notifications\Channels\SendsEmail;
use App\Notifications\TransactionalEmails\EmailChangeVerification;
use App\Notifications\TransactionalEmails\ResetPassword as TransactionalEmailsResetPassword;
use App\Services\Access\AccessResolver;
use App\Services\ChangelogService;
use App\Traits\DeletesUserSessions;
use DateTimeInterface;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Messages\MailMessage;
@@ -285,7 +288,7 @@ class User extends Authenticatable implements PasskeyUser, SendsEmail
public function teams()
{
return $this->belongsToMany(Team::class)->withPivot('role');
return $this->belongsToMany(Team::class)->withPivot('role', 'access');
}
public function changelogReads()
@@ -484,6 +487,14 @@ class User extends Authenticatable implements PasskeyUser, SendsEmail
return data_get($team, 'pivot.role');
}
/**
* Check the access grants of the user (and the API token of the request) on a project, environment, or resource.
*/
public function canAccess(AccessLevel $level, Model $target): bool
{
return app(AccessResolver::class)->allows($this, $level, $target);
}
/**
* Check if the user is an admin or owner of a specific team
*/
+36 -4
View File
@@ -2,7 +2,9 @@
namespace App\Policies;
use App\Enums\AccessLevel;
use App\Models\User;
use App\Services\Access\AccessResolver;
use Laravel\Sanctum\PersonalAccessToken;
class ApiTokenPolicy
@@ -60,7 +62,7 @@ class ApiTokenPolicy
*/
public function useRootPermissions(User $user): bool
{
return $user->isAdmin() || $user->isOwner();
return $user->isAdmin() || $user->isOwner() || $this->holdsSomewhere($user, AccessLevel::Root);
}
/**
@@ -68,7 +70,7 @@ class ApiTokenPolicy
*/
public function useWritePermissions(User $user): bool
{
return $user->isAdmin() || $user->isOwner();
return $user->isAdmin() || $user->isOwner() || $this->holdsSomewhere($user, AccessLevel::Write);
}
/**
@@ -76,7 +78,7 @@ class ApiTokenPolicy
*/
public function useDeployPermissions(User $user): bool
{
return $user->isAdmin() || $user->isOwner();
return $user->isAdmin() || $user->isOwner() || $this->holdsSomewhere($user, AccessLevel::Deploy);
}
/**
@@ -84,7 +86,37 @@ class ApiTokenPolicy
*/
public function useSensitivePermissions(User $user): bool
{
return $user->isAdmin() || $user->isOwner();
return $user->isAdmin() || $user->isOwner() || $this->holdsSomewhere($user, AccessLevel::ReadSensitive);
}
/**
* Determine whether the user can use delete permissions for API tokens.
*/
public function useDeletePermissions(User $user): bool
{
return $user->isAdmin() || $user->isOwner() || $this->holdsSomewhere($user, AccessLevel::Delete);
}
/**
* True when a grant of the member in the current team gives the level somewhere.
* Creating the token still checks that the member holds it on every chosen scope.
*/
private function holdsSomewhere(User $user, AccessLevel $level): bool
{
$teamId = $user->currentTeam()?->id;
if ($teamId === null) {
return false;
}
foreach (app(AccessResolver::class)->memberGrants($user, $teamId) ?? [] as $grant) {
foreach ($grant->levels as $granted) {
if (in_array($level, $granted->includes(), true)) {
return true;
}
}
}
return false;
}
private function belongsToUserAndCurrentTeam(User $user, PersonalAccessToken $token): bool
+19 -34
View File
@@ -2,7 +2,9 @@
namespace App\Policies;
use App\Enums\AccessLevel;
use App\Models\Application;
use App\Models\Environment;
use App\Models\User;
use Illuminate\Auth\Access\Response;
@@ -21,17 +23,15 @@ class ApplicationPolicy
*/
public function view(User $user, Application $application): bool
{
$teamId = $this->getTeamId($application);
return $teamId !== null && $user->teams->contains('id', $teamId);
return $user->canAccess(AccessLevel::Read, $application);
}
/**
* Determine whether the user can create models.
* Determine whether the user can create models, in the environment when one is given.
*/
public function create(User $user): bool
public function create(User $user, ?Environment $environment = null): bool
{
return $user->isAdmin();
return app(ResourceCreatePolicy::class)->createAny($user, $environment);
}
/**
@@ -39,13 +39,7 @@ class ApplicationPolicy
*/
public function update(User $user, Application $application): Response
{
$teamId = $this->getTeamId($application);
if ($teamId === null) {
return Response::deny('Application team not found.');
}
if ($user->isAdminOfTeam($teamId)) {
if ($user->canAccess(AccessLevel::Write, $application)) {
return Response::allow();
}
@@ -57,9 +51,7 @@ class ApplicationPolicy
*/
public function delete(User $user, Application $application): bool
{
$teamId = $this->getTeamId($application);
return $teamId !== null && $user->isAdminOfTeam($teamId);
return $user->canAccess(AccessLevel::Delete, $application);
}
/**
@@ -83,13 +75,7 @@ class ApplicationPolicy
*/
public function uploadBackup(User $user, Application $application): Response
{
$teamId = $this->getTeamId($application);
if ($teamId === null) {
return Response::deny('Application team not found.');
}
if ($user->isAdminOfTeam($teamId)) {
if ($user->canAccess(AccessLevel::Write, $application)) {
return Response::allow();
}
@@ -97,13 +83,19 @@ class ApplicationPolicy
}
/**
* Determine whether the user can deploy the application.
* Determine whether the user can deploy, restart, or stop the application.
*/
public function deploy(User $user, Application $application): bool
{
$teamId = $this->getTeamId($application);
return $user->canAccess(AccessLevel::Deploy, $application);
}
return $teamId !== null && $user->isAdminOfTeam($teamId);
/**
* Determine whether the user can read secret values, such as environment variable values.
*/
public function viewSensitive(User $user, Application $application): bool
{
return $user->canAccess(AccessLevel::ReadSensitive, $application);
}
/**
@@ -111,13 +103,6 @@ class ApplicationPolicy
*/
public function manageEnvironment(User $user, Application $application): bool
{
$teamId = $this->getTeamId($application);
return $teamId !== null && $user->isAdminOfTeam($teamId);
}
private function getTeamId(Application $application): ?int
{
return $application->team()?->id;
return $user->canAccess(AccessLevel::Write, $application);
}
}
+9 -18
View File
@@ -2,6 +2,7 @@
namespace App\Policies;
use App\Enums\AccessLevel;
use App\Models\ApplicationPreview;
use App\Models\User;
use Illuminate\Auth\Access\Response;
@@ -21,9 +22,7 @@ class ApplicationPreviewPolicy
*/
public function view(User $user, ApplicationPreview $applicationPreview): bool
{
$teamId = $this->getTeamId($applicationPreview);
return $teamId !== null && $user->teams->contains('id', $teamId);
return $this->allows($user, AccessLevel::Read, $applicationPreview);
}
/**
@@ -39,13 +38,7 @@ class ApplicationPreviewPolicy
*/
public function update(User $user, ApplicationPreview $applicationPreview): Response
{
$teamId = $this->getTeamId($applicationPreview);
if ($teamId === null) {
return Response::deny('Application preview team not found.');
}
if ($user->isAdminOfTeam($teamId)) {
if ($this->allows($user, AccessLevel::Write, $applicationPreview)) {
return Response::allow();
}
@@ -57,9 +50,7 @@ class ApplicationPreviewPolicy
*/
public function delete(User $user, ApplicationPreview $applicationPreview): bool
{
$teamId = $this->getTeamId($applicationPreview);
return $teamId !== null && $user->isAdminOfTeam($teamId);
return $this->allows($user, AccessLevel::Delete, $applicationPreview);
}
/**
@@ -83,13 +74,13 @@ class ApplicationPreviewPolicy
*/
public function deploy(User $user, ApplicationPreview $applicationPreview): bool
{
$teamId = $this->getTeamId($applicationPreview);
return $teamId !== null && $user->isAdminOfTeam($teamId);
return $this->allows($user, AccessLevel::Deploy, $applicationPreview);
}
private function getTeamId(ApplicationPreview $applicationPreview): ?int
private function allows(User $user, AccessLevel $level, ApplicationPreview $applicationPreview): bool
{
return $applicationPreview->application?->team()?->id;
$application = $applicationPreview->application;
return $application !== null && $user->canAccess($level, $application);
}
}
+8 -11
View File
@@ -2,6 +2,7 @@
namespace App\Policies;
use App\Enums\AccessLevel;
use App\Models\ApplicationSetting;
use App\Models\User;
@@ -20,9 +21,7 @@ class ApplicationSettingPolicy
*/
public function view(User $user, ApplicationSetting $applicationSetting): bool
{
$teamId = $this->getTeamId($applicationSetting);
return $teamId !== null && $user->teams->contains('id', $teamId);
return $this->allows($user, AccessLevel::Read, $applicationSetting);
}
/**
@@ -38,9 +37,7 @@ class ApplicationSettingPolicy
*/
public function update(User $user, ApplicationSetting $applicationSetting): bool
{
$teamId = $this->getTeamId($applicationSetting);
return $teamId !== null && $user->isAdminOfTeam($teamId);
return $this->allows($user, AccessLevel::Write, $applicationSetting);
}
/**
@@ -48,9 +45,7 @@ class ApplicationSettingPolicy
*/
public function delete(User $user, ApplicationSetting $applicationSetting): bool
{
$teamId = $this->getTeamId($applicationSetting);
return $teamId !== null && $user->isAdminOfTeam($teamId);
return $this->allows($user, AccessLevel::Delete, $applicationSetting);
}
/**
@@ -69,8 +64,10 @@ class ApplicationSettingPolicy
return false;
}
private function getTeamId(ApplicationSetting $applicationSetting): ?int
private function allows(User $user, AccessLevel $level, ApplicationSetting $applicationSetting): bool
{
return $applicationSetting->application?->team()?->id;
$application = $applicationSetting->application;
return $application !== null && $user->canAccess($level, $application);
}
}
+35 -39
View File
@@ -2,6 +2,8 @@
namespace App\Policies;
use App\Enums\AccessLevel;
use App\Models\Environment;
use App\Models\User;
use Illuminate\Auth\Access\Response;
@@ -20,17 +22,19 @@ class DatabasePolicy
*/
public function view(User $user, $database): bool
{
$teamId = $this->getTeamId($database);
if ($this->isInstanceDatabase($database)) {
return $user->teams->contains('id', 0);
}
return $teamId !== null && $user->teams->contains('id', $teamId);
return $user->canAccess(AccessLevel::Read, $database);
}
/**
* Determine whether the user can create models.
* Determine whether the user can create models, in the environment when one is given.
*/
public function create(User $user): bool
public function create(User $user, ?Environment $environment = null): bool
{
return $user->isAdmin();
return app(ResourceCreatePolicy::class)->createAny($user, $environment);
}
/**
@@ -38,13 +42,7 @@ class DatabasePolicy
*/
public function update(User $user, $database): Response
{
$teamId = $this->getTeamId($database);
if ($teamId === null) {
return Response::deny('Database team not found.');
}
if ($user->isAdminOfTeam($teamId)) {
if ($this->allows($user, AccessLevel::Write, $database)) {
return Response::allow();
}
@@ -56,9 +54,7 @@ class DatabasePolicy
*/
public function delete(User $user, $database): bool
{
$teamId = $this->getTeamId($database);
return $teamId !== null && $user->isAdminOfTeam($teamId);
return $this->allows($user, AccessLevel::Delete, $database);
}
/**
@@ -78,13 +74,12 @@ class DatabasePolicy
}
/**
* Determine whether the user can start/stop the database.
* Determine whether the user can start, stop, restart, and flush the database.
* Needs write access because flushing deletes the data.
*/
public function manage(User $user, $database): bool
{
$teamId = $this->getTeamId($database);
return $teamId !== null && $user->isAdminOfTeam($teamId);
return $this->allows($user, AccessLevel::Write, $database);
}
/**
@@ -92,13 +87,7 @@ class DatabasePolicy
*/
public function uploadBackup(User $user, $database): Response
{
$teamId = $this->getTeamId($database);
if ($teamId === null) {
return Response::deny('Database team not found.');
}
if ($user->isAdminOfTeam($teamId)) {
if ($this->allows($user, AccessLevel::Write, $database)) {
return Response::allow();
}
@@ -110,9 +99,15 @@ class DatabasePolicy
*/
public function manageBackups(User $user, $database): bool
{
$teamId = $this->getTeamId($database);
return $this->allows($user, AccessLevel::Write, $database);
}
return $teamId !== null && $user->isAdminOfTeam($teamId);
/**
* Determine whether the user can read secret values, such as environment variable values.
*/
public function viewSensitive(User $user, $database): bool
{
return $this->allows($user, AccessLevel::ReadSensitive, $database);
}
/**
@@ -120,22 +115,23 @@ class DatabasePolicy
*/
public function manageEnvironment(User $user, $database): bool
{
$teamId = $this->getTeamId($database);
return $teamId !== null && $user->isAdminOfTeam($teamId);
return $this->allows($user, AccessLevel::Write, $database);
}
private function getTeamId($database): ?int
/**
* The instance database (coolify-db) belongs to the root team; only its owners and admins can change it.
*/
private function allows(User $user, AccessLevel $level, $database): bool
{
// Instance-level databases (e.g., coolify-db) belong to root team
if (isset($database->id) && $database->id === 0) {
return 0;
if ($this->isInstanceDatabase($database)) {
return $user->isAdminOfTeam(0);
}
if (method_exists($database, 'team')) {
return $database->team()?->id;
}
return $user->canAccess($level, $database);
}
return null;
private function isInstanceDatabase($database): bool
{
return isset($database->id) && $database->id === 0;
}
}
+11 -17
View File
@@ -2,8 +2,11 @@
namespace App\Policies;
use App\Enums\AccessLevel;
use App\Models\Environment;
use App\Models\Project;
use App\Models\User;
use App\Services\Access\AccessResolver;
class EnvironmentPolicy
{
@@ -20,17 +23,17 @@ class EnvironmentPolicy
*/
public function view(User $user, Environment $environment): bool
{
$teamId = $this->getTeamId($environment);
return $teamId !== null && $user->teams->contains('id', $teamId);
return app(AccessResolver::class)->canOpen($user, $environment);
}
/**
* Determine whether the user can create models.
* Determine whether the user can create environments, in the project when one is given.
*/
public function create(User $user): bool
public function create(User $user, ?Project $project = null): bool
{
return $user->isAdmin();
return $project === null
? app(ProjectPolicy::class)->create($user)
: $user->canAccess(AccessLevel::Write, $project);
}
/**
@@ -38,9 +41,7 @@ class EnvironmentPolicy
*/
public function update(User $user, Environment $environment): bool
{
$teamId = $this->getTeamId($environment);
return $teamId !== null && $user->isAdminOfTeam($teamId);
return $user->canAccess(AccessLevel::Write, $environment);
}
/**
@@ -48,9 +49,7 @@ class EnvironmentPolicy
*/
public function delete(User $user, Environment $environment): bool
{
$teamId = $this->getTeamId($environment);
return $teamId !== null && $user->isAdminOfTeam($teamId);
return $user->canAccess(AccessLevel::Delete, $environment);
}
/**
@@ -68,9 +67,4 @@ class EnvironmentPolicy
{
return false;
}
private function getTeamId(Environment $environment): ?int
{
return $environment->project?->team_id;
}
}
+18 -17
View File
@@ -4,7 +4,12 @@ namespace App\Policies;
use App\Models\EnvironmentVariable;
use App\Models\User;
use Illuminate\Support\Facades\Gate;
/**
* An environment variable is part of its resource's configuration: listing it needs view on the resource,
* reading its value needs viewSensitive, and every change needs manageEnvironment (write access).
*/
class EnvironmentVariablePolicy
{
/**
@@ -20,9 +25,15 @@ class EnvironmentVariablePolicy
*/
public function view(User $user, EnvironmentVariable $environmentVariable): bool
{
$teamId = $this->getTeamId($environmentVariable);
return $this->allowsOnResource($user, 'view', $environmentVariable);
}
return $teamId !== null && $user->teams->contains('id', $teamId);
/**
* Determine whether the user can read the value.
*/
public function viewSensitive(User $user, EnvironmentVariable $environmentVariable): bool
{
return $this->allowsOnResource($user, 'viewSensitive', $environmentVariable);
}
/**
@@ -38,9 +49,7 @@ class EnvironmentVariablePolicy
*/
public function update(User $user, EnvironmentVariable $environmentVariable): bool
{
$teamId = $this->getTeamId($environmentVariable);
return $teamId !== null && $user->isAdminOfTeam($teamId);
return $this->allowsOnResource($user, 'manageEnvironment', $environmentVariable);
}
/**
@@ -48,9 +57,7 @@ class EnvironmentVariablePolicy
*/
public function delete(User $user, EnvironmentVariable $environmentVariable): bool
{
$teamId = $this->getTeamId($environmentVariable);
return $teamId !== null && $user->isAdminOfTeam($teamId);
return $this->allowsOnResource($user, 'manageEnvironment', $environmentVariable);
}
/**
@@ -74,19 +81,13 @@ class EnvironmentVariablePolicy
*/
public function manageEnvironment(User $user, EnvironmentVariable $environmentVariable): bool
{
$teamId = $this->getTeamId($environmentVariable);
return $teamId !== null && $user->isAdminOfTeam($teamId);
return $this->allowsOnResource($user, 'manageEnvironment', $environmentVariable);
}
private function getTeamId(EnvironmentVariable $environmentVariable): ?int
private function allowsOnResource(User $user, string $ability, EnvironmentVariable $environmentVariable): bool
{
$resource = $environmentVariable->resourceable;
if (! $resource || ! method_exists($resource, 'team')) {
return null;
}
return $resource->team()?->id;
return $resource !== null && Gate::forUser($user)->allows($ability, $resource);
}
}
+9 -5
View File
@@ -2,8 +2,10 @@
namespace App\Policies;
use App\Enums\AccessLevel;
use App\Models\Project;
use App\Models\User;
use App\Services\Access\AccessResolver;
class ProjectPolicy
{
@@ -20,15 +22,17 @@ class ProjectPolicy
*/
public function view(User $user, Project $project): bool
{
return $user->teams->contains('id', $project->team_id);
return app(AccessResolver::class)->canOpen($user, $project);
}
/**
* Determine whether the user can create models.
* Determine whether the user can create projects in the current team.
*/
public function create(User $user): bool
{
return $user->isAdmin();
$teamId = $user->currentTeam()?->id;
return $teamId !== null && app(AccessResolver::class)->allowsTeamWide($user, AccessLevel::Write, $teamId);
}
/**
@@ -36,7 +40,7 @@ class ProjectPolicy
*/
public function update(User $user, Project $project): bool
{
return $user->isAdminOfTeam($project->team_id);
return $user->canAccess(AccessLevel::Write, $project);
}
/**
@@ -44,7 +48,7 @@ class ProjectPolicy
*/
public function delete(User $user, Project $project): bool
{
return $user->isAdminOfTeam($project->team_id);
return $user->canAccess(AccessLevel::Delete, $project);
}
/**
+13 -3
View File
@@ -2,15 +2,25 @@
namespace App\Policies;
use App\Enums\AccessLevel;
use App\Models\Environment;
use App\Models\User;
use App\Services\Access\AccessResolver;
class ResourceCreatePolicy
{
/**
* Determine whether the user can create any resource.
* Determine whether the user can create resources in the environment.
* Without an environment, the user needs write access on the whole current team.
*/
public function createAny(User $user): bool
public function createAny(User $user, ?Environment $environment = null): bool
{
return $user->isAdmin();
if ($environment !== null) {
return $user->canAccess(AccessLevel::Write, $environment);
}
$teamId = $user->currentTeam()?->id;
return $teamId !== null && app(AccessResolver::class)->allowsTeamWide($user, AccessLevel::Write, $teamId);
}
}
+30 -3
View File
@@ -2,10 +2,17 @@
namespace App\Policies;
use App\Enums\AccessLevel;
use App\Models\ScheduledTask;
use App\Models\User;
use App\Services\Access\AccessResolver;
use Illuminate\Auth\Access\Response;
use Illuminate\Support\Facades\Gate;
/**
* A scheduled task is part of its application's or service's configuration: reading it needs view on that
* resource, every change needs update (write access) on it. A task without a visible parent needs team-wide access.
*/
class ScheduledTaskPolicy
{
/**
@@ -21,7 +28,12 @@ class ScheduledTaskPolicy
*/
public function view(User $user, ScheduledTask $scheduledTask): bool
{
return $user->teams->contains('id', $scheduledTask->team_id);
$resource = $this->resource($scheduledTask);
if ($resource === null) {
return app(AccessResolver::class)->allowsTeamWide($user, AccessLevel::Read, $scheduledTask->team_id);
}
return Gate::forUser($user)->allows('view', $resource);
}
/**
@@ -37,7 +49,7 @@ class ScheduledTaskPolicy
*/
public function update(User $user, ScheduledTask $scheduledTask): Response
{
if (! $user->isAdminOfTeam($scheduledTask->team_id)) {
if (! $this->canChange($user, $scheduledTask)) {
return Response::deny('You need at least admin or owner permissions to update this scheduled task.');
}
@@ -49,7 +61,7 @@ class ScheduledTaskPolicy
*/
public function delete(User $user, ScheduledTask $scheduledTask): bool
{
return $user->isAdminOfTeam($scheduledTask->team_id);
return $this->canChange($user, $scheduledTask);
}
/**
@@ -67,4 +79,19 @@ class ScheduledTaskPolicy
{
return false;
}
private function canChange(User $user, ScheduledTask $scheduledTask): bool
{
$resource = $this->resource($scheduledTask);
if ($resource === null) {
return $user->isAdminOfTeam($scheduledTask->team_id);
}
return Gate::forUser($user)->allows('update', $resource);
}
private function resource(ScheduledTask $scheduledTask): ?object
{
return $scheduledTask->application ?? $scheduledTask->service;
}
}
+19 -28
View File
@@ -2,6 +2,8 @@
namespace App\Policies;
use App\Enums\AccessLevel;
use App\Models\Environment;
use App\Models\Service;
use App\Models\User;
@@ -20,17 +22,15 @@ class ServicePolicy
*/
public function view(User $user, Service $service): bool
{
$teamId = $this->getTeamId($service);
return $teamId !== null && $user->teams->contains('id', $teamId);
return $user->canAccess(AccessLevel::Read, $service);
}
/**
* Determine whether the user can create models.
* Determine whether the user can create models, in the environment when one is given.
*/
public function create(User $user): bool
public function create(User $user, ?Environment $environment = null): bool
{
return $user->isAdmin();
return app(ResourceCreatePolicy::class)->createAny($user, $environment);
}
/**
@@ -38,9 +38,7 @@ class ServicePolicy
*/
public function update(User $user, Service $service): bool
{
$teamId = $this->getTeamId($service);
return $teamId !== null && $user->isAdminOfTeam($teamId);
return $user->canAccess(AccessLevel::Write, $service);
}
/**
@@ -48,9 +46,7 @@ class ServicePolicy
*/
public function delete(User $user, Service $service): bool
{
$teamId = $this->getTeamId($service);
return $teamId !== null && $user->isAdminOfTeam($teamId);
return $user->canAccess(AccessLevel::Delete, $service);
}
/**
@@ -74,9 +70,15 @@ class ServicePolicy
*/
public function stop(User $user, Service $service): bool
{
$teamId = $this->getTeamId($service);
return $user->canAccess(AccessLevel::Deploy, $service);
}
return $teamId !== null && $user->isAdminOfTeam($teamId);
/**
* Determine whether the user can read secret values, such as environment variable values.
*/
public function viewSensitive(User $user, Service $service): bool
{
return $user->canAccess(AccessLevel::ReadSensitive, $service);
}
/**
@@ -84,9 +86,7 @@ class ServicePolicy
*/
public function manageEnvironment(User $user, Service $service): bool
{
$teamId = $this->getTeamId($service);
return $teamId !== null && $user->isAdminOfTeam($teamId);
return $user->canAccess(AccessLevel::Write, $service);
}
/**
@@ -94,9 +94,7 @@ class ServicePolicy
*/
public function uploadBackup(User $user, Service $service): bool
{
$teamId = $this->getTeamId($service);
return $teamId !== null && $user->isAdminOfTeam($teamId);
return $user->canAccess(AccessLevel::Write, $service);
}
/**
@@ -104,13 +102,6 @@ class ServicePolicy
*/
public function deploy(User $user, Service $service): bool
{
$teamId = $this->getTeamId($service);
return $teamId !== null && $user->isAdminOfTeam($teamId);
}
private function getTeamId(Service $service): ?int
{
return $service->team()?->id;
return $user->canAccess(AccessLevel::Deploy, $service);
}
}
@@ -2,9 +2,15 @@
namespace App\Policies;
use App\Enums\AccessLevel;
use App\Models\SharedEnvironmentVariable;
use App\Models\User;
use App\Services\Access\AccessResolver;
/**
* Team and server variables need team-wide access (owners and admins to change them).
* Project and environment variables follow the access level on their project or environment.
*/
class SharedEnvironmentVariablePolicy
{
/**
@@ -20,7 +26,15 @@ class SharedEnvironmentVariablePolicy
*/
public function view(User $user, SharedEnvironmentVariable $sharedEnvironmentVariable): bool
{
return $user->teams->contains('id', $sharedEnvironmentVariable->team_id);
return $this->allows($user, AccessLevel::Read, $sharedEnvironmentVariable);
}
/**
* Determine whether the user can read the value.
*/
public function viewSensitive(User $user, SharedEnvironmentVariable $sharedEnvironmentVariable): bool
{
return $this->allows($user, AccessLevel::ReadSensitive, $sharedEnvironmentVariable);
}
/**
@@ -36,7 +50,7 @@ class SharedEnvironmentVariablePolicy
*/
public function update(User $user, SharedEnvironmentVariable $sharedEnvironmentVariable): bool
{
return $user->isAdminOfTeam($sharedEnvironmentVariable->team_id);
return $this->allows($user, AccessLevel::Write, $sharedEnvironmentVariable);
}
/**
@@ -44,7 +58,7 @@ class SharedEnvironmentVariablePolicy
*/
public function delete(User $user, SharedEnvironmentVariable $sharedEnvironmentVariable): bool
{
return $user->isAdminOfTeam($sharedEnvironmentVariable->team_id);
return $this->allows($user, AccessLevel::Write, $sharedEnvironmentVariable);
}
/**
@@ -68,6 +82,21 @@ class SharedEnvironmentVariablePolicy
*/
public function manageEnvironment(User $user, SharedEnvironmentVariable $sharedEnvironmentVariable): bool
{
return $user->isAdminOfTeam($sharedEnvironmentVariable->team_id);
return $this->allows($user, AccessLevel::Write, $sharedEnvironmentVariable);
}
private function allows(User $user, AccessLevel $level, SharedEnvironmentVariable $sharedEnvironmentVariable): bool
{
if (in_array($sharedEnvironmentVariable->type, ['project', 'environment'], true)) {
$target = $sharedEnvironmentVariable->type === 'project'
? $sharedEnvironmentVariable->project
: $sharedEnvironmentVariable->environment;
return $target !== null && $user->canAccess($level, $target);
}
return $level === AccessLevel::Read
? app(AccessResolver::class)->allowsTeamWide($user, AccessLevel::Read, $sharedEnvironmentVariable->team_id)
: $user->isAdminOfTeam($sharedEnvironmentVariable->team_id);
}
}
+2
View File
@@ -8,6 +8,7 @@ use App\Auth\Oidc\Socialite\OidcProvider;
use App\Models\PersonalAccessToken;
use App\Models\Server;
use App\Rules\SafeExternalUrl;
use App\Services\Access\AccessResolver;
use App\Support\AtomicFilesystem;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Console\ConfigCacheCommand;
@@ -27,6 +28,7 @@ class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->scoped(AccessResolver::class);
$this->app->bind(StripeClient::class, fn () => new StripeClient(config('subscription.stripe_api_key')));
// Requests must never read a partly written cache file. See AtomicFilesystem.
+124
View File
@@ -0,0 +1,124 @@
<?php
namespace App\Services\Access;
use App\Enums\AccessLevel;
/**
* The grants of one actor (a member or a token) in one team, with UUIDs resolved to IDs.
*
* A grant on a parent applies to its children: team → project → environment → resource.
* The levels of all matching grants add up.
*/
final class AccessGrants
{
/**
* @param array<string, true> $teamLevels expanded levels of team-wide grants
* @param array<int, array<string, true>> $projectLevels project ID => expanded levels
* @param array<int, array<string, true>> $environmentLevels environment ID => expanded levels
* @param array<string, array<string, true>> $resourceLevels resource UUID => expanded levels
* @param array<int, int> $environmentProjects environment ID => project ID, for granted environments and parents of granted resources
* @param array<string, int> $resourceEnvironments resource UUID => environment ID
* @param list<int> $projectEnvironmentIds environments inside granted projects
*/
public function __construct(
private readonly array $teamLevels,
private readonly array $projectLevels,
private readonly array $environmentLevels,
private readonly array $resourceLevels,
private readonly array $environmentProjects,
private readonly array $resourceEnvironments,
private readonly array $projectEnvironmentIds,
) {}
public static function unrestricted(): self
{
return new self(self::expand([AccessLevel::Root]), [], [], [], [], [], []);
}
/**
* @param list<AccessLevel> $levels
* @return array<string, true>
*/
public static function expand(array $levels): array
{
$expanded = [];
foreach ($levels as $level) {
foreach ($level->includes() as $included) {
$expanded[$included->value] = true;
}
}
return $expanded;
}
/**
* True when a team-wide grant exists, so every project in the team is visible.
*/
public function coversWholeTeam(): bool
{
return $this->teamLevels !== [];
}
public function allows(AccessLevel $level, ?int $projectId = null, ?int $environmentId = null, ?string $resourceUuid = null): bool
{
if (isset($this->teamLevels[$level->value])) {
return true;
}
if ($projectId !== null && isset($this->projectLevels[$projectId][$level->value])) {
return true;
}
if ($environmentId !== null && isset($this->environmentLevels[$environmentId][$level->value])) {
return true;
}
return $resourceUuid !== null && isset($this->resourceLevels[$resourceUuid][$level->value]);
}
/**
* Projects the actor can open: granted projects and parents of granted environments and resources.
*
* @return list<int>
*/
public function visibleProjectIds(): array
{
return array_values(array_unique([
...array_keys($this->projectLevels),
...array_values($this->environmentProjects),
]));
}
/**
* Environments the actor can open, including parents of granted resources.
*
* @return list<int>
*/
public function visibleEnvironmentIds(): array
{
return array_values(array_unique([
...$this->fullEnvironmentIds(),
...array_values($this->resourceEnvironments),
]));
}
/**
* Environments where every resource is visible (granted directly or through the project).
*
* @return list<int>
*/
public function fullEnvironmentIds(): array
{
return array_values(array_unique([
...array_keys($this->environmentLevels),
...$this->projectEnvironmentIds,
]));
}
/**
* @return list<string>
*/
public function resourceUuids(): array
{
return array_map('strval', array_keys($this->resourceLevels));
}
}
+468
View File
@@ -0,0 +1,468 @@
<?php
namespace App\Services\Access;
use App\Enums\AccessLevel;
use App\Enums\AccessScope;
use App\Models\Application;
use App\Models\Environment;
use App\Models\PersonalAccessToken;
use App\Models\Project;
use App\Models\Scopes\VisibleThroughAccessGrants;
use App\Models\Service;
use App\Models\StandaloneClickhouse;
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\StandaloneSqlite;
use App\Models\User;
use Closure;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
/**
* Decides what a user, and the API token of the current request, can see and do.
*
* Owners and admins of a team have full access there. A member has the grants on their
* `team_user.access` row; a member without stored grants keeps the old behavior (team-wide read).
* A token with `scopes` adds a second layer: both layers must allow an action.
*/
class AccessResolver
{
/**
* Models that are a resource inside an environment.
*
* @var list<class-string<Model>>
*/
public const RESOURCE_MODELS = [
Application::class,
Service::class,
StandalonePostgresql::class,
StandaloneMysql::class,
StandaloneMariadb::class,
StandaloneMongodb::class,
StandaloneRedis::class,
StandaloneKeydb::class,
StandaloneDragonfly::class,
StandaloneClickhouse::class,
StandaloneSqlite::class,
];
/** @var array<string, AccessGrants> */
private array $layers = [];
private bool $resolving = false;
private int $unscopedDepth = 0;
public function isResolving(): bool
{
return $this->resolving || $this->unscopedDepth > 0;
}
/**
* Run the callback with VisibleThroughAccessGrants turned off, also for nested relations.
* Only for checks that must see every row in the team, such as domain and port conflicts.
*
* @template T
*
* @param Closure(): T $callback
* @return T
*/
public function withoutVisibilityScope(Closure $callback): mixed
{
$this->unscopedDepth++;
try {
return $callback();
} finally {
$this->unscopedDepth--;
}
}
public function flush(): void
{
$this->layers = [];
}
/**
* The levels a member without stored grants has.
*
* @return list<Grant>
*/
public static function defaultMemberGrants(): array
{
return [Grant::team([AccessLevel::Read])];
}
/**
* @return list<Grant>|null null when the token is not limited to scopes
*/
public static function tokenGrants(PersonalAccessToken $token): ?array
{
$scopes = $token->scopes;
if ($scopes === null) {
return null;
}
$levels = AccessLevel::parseMany($token->abilities ?? []);
return array_map(
fn (Grant $scope) => new Grant($scope->scope, $scope->uuid, $levels),
Grant::listFrom($scopes),
);
}
/**
* @return list<Grant>|null null for owners and admins, who have no limits in the team
*/
public function memberGrants(User $user, int $teamId): ?array
{
$membership = $this->membership($user, $teamId);
if ($membership === null) {
return [];
}
$role = $membership->role ?? null;
if ($role === 'admin' || $role === 'owner') {
return null;
}
$stored = $membership->access ?? null;
return $stored === null ? self::defaultMemberGrants() : Grant::listFrom($stored);
}
private function isMember(User $user, int $teamId): bool
{
return $this->membership($user, $teamId) !== null;
}
/**
* The `team_user` row of the user. It uses the loaded `teams` relation, the same data `isAdminOfTeam()`
* reads, but it never loads that relation itself: list queries call this early in a request, and a
* relation cached then would hide a role change made later in the same request.
*/
private function membership(User $user, int $teamId): ?object
{
if ($user->relationLoaded('teams')) {
return $user->teams->firstWhere('id', $teamId)?->pivot;
}
return $user->teams()->where('teams.id', $teamId)->first()?->pivot;
}
public function allows(User $user, AccessLevel $level, Model $target): bool
{
$location = $this->locate($target);
if ($location === null) {
return false;
}
[$teamId, $projectId, $environmentId, $resourceUuid] = $location;
foreach ($this->layersFor($user, $teamId) as $layer) {
if (! $layer->allows($level, $projectId, $environmentId, $resourceUuid)) {
return false;
}
}
return $this->isMember($user, $teamId);
}
/**
* True when the user can open a project or environment: it is granted, or it contains a granted
* environment or resource. Opening shows only the children the user can see.
*/
public function canOpen(User $user, Project|Environment $target): bool
{
$location = $this->locate($target);
if ($location === null) {
return false;
}
[$teamId, $projectId, $environmentId] = $location;
foreach ($this->layersFor($user, $teamId) as $layer) {
$visible = $target instanceof Project
? in_array($projectId, $layer->visibleProjectIds(), true)
: in_array($environmentId, $layer->visibleEnvironmentIds(), true);
if (! $layer->coversWholeTeam() && ! $visible) {
return false;
}
}
return $this->isMember($user, $teamId);
}
/**
* True when the user (and the current token) can act on the whole team, not only on some projects.
*/
public function coversWholeTeam(User $user, int $teamId): bool
{
foreach ($this->layersFor($user, $teamId) as $layer) {
if (! $layer->coversWholeTeam()) {
return false;
}
}
return true;
}
/**
* True when the user (and the current token) has the level on the whole team, for actions without a target
* inside the team, such as creating a project.
*/
public function allowsTeamWide(User $user, AccessLevel $level, int $teamId): bool
{
foreach ($this->layersFor($user, $teamId) as $layer) {
if (! $layer->allows($level)) {
return false;
}
}
return $this->isMember($user, $teamId);
}
/**
* True when the user has every level of every grant, at that scope or above it.
* A token can never get more access than the user who owns it.
*
* @param list<Grant> $grants
*/
public function userHolds(User $user, int $teamId, array $grants): bool
{
$memberGrants = $this->memberGrants($user, $teamId);
if ($memberGrants === null) {
return true;
}
$layer = $this->resolve($teamId, $memberGrants);
foreach ($grants as $grant) {
$location = $this->locateScope($teamId, $grant);
if ($location === null) {
return false;
}
[$projectId, $environmentId, $resourceUuid] = $location;
foreach ($grant->levels as $level) {
if (! $layer->allows($level, $projectId, $environmentId, $resourceUuid)) {
return false;
}
}
}
return true;
}
/**
* Layers of the authenticated user (and token) in their current team, for list queries.
*
* @return list<AccessGrants>
*/
public function currentLayers(): array
{
if ($this->resolving) {
return [];
}
$user = auth()->user();
if (! $user instanceof User) {
return [];
}
$teamId = data_get(session('currentTeam'), 'id') ?? data_get($user->currentAccessToken(), 'team_id');
if ($teamId === null || ! $this->isMember($user, (int) $teamId)) {
return [];
}
return $this->layersFor($user, (int) $teamId);
}
/**
* @return list<AccessGrants>
*/
public function layersFor(User $user, int $teamId): array
{
$layers = [];
$memberGrants = $this->memberGrants($user, $teamId);
if ($memberGrants !== null) {
$layers[] = $this->cachedLayer($teamId, $memberGrants);
}
$token = $user->is(auth()->user()) ? $user->currentAccessToken() : null;
if ($token instanceof PersonalAccessToken && (int) $token->team_id === $teamId) {
$tokenGrants = self::tokenGrants($token);
if ($tokenGrants !== null) {
$layers[] = $this->cachedLayer($teamId, $tokenGrants);
}
}
return $layers;
}
/**
* @param list<Grant> $grants
*/
private function cachedLayer(int $teamId, array $grants): AccessGrants
{
$key = $teamId.':'.md5(json_encode(Grant::listToArray($grants)));
return $this->layers[$key] ??= $this->resolve($teamId, $grants);
}
/**
* Resolve grant UUIDs to IDs inside the team. Grants that point outside the team are dropped.
*
* @param list<Grant> $grants
*/
public function resolve(int $teamId, array $grants): AccessGrants
{
$this->resolving = true;
try {
return $this->build($teamId, $grants);
} finally {
$this->resolving = false;
}
}
/**
* @param list<Grant> $grants
*/
private function build(int $teamId, array $grants): AccessGrants
{
$teamLevels = [];
$byScope = [AccessScope::Project->value => [], AccessScope::Environment->value => [], AccessScope::Resource->value => []];
foreach ($grants as $grant) {
if ($grant->levels === []) {
continue;
}
if ($grant->scope === AccessScope::Team) {
$teamLevels += AccessGrants::expand($grant->levels);
continue;
}
$existing = $byScope[$grant->scope->value][$grant->uuid] ?? [];
$byScope[$grant->scope->value][$grant->uuid] = $existing + AccessGrants::expand($grant->levels);
}
$projectLevels = [];
$projectEnvironmentIds = [];
if ($byScope[AccessScope::Project->value] !== []) {
$projects = Project::withoutGlobalScope(VisibleThroughAccessGrants::class)
->where('team_id', $teamId)
->whereIn('uuid', array_keys($byScope[AccessScope::Project->value]))
->get(['id', 'uuid']);
foreach ($projects as $project) {
$projectLevels[$project->id] = $byScope[AccessScope::Project->value][$project->uuid];
}
$projectEnvironmentIds = Environment::withoutGlobalScope(VisibleThroughAccessGrants::class)
->whereIn('project_id', array_keys($projectLevels))
->pluck('id')
->all();
}
$environmentLevels = [];
$environmentProjects = [];
if ($byScope[AccessScope::Environment->value] !== []) {
$environments = $this->teamEnvironments($teamId)
->whereIn('environments.uuid', array_keys($byScope[AccessScope::Environment->value]))
->get(['environments.id', 'environments.uuid', 'environments.project_id']);
foreach ($environments as $environment) {
$environmentLevels[$environment->id] = $byScope[AccessScope::Environment->value][$environment->uuid];
$environmentProjects[$environment->id] = $environment->project_id;
}
}
$resourceLevels = [];
$resourceEnvironments = [];
$resourceUuids = array_keys($byScope[AccessScope::Resource->value]);
if ($resourceUuids !== []) {
$teamEnvironmentProjects = $this->teamEnvironments($teamId)->pluck('environments.project_id', 'environments.id')->all();
foreach (self::RESOURCE_MODELS as $modelClass) {
$resources = $modelClass::withoutGlobalScope(VisibleThroughAccessGrants::class)
->whereIn('uuid', $resourceUuids)
->whereIn('environment_id', array_keys($teamEnvironmentProjects))
->get(['uuid', 'environment_id']);
foreach ($resources as $resource) {
$resourceLevels[$resource->uuid] = $byScope[AccessScope::Resource->value][$resource->uuid];
$resourceEnvironments[$resource->uuid] = $resource->environment_id;
$environmentProjects[$resource->environment_id] = $teamEnvironmentProjects[$resource->environment_id];
}
}
}
return new AccessGrants(
$teamLevels,
$projectLevels,
$environmentLevels,
$resourceLevels,
$environmentProjects,
$resourceEnvironments,
$projectEnvironmentIds,
);
}
private function teamEnvironments(int $teamId): Builder
{
return Environment::withoutGlobalScope(VisibleThroughAccessGrants::class)
->join('projects', 'projects.id', '=', 'environments.project_id')
->where('projects.team_id', $teamId);
}
/**
* @return array{0: int, 1: int|null, 2: int|null, 3: string|null}|null team, project, environment, resource UUID
*/
private function locate(Model $target): ?array
{
if ($target instanceof Project) {
return [$target->team_id, $target->id, null, null];
}
$environmentId = $target instanceof Environment ? $target->id : $target->getAttribute('environment_id');
if ($environmentId === null) {
return null;
}
$this->resolving = true;
try {
$environment = Environment::withoutGlobalScope(VisibleThroughAccessGrants::class)
->with(['project' => fn ($query) => $query->withoutGlobalScope(VisibleThroughAccessGrants::class)])
->find($environmentId);
} finally {
$this->resolving = false;
}
if ($environment?->project === null) {
return null;
}
$resourceUuid = $target instanceof Environment ? null : $target->getAttribute('uuid');
return [$environment->project->team_id, $environment->project_id, $environment->id, $resourceUuid];
}
/**
* @return array{0: int|null, 1: int|null, 2: string|null}|null project, environment, resource UUID
*/
private function locateScope(int $teamId, Grant $grant): ?array
{
if ($grant->scope === AccessScope::Team) {
return [null, null, null];
}
$layer = $this->resolve($teamId, [new Grant($grant->scope, $grant->uuid, [AccessLevel::Read])]);
return match ($grant->scope) {
AccessScope::Project => ($layer->visibleProjectIds()[0] ?? null) === null ? null : [$layer->visibleProjectIds()[0], null, null],
AccessScope::Environment => ($layer->fullEnvironmentIds()[0] ?? null) === null ? null : [$layer->visibleProjectIds()[0], $layer->fullEnvironmentIds()[0], null],
AccessScope::Resource => $layer->resourceUuids() === [] ? null : [$layer->visibleProjectIds()[0], $layer->visibleEnvironmentIds()[0], $grant->uuid],
};
}
}
+94
View File
@@ -0,0 +1,94 @@
<?php
namespace App\Services\Access;
use App\Enums\AccessLevel;
use App\Enums\AccessScope;
/**
* One access grant: a scope (team, project, environment, or one resource) and the levels it gives there.
*
* Stored as JSON on `team_user.access` (members) and `personal_access_tokens.scopes` (tokens).
* Scopes point to records by UUID, so a grant never stores a raw foreign key.
*/
final readonly class Grant
{
/**
* @param list<AccessLevel> $levels
*/
public function __construct(
public AccessScope $scope,
public ?string $uuid,
public array $levels,
) {}
/**
* @param list<AccessLevel> $levels
*/
public static function team(array $levels): self
{
return new self(AccessScope::Team, null, $levels);
}
/**
* @param array{scope?: string, uuid?: string|null, levels?: list<string>} $data
*/
public static function fromArray(array $data): ?self
{
$scope = AccessScope::tryFrom((string) ($data['scope'] ?? ''));
if ($scope === null) {
return null;
}
$uuid = $scope === AccessScope::Team ? null : ($data['uuid'] ?? null);
if ($scope !== AccessScope::Team && (! is_string($uuid) || $uuid === '')) {
return null;
}
return new self($scope, $uuid, AccessLevel::parseMany($data['levels'] ?? []));
}
/**
* @return list<self>
*/
public static function listFrom(mixed $stored): array
{
if (is_string($stored)) {
$stored = json_decode($stored, true);
}
if (! is_array($stored)) {
return [];
}
$grants = [];
foreach ($stored as $item) {
$grant = is_array($item) ? self::fromArray($item) : null;
if ($grant !== null) {
$grants[] = $grant;
}
}
return $grants;
}
/**
* @param list<self> $grants
* @return list<array{scope: string, uuid: string|null, levels: list<string>}>
*/
public static function listToArray(array $grants): array
{
return array_map(fn (self $grant) => $grant->toArray(), $grants);
}
/**
* @return array{scope: string, uuid: string|null, levels: list<string>}
*/
public function toArray(): array
{
return [
'scope' => $this->scope->value,
'uuid' => $this->uuid,
'levels' => array_map(fn (AccessLevel $level) => $level->value, $this->levels),
];
}
}
+6 -4
View File
@@ -2,6 +2,8 @@
namespace App\Traits;
use App\Models\Environment;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
trait AuthorizesResourceCreation
@@ -9,12 +11,12 @@ trait AuthorizesResourceCreation
use AuthorizesRequests;
/**
* Authorize creation of all supported resources.
* Authorize creation of all supported resources, in the environment when one is given.
*
* @throws \Illuminate\Auth\Access\AuthorizationException
* @throws AuthorizationException
*/
protected function authorizeResourceCreation(): void
protected function authorizeResourceCreation(?Environment $environment = null): void
{
$this->authorize('createAnyResource');
$this->authorize('createAnyResource', [$environment]);
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace App\Traits;
use App\Models\Scopes\VisibleThroughAccessGrants;
use App\Services\Access\AccessResolver;
trait ScopedByAccessGrants
{
public static function bootScopedByAccessGrants(): void
{
static::addGlobalScope(new VisibleThroughAccessGrants);
static::created(fn () => app(AccessResolver::class)->flush());
static::deleted(fn () => app(AccessResolver::class)->flush());
}
}
+7 -7
View File
@@ -17,6 +17,7 @@ use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Models\StandaloneSqlite;
use App\Models\SwarmDocker;
use App\Services\Access\AccessResolver;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
@@ -500,14 +501,13 @@ function deleteOldBackupsFromS3($backup): Collection
function isPublicPortAlreadyUsed(Server $server, int $port, ?string $id = null): bool
{
// A member limited to some projects must still see the ports of every database on the server.
$databases = app(AccessResolver::class)->withoutVisibilityScope(fn () => $server->databases());
$query = $databases->where('public_port', $port)->where('is_public', true);
if ($id) {
$foundDatabase = $server->databases()->where('public_port', $port)->where('is_public', true)->where('id', '!=', $id)->first();
} else {
$foundDatabase = $server->databases()->where('public_port', $port)->where('is_public', true)->first();
}
if ($foundDatabase) {
return true;
$query = $query->where('id', '!=', $id);
}
return false;
return $query->isNotEmpty();
}
+93 -61
View File
@@ -4,7 +4,10 @@ use App\Models\Application;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Services\Access\AccessResolver;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Gate;
/**
* Count domains in a comma-separated FQDN string (e.g. "https://a.com,https://b.com").
@@ -115,6 +118,29 @@ function applicationDomainEntries(Application $application): Collection
return $entries->values();
}
/**
* True when the authenticated user cannot see the resource, so a conflict must not reveal its name.
*/
function domainConflictResourceIsHidden(Model $resource): bool
{
return auth()->check() && Gate::denies('view', $resource);
}
/**
* @return array{domain: string, resource_name: string, resource_link: null, resource_uuid: null, resource_type: string, message: string}
*/
function hiddenDomainConflict(string $domain, string $type): array
{
return [
'domain' => $domain,
'resource_name' => 'another resource',
'resource_link' => null,
'resource_uuid' => null,
'resource_type' => $type,
'message' => "Domain $domain is already in use by another resource in this team",
];
}
function checkDomainUsage(ServiceApplication|Application|null $resource = null, ?string $domain = null)
{
$conflicts = [];
@@ -145,79 +171,72 @@ function checkDomainUsage(ServiceApplication|Application|null $resource = null,
return domainConflictKey((string) $domain);
});
// Filter applications by team if we have a current team
$appsQuery = Application::query();
if ($currentTeam) {
$appsQuery = $appsQuery->whereHas('environment.project', function ($query) use ($currentTeam) {
$query->where('team_id', $currentTeam->id);
});
}
$apps = $appsQuery->get();
// A member limited to some projects must still see conflicts with every resource of the team.
[$apps, $serviceApps] = app(AccessResolver::class)->withoutVisibilityScope(function () use ($currentTeam) {
$appsQuery = Application::query();
$serviceAppsQuery = ServiceApplication::query()->with('service');
if ($currentTeam) {
$appsQuery->whereHas('environment.project', function ($query) use ($currentTeam) {
$query->where('team_id', $currentTeam->id);
});
$serviceAppsQuery->whereHas('service.environment.project', function ($query) use ($currentTeam) {
$query->where('team_id', $currentTeam->id);
});
}
return [$appsQuery->get(), $serviceAppsQuery->get()];
});
foreach ($apps as $app) {
foreach (applicationDomainEntries($app) as $domainEntry) {
$naked_domain = $domainEntry['domain'];
if ($domains->contains(domainConflictKey($naked_domain))) {
if (data_get($resource, 'uuid')) {
if ($resource->uuid !== $app->uuid) {
$conflicts[] = [
'domain' => $naked_domain,
'resource_name' => $app->name,
'resource_link' => $app->link(),
'resource_type' => 'application',
'message' => "Domain $naked_domain is already in use by application '{$app->name}'".($domainEntry['service_name'] ? " (service: {$domainEntry['service_name']})" : ''),
...($domainEntry['service_name'] ? ['service_name' => $domainEntry['service_name']] : []),
];
}
} else {
$conflicts[] = [
'domain' => $naked_domain,
'resource_name' => $app->name,
'resource_link' => $app->link(),
'resource_type' => 'application',
'message' => "Domain $naked_domain is already in use by application '{$app->name}'".($domainEntry['service_name'] ? " (service: {$domainEntry['service_name']})" : ''),
...($domainEntry['service_name'] ? ['service_name' => $domainEntry['service_name']] : []),
];
}
if (! $domains->contains(domainConflictKey($naked_domain))) {
continue;
}
if (data_get($resource, 'uuid') && $resource->uuid === $app->uuid) {
continue;
}
if (domainConflictResourceIsHidden($app)) {
$conflicts[] = hiddenDomainConflict($naked_domain, 'application');
continue;
}
$conflicts[] = [
'domain' => $naked_domain,
'resource_name' => $app->name,
'resource_link' => $app->link(),
'resource_type' => 'application',
'message' => "Domain $naked_domain is already in use by application '{$app->name}'".($domainEntry['service_name'] ? " (service: {$domainEntry['service_name']})" : ''),
...($domainEntry['service_name'] ? ['service_name' => $domainEntry['service_name']] : []),
];
}
}
// Filter service applications by team if we have a current team
$serviceAppsQuery = ServiceApplication::query();
if ($currentTeam) {
$serviceAppsQuery = $serviceAppsQuery->whereHas('service.environment.project', function ($query) use ($currentTeam) {
$query->where('team_id', $currentTeam->id);
});
}
$apps = $serviceAppsQuery->get();
foreach ($apps as $app) {
foreach ($serviceApps as $app) {
$list_of_domains = collect(explode(',', $app->fqdn))->filter(fn ($fqdn) => $fqdn !== '');
foreach ($list_of_domains as $domain) {
if (str($domain)->endsWith('/')) {
$domain = str($domain)->beforeLast('/');
}
$naked_domain = str($domain)->value();
if ($domains->contains(domainConflictKey($naked_domain))) {
if (data_get($resource, 'uuid')) {
if ($resource->uuid !== $app->uuid) {
$conflicts[] = [
'domain' => $naked_domain,
'resource_name' => $app->service->name,
'resource_link' => $app->service->link(),
'resource_type' => 'service',
'message' => "Domain $naked_domain is already in use by service '{$app->service->name}'",
];
}
} elseif ($domain) {
$conflicts[] = [
'domain' => $naked_domain,
'resource_name' => $app->service->name,
'resource_link' => $app->service->link(),
'resource_type' => 'service',
'message' => "Domain $naked_domain is already in use by service '{$app->service->name}'",
];
}
if (! $domains->contains(domainConflictKey($naked_domain))) {
continue;
}
if (data_get($resource, 'uuid') && $resource->uuid === $app->uuid) {
continue;
}
if (domainConflictResourceIsHidden($app->service)) {
$conflicts[] = hiddenDomainConflict($naked_domain, 'service');
continue;
}
$conflicts[] = [
'domain' => $naked_domain,
'resource_name' => $app->service->name,
'resource_link' => $app->service->link(),
'resource_type' => 'service',
'message' => "Domain $naked_domain is already in use by service '{$app->service->name}'",
];
}
}
@@ -266,8 +285,11 @@ function checkIfDomainIsAlreadyUsedViaAPI(Collection|array $domains, ?string $te
return domainConflictKey((string) $domain);
});
$applications = Application::ownedByCurrentTeamAPI($teamId)->get(['fqdn', 'uuid', 'name', 'id', 'docker_compose_domains', 'build_pack']);
$serviceApplications = ServiceApplication::ownedByCurrentTeamAPI($teamId)->with('service:id,name')->get(['fqdn', 'uuid', 'id', 'service_id']);
// A token or member limited to some projects must still see conflicts with every resource of the team.
[$applications, $serviceApplications] = app(AccessResolver::class)->withoutVisibilityScope(fn () => [
Application::ownedByCurrentTeamAPI($teamId)->get(['fqdn', 'uuid', 'name', 'id', 'docker_compose_domains', 'build_pack', 'environment_id']),
ServiceApplication::ownedByCurrentTeamAPI($teamId)->with('service:id,name,uuid,environment_id')->get(['fqdn', 'uuid', 'id', 'service_id']),
]);
if ($uuid) {
$applications = $applications->filter(fn ($app) => $app->uuid !== $uuid);
@@ -278,6 +300,11 @@ function checkIfDomainIsAlreadyUsedViaAPI(Collection|array $domains, ?string $te
foreach (applicationDomainEntries($app) as $domainEntry) {
$naked_domain = $domainEntry['domain'];
if ($domains->contains(domainConflictKey($naked_domain))) {
if (domainConflictResourceIsHidden($app)) {
$conflicts[] = hiddenDomainConflict($naked_domain, 'application');
continue;
}
$conflicts[] = [
'domain' => $naked_domain,
'resource_name' => $app->name,
@@ -301,6 +328,11 @@ function checkIfDomainIsAlreadyUsedViaAPI(Collection|array $domains, ?string $te
}
$naked_domain = str($domain)->value();
if ($domains->contains(domainConflictKey($naked_domain))) {
if ($app->service !== null && domainConflictResourceIsHidden($app->service)) {
$conflicts[] = hiddenDomainConflict($naked_domain, 'service');
continue;
}
$conflicts[] = [
'domain' => $naked_domain,
'resource_name' => $app->service->name ?? 'Unknown Service',
@@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasColumn('team_user', 'access')) {
Schema::table('team_user', function (Blueprint $table) {
$table->json('access')->nullable();
});
}
if (! Schema::hasColumn('personal_access_tokens', 'scopes')) {
Schema::table('personal_access_tokens', function (Blueprint $table) {
$table->json('scopes')->nullable();
});
}
// Delete becomes its own ability. Tokens that could write could delete, so they keep that.
DB::table('personal_access_tokens')->orderBy('id')->chunkById(500, function ($tokens) {
foreach ($tokens as $token) {
$abilities = json_decode($token->abilities ?? '[]', true);
if (! is_array($abilities) || ! in_array('write', $abilities, true) || in_array('delete', $abilities, true)) {
continue;
}
$abilities[] = 'delete';
DB::table('personal_access_tokens')->where('id', $token->id)->update(['abilities' => json_encode($abilities)]);
}
});
}
public function down(): void
{
Schema::table('team_user', function (Blueprint $table) {
$table->dropColumn('access');
});
Schema::table('personal_access_tokens', function (Blueprint $table) {
$table->dropColumn('scopes');
});
}
};
@@ -39,7 +39,7 @@
<li class="flex items-start text-[12px] leading-5 text-red-600 dark:text-red-400">
<div>
<strong>{{ $conflict['domain'] }}</strong> is used by
@if ($conflict['resource_type'] === 'instance')
@if ($conflict['resource_type'] === 'instance' || blank($conflict['resource_link'] ?? null))
<strong>{{ $conflict['resource_name'] }}</strong>
@else
<a href="{{ $conflict['resource_link'] }}" target="_blank"
+18 -16
View File
@@ -101,42 +101,44 @@
<ul role="list" class="sidebar-menu -mx-1 flex min-h-0 flex-1 flex-col gap-y-0.5 overflow-y-auto px-1 pb-2">
@if (isSubscribed() || !isCloud())
@php
// Members limited to some projects only see project pages; team-level pages return 403 for them.
$teamWide = app(App\Services\Access\AccessResolver::class)->coversWholeTeam(auth()->user(), currentTeam()->id);
// An entry with children opens a submenu in place of the main menu (back button + its pages).
$navSections = collect([
'Workspace' => [
['label' => 'Dashboard', 'icon' => 'dashboard', 'href' => '/', 'active' => request()->is('/', 'dashboard/*', 'canvas')],
['label' => 'Projects', 'icon' => 'projects', 'href' => route('project.index'), 'active' => request()->is('projects', 'projects/*', 'project/*')],
['label' => 'Monitoring', 'icon' => 'graph', 'children' => [
['label' => 'Monitoring', 'icon' => 'graph', 'visible' => $teamWide, 'children' => [
['label' => 'Analytics', 'icon' => 'analytics', 'href' => route('analytics'), 'active' => request()->is('analytics')],
['label' => 'Metrics', 'icon' => 'graph', 'href' => route('metrics'), 'active' => request()->is('metrics')],
]],
['label' => 'Terminal', 'icon' => 'browser-terminal', 'href' => route('terminal'), 'active' => request()->is('terminal*'), 'navigate' => false, 'visible' => auth()->user()?->can('canAccessTerminal')],
['label' => 'Terminal', 'icon' => 'browser-terminal', 'href' => route('terminal'), 'active' => request()->is('terminal*'), 'navigate' => false, 'visible' => $teamWide && auth()->user()?->can('canAccessTerminal')],
],
'Infrastructure' => [
['label' => 'Servers', 'icon' => 'servers', 'href' => '/servers', 'active' => request()->is('server/*', 'servers', 'servers/*')],
['label' => 'Integrations', 'icon' => 'link', 'children' => [
['label' => 'Servers', 'icon' => 'servers', 'href' => '/servers', 'active' => request()->is('server/*', 'servers', 'servers/*'), 'visible' => $teamWide],
['label' => 'Integrations', 'icon' => 'link', 'visible' => $teamWide, 'children' => [
['label' => 'Sources', 'icon' => 'sources', 'href' => route('source.all'), 'active' => request()->is('sources', 'source/*')],
['label' => 'Destinations', 'icon' => 'destinations', 'href' => route('destination.index'), 'active' => request()->is('destinations', 'destination/*')],
['label' => 'Registries', 'icon' => 'layers', 'href' => route('registries.index'), 'active' => request()->is('registries'), 'visible' => auth()->user()?->isAdmin()],
['label' => 'S3 Storage', 'icon' => 'storages', 'href' => route('storage.index'), 'active' => request()->is('storages', 'storages/*')],
]],
['label' => 'Shared Variables', 'icon' => 'variables', 'children' => [
['label' => 'Overview', 'icon' => 'dashboard', 'href' => route('shared-variables.index'), 'active' => request()->is('shared-variables')],
['label' => 'Team', 'icon' => 'teams', 'href' => route('shared-variables.team.index'), 'active' => request()->is('shared-variables/team*')],
['label' => 'Overview', 'icon' => 'dashboard', 'href' => route('shared-variables.index'), 'active' => request()->is('shared-variables'), 'visible' => $teamWide],
['label' => 'Team', 'icon' => 'teams', 'href' => route('shared-variables.team.index'), 'active' => request()->is('shared-variables/team*'), 'visible' => $teamWide],
['label' => 'Projects', 'icon' => 'projects', 'href' => route('shared-variables.project.index'), 'active' => request()->is('shared-variables/projects', 'shared-variables/project/*')],
['label' => 'Environments', 'icon' => 'layers', 'href' => route('shared-variables.environment.index'), 'active' => request()->is('shared-variables/environments*')],
['label' => 'Servers', 'icon' => 'servers', 'href' => route('shared-variables.server.index'), 'active' => request()->is('shared-variables/servers', 'shared-variables/server/*')],
['label' => 'Servers', 'icon' => 'servers', 'href' => route('shared-variables.server.index'), 'active' => request()->is('shared-variables/servers', 'shared-variables/server/*'), 'visible' => $teamWide],
]],
],
'Manage' => [
['label' => 'Team', 'icon' => 'teams', 'children' => [
['label' => 'Team', 'icon' => 'teams', 'visible' => $teamWide, 'children' => [
['label' => 'General', 'icon' => 'settings', 'href' => route('team.index'), 'active' => request()->is('team')],
['label' => 'Members', 'icon' => 'teams', 'href' => route('team.member.index'), 'active' => request()->is('team/members*')],
['label' => 'Audit log', 'icon' => 'time-back', 'href' => route('team.audit-log'), 'active' => request()->is('team/audit-log'), 'visible' => auth()->user()?->isAdminOfTeam(currentTeam()->id)],
['label' => 'Admin View', 'icon' => 'admin', 'href' => route('team.admin-view'), 'active' => request()->is('team/admin'), 'visible' => isInstanceAdmin()],
['label' => 'Danger Zone', 'icon' => 'danger', 'href' => route('team.danger-zone'), 'active' => request()->is('team/danger'), 'visible' => auth()->user()?->can('delete', currentTeam()), 'sectionStart' => true],
]],
['label' => 'Notifications', 'icon' => 'notifications', 'children' => [
['label' => 'Notifications', 'icon' => 'notifications', 'visible' => $teamWide, 'children' => [
['label' => 'Email', 'icon' => 'mail', 'href' => route('notifications.email'), 'active' => request()->is('notifications/email')],
['label' => 'Discord', 'brandIcon' => 'discord', 'href' => route('notifications.discord'), 'active' => request()->is('notifications/discord')],
['label' => 'Telegram', 'brandIcon' => 'telegram', 'href' => route('notifications.telegram'), 'active' => request()->is('notifications/telegram')],
@@ -145,15 +147,15 @@
['label' => 'Webhook', 'icon' => 'destinations', 'href' => route('notifications.webhook'), 'active' => request()->is('notifications/webhook')],
]],
['label' => 'Keys & Tokens', 'icon' => 'keys', 'children' => [
['label' => 'Private Keys', 'icon' => 'keys', 'href' => route('security.private-key.index'), 'active' => request()->is('security/private-key*')],
['label' => 'Cloud Tokens', 'icon' => 'cloud', 'href' => route('security.cloud-tokens'), 'active' => request()->is('security/cloud-tokens*'), 'visible' => auth()->user()?->can('viewAny', App\Models\CloudProviderToken::class)],
['label' => 'Integration Tokens', 'icon' => 'network', 'href' => route('security.integration-tokens'), 'active' => request()->is('security/integration-tokens'), 'visible' => auth()->user()?->can('viewAny', App\Models\IntegrationToken::class)],
['label' => 'Cloud-Init Scripts', 'icon' => 'file-content', 'href' => route('security.cloud-init-scripts'), 'active' => request()->is('security/cloud-init-scripts*'), 'visible' => auth()->user()?->can('viewAny', App\Models\CloudInitScript::class)],
['label' => 'Private Keys', 'icon' => 'keys', 'href' => route('security.private-key.index'), 'active' => request()->is('security/private-key*'), 'visible' => $teamWide],
['label' => 'Cloud Tokens', 'icon' => 'cloud', 'href' => route('security.cloud-tokens'), 'active' => request()->is('security/cloud-tokens*'), 'visible' => $teamWide && auth()->user()?->can('viewAny', App\Models\CloudProviderToken::class)],
['label' => 'Integration Tokens', 'icon' => 'network', 'href' => route('security.integration-tokens'), 'active' => request()->is('security/integration-tokens'), 'visible' => $teamWide && auth()->user()?->can('viewAny', App\Models\IntegrationToken::class)],
['label' => 'Cloud-Init Scripts', 'icon' => 'file-content', 'href' => route('security.cloud-init-scripts'), 'active' => request()->is('security/cloud-init-scripts*'), 'visible' => $teamWide && auth()->user()?->can('viewAny', App\Models\CloudInitScript::class)],
['label' => 'API Tokens', 'icon' => 'code', 'href' => route('security.api-tokens'), 'active' => request()->is('security/api-tokens')],
]],
['label' => 'Subscription', 'icon' => 'subscription', 'href' => isCloud() ? route('subscription.show') : '', 'active' => request()->is('subscription*'), 'visible' => isCloud() && auth()->user()->isAdmin()],
['label' => 'Tags', 'icon' => 'tags', 'href' => route('tags.show'), 'active' => request()->is('tags*', 'canvas/tags')],
['label' => 'Settings', 'icon' => 'settings', 'visible' => isInstanceAdmin(), 'children' => [
['label' => 'Subscription', 'icon' => 'subscription', 'href' => isCloud() ? route('subscription.show') : '', 'active' => request()->is('subscription*'), 'visible' => $teamWide && isCloud() && auth()->user()->isAdmin()],
['label' => 'Tags', 'icon' => 'tags', 'href' => route('tags.show'), 'active' => request()->is('tags*', 'canvas/tags'), 'visible' => $teamWide],
['label' => 'Settings', 'icon' => 'settings', 'visible' => $teamWide && isInstanceAdmin(), 'children' => [
['label' => 'General', 'icon' => 'settings', 'href' => route('settings.index'), 'active' => request()->is('settings')],
['label' => 'Advanced', 'icon' => 'grid', 'href' => route('settings.advanced'), 'active' => request()->is('settings/advanced')],
['label' => 'Updates', 'icon' => 'refresh3', 'href' => route('settings.updates'), 'active' => request()->is('settings/updates')],
@@ -264,6 +264,7 @@
@endif
</section>
@if ($showsTeamSections)
<section class="@container mb-0! min-w-0" x-data="{
viewMode: @js($this->listView('dashboard_servers', 'table')),
setViewMode(mode) {
@@ -404,6 +405,7 @@
</div>
@endif
</section>
@endif
</div>
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More