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