From 2796609e9677b07b8bcb519664331ebd61801e9c Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:13:20 +0100 Subject: [PATCH] feat(access): add scoped grants for team members and API tokens (#12329) --- app/Enums/AccessLevel.php | 64 ++ app/Enums/AccessScope.php | 11 + .../Api/ApplicationsController.php | 4 +- .../Controllers/Api/DatabasesController.php | 6 +- app/Http/Controllers/Api/DeployController.php | 17 +- .../Controllers/Api/ServicesController.php | 5 +- .../SharedEnvironmentVariablesController.php | 14 +- app/Http/Kernel.php | 2 + app/Http/Middleware/ApiAbility.php | 43 +- app/Http/Middleware/CanCreateResources.php | 17 +- app/Http/Middleware/EnsureTeamWideAccess.php | 30 + .../EnsureTokenBelongsToCurrentTeamMember.php | 53 +- app/Livewire/Dashboard.php | 16 +- app/Livewire/Dashboard/ActiveDeployments.php | 3 +- app/Livewire/DeploymentsIndicator.php | 1 + app/Livewire/GlobalSearch.php | 784 +++++++++--------- app/Livewire/Project/Index.php | 2 +- app/Livewire/Project/New/DockerCompose.php | 3 +- app/Livewire/Project/New/DockerImage.php | 3 +- .../Project/New/GithubPrivateRepository.php | 3 +- .../New/GithubPrivateRepositoryDeployKey.php | 7 +- .../Project/New/GitlabPrivateRepository.php | 3 +- .../Project/New/PublicGitRepository.php | 3 +- app/Livewire/Project/New/SimpleDockerfile.php | 3 +- app/Livewire/Project/Resource/Create.php | 3 +- .../Shared/EnvironmentVariable/All.php | 5 +- .../Shared/EnvironmentVariable/Show.php | 4 +- app/Livewire/Project/Show.php | 9 +- app/Livewire/Security/ApiTokens.php | 145 +++- app/Livewire/Team/Member.php | 2 +- app/Livewire/Team/Member/Access.php | 243 ++++++ app/Mcp/Concerns/ResolvesTeam.php | 58 +- .../InfrastructureOverviewResource.php | 2 +- app/Mcp/Servers/CoolifyServer.php | 60 +- app/Mcp/Tools/CloudProvider.php | 2 +- app/Mcp/Tools/CoolifyHelp.php | 2 +- app/Mcp/Tools/GetInfrastructureOverview.php | 5 +- app/Mcp/Tools/ListSharedEnvKeys.php | 5 +- app/Mcp/Tools/ListUnhealthyResources.php | 2 +- app/Mcp/Tools/ManageApplication.php | 4 +- app/Mcp/Tools/ManageBackup.php | 4 +- app/Mcp/Tools/ManageDatabase.php | 2 +- app/Mcp/Tools/ManageDestination.php | 2 +- app/Mcp/Tools/ManageEnv.php | 2 +- app/Mcp/Tools/ManageGitSource.php | 2 +- app/Mcp/Tools/ManagePreview.php | 2 +- app/Mcp/Tools/ManagePrivateKey.php | 2 +- app/Mcp/Tools/ManageProject.php | 4 +- app/Mcp/Tools/ManageRegistry.php | 2 +- app/Mcp/Tools/ManageS3Storage.php | 2 +- app/Mcp/Tools/ManageScheduledTask.php | 2 +- app/Mcp/Tools/ManageServer.php | 2 +- app/Mcp/Tools/ManageService.php | 2 +- app/Mcp/Tools/ManageSharedEnv.php | 2 +- app/Mcp/Tools/ManageStorage.php | 4 +- app/Mcp/Tools/ManageTags.php | 4 +- app/Mcp/Tools/SearchResources.php | 3 +- app/Models/Application.php | 3 +- app/Models/ApplicationDeploymentQueue.php | 16 + app/Models/ApplicationSetting.php | 6 +- app/Models/Environment.php | 3 +- app/Models/PersonalAccessToken.php | 2 + app/Models/Project.php | 3 +- .../Scopes/VisibleThroughAccessGrants.php | 43 + app/Models/Service.php | 3 +- app/Models/StandaloneClickhouse.php | 3 +- app/Models/StandaloneDragonfly.php | 3 +- app/Models/StandaloneKeydb.php | 3 +- app/Models/StandaloneMariadb.php | 3 +- app/Models/StandaloneMongodb.php | 3 +- app/Models/StandaloneMysql.php | 3 +- app/Models/StandalonePostgresql.php | 3 +- app/Models/StandaloneRedis.php | 3 +- app/Models/StandaloneSqlite.php | 3 +- app/Models/Team.php | 2 +- app/Models/User.php | 13 +- app/Policies/ApiTokenPolicy.php | 40 +- app/Policies/ApplicationPolicy.php | 53 +- app/Policies/ApplicationPreviewPolicy.php | 27 +- app/Policies/ApplicationSettingPolicy.php | 19 +- app/Policies/DatabasePolicy.php | 74 +- app/Policies/EnvironmentPolicy.php | 28 +- app/Policies/EnvironmentVariablePolicy.php | 35 +- app/Policies/ProjectPolicy.php | 14 +- app/Policies/ResourceCreatePolicy.php | 16 +- app/Policies/ScheduledTaskPolicy.php | 33 +- app/Policies/ServicePolicy.php | 47 +- .../SharedEnvironmentVariablePolicy.php | 37 +- app/Providers/AppServiceProvider.php | 2 + app/Services/Access/AccessGrants.php | 124 +++ app/Services/Access/AccessResolver.php | 468 +++++++++++ app/Services/Access/Grant.php | 94 +++ app/Traits/AuthorizesResourceCreation.php | 10 +- app/Traits/ScopedByAccessGrants.php | 16 + bootstrap/helpers/databases.php | 14 +- bootstrap/helpers/domains.php | 154 ++-- ...cess_grants_to_team_members_and_tokens.php | 46 + .../domain-conflict-modal.blade.php | 2 +- resources/views/components/navbar.blade.php | 34 +- resources/views/livewire/dashboard.blade.php | 2 + .../livewire/project/resource/index.blade.php | 4 +- .../livewire/security/api-tokens.blade.php | 41 +- .../views/livewire/team/member.blade.php | 13 + .../livewire/team/member/access.blade.php | 90 ++ .../livewire/team/member/index.blade.php | 4 + routes/api.php | 74 +- routes/web.php | 207 ++--- tests/Feature/Access/AccessGrantsTest.php | 142 ++++ tests/Feature/Access/AccessPoliciesTest.php | 269 ++++++ tests/Feature/Access/MemberGrantsTest.php | 240 ++++++ tests/Feature/Access/ScopedApiTokenTest.php | 346 ++++++++ .../Api/ServerDeleteFromProviderApiTest.php | 2 +- .../Api/ServerDockerRegistriesApiTest.php | 2 +- .../Api/ServiceDeleteMessageApiTest.php | 2 +- tests/Feature/ApplicationDomainsTest.php | 10 + .../Authorization/LegacyMemberTokenTest.php | 8 +- tests/Feature/Mcp/McpApplicationToolsTest.php | 4 +- tests/Feature/Mcp/McpDataToolsTest.php | 2 +- tests/Feature/Mcp/McpOperationsToolsTest.php | 2 +- tests/Feature/Mcp/McpReadToolsTest.php | 4 +- tests/Feature/Mcp/McpServerToolsTest.php | 6 +- tests/Feature/Mcp/McpWriteToolsTest.php | 2 +- ...ServerDeleteFailsQueuedDeploymentsTest.php | 2 +- tests/Unit/Policies/ApiTokenPolicyTest.php | 4 + tests/Unit/Policies/ApplicationPolicyTest.php | 94 ++- .../Policies/ApplicationPreviewPolicyTest.php | 63 +- .../Policies/ApplicationSettingPolicyTest.php | 51 +- tests/Unit/Policies/DatabasePolicyTest.php | 104 +-- tests/Unit/Policies/EnvironmentPolicyTest.php | 78 +- .../EnvironmentVariablePolicyTest.php | 70 +- tests/Unit/Policies/ProjectPolicyTest.php | 60 +- .../Policies/ResourceCreatePolicyTest.php | 24 +- tests/Unit/Policies/ServicePolicyTest.php | 102 ++- .../SharedEnvironmentVariablePolicyTest.php | 25 +- 134 files changed, 4031 insertions(+), 1321 deletions(-) create mode 100644 app/Enums/AccessLevel.php create mode 100644 app/Enums/AccessScope.php create mode 100644 app/Http/Middleware/EnsureTeamWideAccess.php create mode 100644 app/Livewire/Team/Member/Access.php create mode 100644 app/Models/Scopes/VisibleThroughAccessGrants.php create mode 100644 app/Services/Access/AccessGrants.php create mode 100644 app/Services/Access/AccessResolver.php create mode 100644 app/Services/Access/Grant.php create mode 100644 app/Traits/ScopedByAccessGrants.php create mode 100644 database/migrations/2026_10_25_100000_add_access_grants_to_team_members_and_tokens.php create mode 100644 resources/views/livewire/team/member/access.blade.php create mode 100644 tests/Feature/Access/AccessGrantsTest.php create mode 100644 tests/Feature/Access/AccessPoliciesTest.php create mode 100644 tests/Feature/Access/MemberGrantsTest.php create mode 100644 tests/Feature/Access/ScopedApiTokenTest.php diff --git a/app/Enums/AccessLevel.php b/app/Enums/AccessLevel.php new file mode 100644 index 0000000000..6ec41a3506 --- /dev/null +++ b/app/Enums/AccessLevel.php @@ -0,0 +1,64 @@ + + */ + 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 $values + * @return list + */ + 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; + } +} diff --git a/app/Enums/AccessScope.php b/app/Enums/AccessScope.php new file mode 100644 index 0000000000..0846479401 --- /dev/null +++ b/app/Enums/AccessScope.php @@ -0,0 +1,11 @@ +authorize('create', Application::class); - // removeUnnecessaryFieldsFromRequest() strips force_domain_override before the docker_compose_domains checks run. $forceDomainOverride = $request->boolean('force_domain_override'); @@ -1428,6 +1426,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); @@ -6063,6 +6062,7 @@ class ApplicationsController extends Controller } $this->authorize('update', $application); + $this->authorize('create', [Application::class, $application->environment]); $destination = StandaloneDocker::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->destination_uuid)->first() ?? SwarmDocker::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->destination_uuid)->first(); diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 634f20720d..61c1860ce6 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -2057,9 +2057,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'); } @@ -2104,6 +2101,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); @@ -5193,6 +5192,7 @@ class DatabasesController extends Controller } $this->authorize('update', $database); + $this->authorize('create', [StandalonePostgresql::class, $database->environment]); $destination = StandaloneDocker::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->destination_uuid)->first() ?? SwarmDocker::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->destination_uuid)->first(); diff --git a/app/Http/Controllers/Api/DeployController.php b/app/Http/Controllers/Api/DeployController.php index 5c405f3e17..885006909d 100644 --- a/app/Http/Controllers/Api/DeployController.php +++ b/app/Http/Controllers/Api/DeployController.php @@ -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; @@ -75,7 +76,12 @@ class DeployController extends Controller return invalidTokenResponse(); } $servers = Server::whereTeamId($teamId)->get(); - $deployments_per_server = ApplicationDeploymentQueue::whereIn('status', ['in_progress', 'queued'])->whereIn('server_id', $servers->pluck('id'))->get()->sortBy('id'); + $query = ApplicationDeploymentQueue::whereIn('status', ['in_progress', 'queued'])->whereIn('server_id', $servers->pluck('id')); + 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); }); @@ -233,6 +239,15 @@ class DeployController extends Controller if (! $servers->contains($deployment->server_id)) { 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)) { + $deploymentApplication = $deployment->application()->withTrashed()->first(); + if (! $deploymentApplication) { + return response()->json(['message' => 'Deployment not found.'], 404); + } + $this->authorize('deploy', $deploymentApplication); + } // Check if deployment can be cancelled (must be queued or in_progress) $cancellableStatuses = [ diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 0a86606518..a03e1da78d 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -398,8 +398,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'); } @@ -482,6 +480,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); @@ -691,6 +690,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); @@ -3308,6 +3308,7 @@ class ServicesController extends Controller } $this->authorize('update', $service); + $this->authorize('create', [Service::class, $service->environment]); $destination = StandaloneDocker::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->destination_uuid)->first() ?? SwarmDocker::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->destination_uuid)->first(); diff --git a/app/Http/Controllers/Api/SharedEnvironmentVariablesController.php b/app/Http/Controllers/Api/SharedEnvironmentVariablesController.php index 32145f4ec3..d87750e335 100644 --- a/app/Http/Controllers/Api/SharedEnvironmentVariablesController.php +++ b/app/Http/Controllers/Api/SharedEnvironmentVariablesController.php @@ -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, diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 48710f88fe..26cbf3ac45 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -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, diff --git a/app/Http/Middleware/ApiAbility.php b/app/Http/Middleware/ApiAbility.php index 8951ad50de..3a48bc6b1d 100644 --- a/app/Http/Middleware/ApiAbility.php +++ b/app/Http/Middleware/ApiAbility.php @@ -2,40 +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)) { - $tokenAbilities = $token->abilities ?? []; - $disallowed = array_intersect($tokenAbilities, self::MEMBER_DISALLOWED_ABILITIES); + 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); } diff --git a/app/Http/Middleware/CanCreateResources.php b/app/Http/Middleware/CanCreateResources.php index 874feb3476..7cfa06b629 100644 --- a/app/Http/Middleware/CanCreateResources.php +++ b/app/Http/Middleware/CanCreateResources.php @@ -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.'); } diff --git a/app/Http/Middleware/EnsureTeamWideAccess.php b/app/Http/Middleware/EnsureTeamWideAccess.php new file mode 100644 index 0000000000..c41928e4e0 --- /dev/null +++ b/app/Http/Middleware/EnsureTeamWideAccess.php @@ -0,0 +1,30 @@ +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); + } +} diff --git a/app/Http/Middleware/EnsureTokenBelongsToCurrentTeamMember.php b/app/Http/Middleware/EnsureTokenBelongsToCurrentTeamMember.php index 83ae05400b..2483fcf35c 100644 --- a/app/Http/Middleware/EnsureTokenBelongsToCurrentTeamMember.php +++ b/app/Http/Middleware/EnsureTokenBelongsToCurrentTeamMember.php @@ -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)]); + } } diff --git a/app/Livewire/Dashboard.php b/app/Livewire/Dashboard.php index aa32fe6770..f04c960345 100644 --- a/app/Livewire/Dashboard.php +++ b/app/Livewire/Dashboard.php @@ -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([ diff --git a/app/Livewire/Dashboard/ActiveDeployments.php b/app/Livewire/Dashboard/ActiveDeployments.php index 29e0c1cd24..91c8c0e38c 100644 --- a/app/Livewire/Dashboard/ActiveDeployments.php +++ b/app/Livewire/Dashboard/ActiveDeployments.php @@ -39,7 +39,8 @@ class ActiveDeployments extends Component $baseQuery = ApplicationDeploymentQueue::query() ->with(['application.environment.project']) - ->whereIn('server_id', $serverIds); + ->whereIn('server_id', $serverIds) + ->visibleToCurrentActor(); $this->activeDeployments = (clone $baseQuery) ->whereIn('status', [ diff --git a/app/Livewire/DeploymentsIndicator.php b/app/Livewire/DeploymentsIndicator.php index 190da1a097..d4dae3f593 100644 --- a/app/Livewire/DeploymentsIndicator.php +++ b/app/Livewire/DeploymentsIndicator.php @@ -34,6 +34,7 @@ class DeploymentsIndicator extends Component return ApplicationDeploymentQueue::with(['application.environment.project']) ->whereIn('status', ['in_progress', 'queued']) ->whereIn('server_id', $servers->pluck('id')) + ->visibleToCurrentActor() ->orderBy('id') ->get([ 'id', diff --git a/app/Livewire/GlobalSearch.php b/app/Livewire/GlobalSearch.php index 3e9733f063..027db23ec1 100644 --- a/app/Livewire/GlobalSearch.php +++ b/app/Livewire/GlobalSearch.php @@ -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; @@ -254,390 +255,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> + */ + 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(); @@ -932,6 +945,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; } diff --git a/app/Livewire/Project/Index.php b/app/Livewire/Project/Index.php index e3ba5f4f3c..f8556a2eb1 100644 --- a/app/Livewire/Project/Index.php +++ b/app/Livewire/Project/Index.php @@ -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, diff --git a/app/Livewire/Project/New/DockerCompose.php b/app/Livewire/Project/New/DockerCompose.php index 80f9f481b4..2fb883d6b2 100644 --- a/app/Livewire/Project/New/DockerCompose.php +++ b/app/Livewire/Project/New/DockerCompose.php @@ -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); diff --git a/app/Livewire/Project/New/DockerImage.php b/app/Livewire/Project/New/DockerImage.php index ab6063e091..0e660abe3c 100644 --- a/app/Livewire/Project/New/DockerImage.php +++ b/app/Livewire/Project/New/DockerImage.php @@ -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(); diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php index 59dce3cf59..fb2ee74c13 100644 --- a/app/Livewire/Project/New/GithubPrivateRepository.php +++ b/app/Livewire/Project/New/GithubPrivateRepository.php @@ -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), diff --git a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php index 5a777d7a59..35c43aa888 100644 --- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php +++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php @@ -127,7 +127,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; @@ -148,8 +150,6 @@ class GithubPrivateRepositoryDeployKey extends Component public function submit() { - $this->authorize('create', Application::class); - $privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id); $this->validate(); @@ -168,6 +168,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(), diff --git a/app/Livewire/Project/New/GitlabPrivateRepository.php b/app/Livewire/Project/New/GitlabPrivateRepository.php index 3da796a81e..849ba7ccd1 100644 --- a/app/Livewire/Project/New/GitlabPrivateRepository.php +++ b/app/Livewire/Project/New/GitlabPrivateRepository.php @@ -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(); diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php index a898204fc2..268fb045b7 100644 --- a/app/Livewire/Project/New/PublicGitRepository.php +++ b/app/Livewire/Project/New/PublicGitRepository.php @@ -276,8 +276,6 @@ class PublicGitRepository extends Component public function submit() { try { - $this->authorize('create', Application::class); - $this->validate(); // Additional validation for git repository and branch @@ -312,6 +310,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 = [ diff --git a/app/Livewire/Project/New/SimpleDockerfile.php b/app/Livewire/Project/New/SimpleDockerfile.php index 24f21b4cb5..e04ff83cf2 100644 --- a/app/Livewire/Project/New/SimpleDockerfile.php +++ b/app/Livewire/Project/New/SimpleDockerfile.php @@ -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) { diff --git a/app/Livewire/Project/Resource/Create.php b/app/Livewire/Project/Resource/Create.php index d523fb3b9f..7faf90c500 100644 --- a/app/Livewire/Project/Resource/Create.php +++ b/app/Livewire/Project/Resource/Create.php @@ -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) { diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php index 736075dc7c..55b529fd9b 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php @@ -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, diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php index 6dc835fa95..fb0fa4a772 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php @@ -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() diff --git a/app/Livewire/Project/Show.php b/app/Livewire/Project/Show.php index a055904d51..8f57d14417 100644 --- a/app/Livewire/Project/Show.php +++ b/app/Livewire/Project/Show.php @@ -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, diff --git a/app/Livewire/Security/ApiTokens.php b/app/Livewire/Security/ApiTokens.php index 0a255370e5..8ba02ed298 100644 --- a/app/Livewire/Security/ApiTokens.php +++ b/app/Livewire/Security/ApiTokens.php @@ -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:", "environment:", or "resource:". + * + * @var list + */ + 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 + */ + #[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)) { @@ -122,35 +192,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(); @@ -161,6 +241,31 @@ class ApiTokens extends Component } } + /** + * @return list 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 { diff --git a/app/Livewire/Team/Member.php b/app/Livewire/Team/Member.php index f28087056f..45b0affdd3 100644 --- a/app/Livewire/Team/Member.php +++ b/app/Livewire/Team/Member.php @@ -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); diff --git a/app/Livewire/Team/Member/Access.php b/app/Livewire/Team/Member/Access.php new file mode 100644 index 0000000000..7c085fe1d3 --- /dev/null +++ b/app/Livewire/Team/Member/Access.php @@ -0,0 +1,243 @@ +:` 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}> */ + 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 + */ + #[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 $levels + * @return array{key: string, target: string, levels: array} + */ + 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(), + ]; + } +} diff --git a/app/Mcp/Concerns/ResolvesTeam.php b/app/Mcp/Concerns/ResolvesTeam.php index d77455b170..ced115cae7 100644 --- a/app/Mcp/Concerns/ResolvesTeam.php +++ b/app/Mcp/Concerns/ResolvesTeam.php @@ -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 - */ - 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 + */ + 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); diff --git a/app/Mcp/Resources/InfrastructureOverviewResource.php b/app/Mcp/Resources/InfrastructureOverviewResource.php index f2430b1ab3..0e44ca8750 100644 --- a/app/Mcp/Resources/InfrastructureOverviewResource.php +++ b/app/Mcp/Resources/InfrastructureOverviewResource.php @@ -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) => [ diff --git a/app/Mcp/Servers/CoolifyServer.php b/app/Mcp/Servers/CoolifyServer.php index 115b36c0fc..a89f412b6b 100644 --- a/app/Mcp/Servers/CoolifyServer.php +++ b/app/Mcp/Servers/CoolifyServer.php @@ -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> + */ + 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> + */ + 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, diff --git a/app/Mcp/Tools/CloudProvider.php b/app/Mcp/Tools/CloudProvider.php index fdc545c258..476fc1ab1f 100644 --- a/app/Mcp/Tools/CloudProvider.php +++ b/app/Mcp/Tools/CloudProvider.php @@ -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 _, 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)'], diff --git a/app/Mcp/Tools/CoolifyHelp.php b/app/Mcp/Tools/CoolifyHelp.php index a3e4e09c36..27c36bdd11 100644 --- a/app/Mcp/Tools/CoolifyHelp.php +++ b/app/Mcp/Tools/CoolifyHelp.php @@ -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() => [ diff --git a/app/Mcp/Tools/GetInfrastructureOverview.php b/app/Mcp/Tools/GetInfrastructureOverview.php index dea213425f..5bfa78b3a7 100644 --- a/app/Mcp/Tools/GetInfrastructureOverview.php +++ b/app/Mcp/Tools/GetInfrastructureOverview.php @@ -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(); diff --git a/app/Mcp/Tools/ListSharedEnvKeys.php b/app/Mcp/Tools/ListSharedEnvKeys.php index beb8f7abc4..ea8713e161 100644 --- a/app/Mcp/Tools/ListSharedEnvKeys.php +++ b/app/Mcp/Tools/ListSharedEnvKeys.php @@ -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]); } diff --git a/app/Mcp/Tools/ListUnhealthyResources.php b/app/Mcp/Tools/ListUnhealthyResources.php index 3fd3ad56ed..a97875ac17 100644 --- a/app/Mcp/Tools/ListUnhealthyResources.php +++ b/app/Mcp/Tools/ListUnhealthyResources.php @@ -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); diff --git a/app/Mcp/Tools/ManageApplication.php b/app/Mcp/Tools/ManageApplication.php index bdb1bf898a..1b995ef965 100644 --- a/app/Mcp/Tools/ManageApplication.php +++ b/app/Mcp/Tools/ManageApplication.php @@ -17,11 +17,11 @@ class ManageApplication extends RestActionTool { 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)'], - 'delete' => ['method' => 'DELETE', 'path' => 'applications/{uuid}', 'ability' => 'write', '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)'], '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'], ]; } diff --git a/app/Mcp/Tools/ManageBackup.php b/app/Mcp/Tools/ManageBackup.php index 244485fee5..983f20bec3 100644 --- a/app/Mcp/Tools/ManageBackup.php +++ b/app/Mcp/Tools/ManageBackup.php @@ -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 ...)'], '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'], - '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)'], ]; } diff --git a/app/Mcp/Tools/ManageDatabase.php b/app/Mcp/Tools/ManageDatabase.php index 3dadf67e26..9928d2d74d 100644 --- a/app/Mcp/Tools/ManageDatabase.php +++ b/app/Mcp/Tools/ManageDatabase.php @@ -17,7 +17,7 @@ class ManageDatabase extends RestActionTool { 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)'], - 'delete' => ['method' => 'DELETE', 'path' => 'databases/{uuid}', 'ability' => 'write', '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)'], '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' => 'write', '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'], diff --git a/app/Mcp/Tools/ManageDestination.php b/app/Mcp/Tools/ManageDestination.php index fa3f65f107..e7b60ddcfd 100644 --- a/app/Mcp/Tools/ManageDestination.php +++ b/app/Mcp/Tools/ManageDestination.php @@ -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'], ]; } diff --git a/app/Mcp/Tools/ManageEnv.php b/app/Mcp/Tools/ManageEnv.php index 676afb81c2..8398e92c21 100644 --- a/app/Mcp/Tools/ManageEnv.php +++ b/app/Mcp/Tools/ManageEnv.php @@ -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'], ]; } diff --git a/app/Mcp/Tools/ManageGitSource.php b/app/Mcp/Tools/ManageGitSource.php index 435d8950ff..ce20ed1978 100644 --- a/app/Mcp/Tools/ManageGitSource.php +++ b/app/Mcp/Tools/ManageGitSource.php @@ -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'], ]; } diff --git a/app/Mcp/Tools/ManagePreview.php b/app/Mcp/Tools/ManagePreview.php index 0f4b92b339..29d524bf6e 100644 --- a/app/Mcp/Tools/ManagePreview.php +++ b/app/Mcp/Tools/ManagePreview.php @@ -18,7 +18,7 @@ class ManagePreview extends RestActionTool 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' => 'write', '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'], ]; } diff --git a/app/Mcp/Tools/ManagePrivateKey.php b/app/Mcp/Tools/ManagePrivateKey.php index 36a9ced91b..782fa243b0 100644 --- a/app/Mcp/Tools/ManagePrivateKey.php +++ b/app/Mcp/Tools/ManagePrivateKey.php @@ -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'], ]; } diff --git a/app/Mcp/Tools/ManageProject.php b/app/Mcp/Tools/ManageProject.php index 6d84a61c5d..79cf90b788 100644 --- a/app/Mcp/Tools/ManageProject.php +++ b/app/Mcp/Tools/ManageProject.php @@ -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'], ]; } diff --git a/app/Mcp/Tools/ManageRegistry.php b/app/Mcp/Tools/ManageRegistry.php index 59749acbcd..43aebc7049 100644 --- a/app/Mcp/Tools/ManageRegistry.php +++ b/app/Mcp/Tools/ManageRegistry.php @@ -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' => 'write', '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'], ]; } diff --git a/app/Mcp/Tools/ManageS3Storage.php b/app/Mcp/Tools/ManageS3Storage.php index 2f5a92970d..2e8c863fd7 100644 --- a/app/Mcp/Tools/ManageS3Storage.php +++ b/app/Mcp/Tools/ManageS3Storage.php @@ -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'], ]; } diff --git a/app/Mcp/Tools/ManageScheduledTask.php b/app/Mcp/Tools/ManageScheduledTask.php index d5e0527404..83a504164f 100644 --- a/app/Mcp/Tools/ManageScheduledTask.php +++ b/app/Mcp/Tools/ManageScheduledTask.php @@ -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', diff --git a/app/Mcp/Tools/ManageServer.php b/app/Mcp/Tools/ManageServer.php index 2a036e07f9..6c518e66c6 100644 --- a/app/Mcp/Tools/ManageServer.php +++ b/app/Mcp/Tools/ManageServer.php @@ -18,7 +18,7 @@ class ManageServer extends RestActionTool 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_*)'], - '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)'], ]; } diff --git a/app/Mcp/Tools/ManageService.php b/app/Mcp/Tools/ManageService.php index bdbc6c0050..f81bc60d40 100644 --- a/app/Mcp/Tools/ManageService.php +++ b/app/Mcp/Tools/ManageService.php @@ -17,7 +17,7 @@ class ManageService extends RestActionTool { 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)'], - 'delete' => ['method' => 'DELETE', 'path' => 'services/{uuid}', 'ability' => 'write', '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)'], '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)'], diff --git a/app/Mcp/Tools/ManageSharedEnv.php b/app/Mcp/Tools/ManageSharedEnv.php index d21acd18d5..03f5feaaa1 100644 --- a/app/Mcp/Tools/ManageSharedEnv.php +++ b/app/Mcp/Tools/ManageSharedEnv.php @@ -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'], ]; } diff --git a/app/Mcp/Tools/ManageStorage.php b/app/Mcp/Tools/ManageStorage.php index d59d95ca14..22d154ae32 100644 --- a/app/Mcp/Tools/ManageStorage.php +++ b/app/Mcp/Tools/ManageStorage.php @@ -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)'], '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)'], - '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 ...)'], - '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' => 'write', 'confirm' => true, 'description' => 'run the volume backup now (can stop the resource when stop_during_backup is set)'], ]; } diff --git a/app/Mcp/Tools/ManageTags.php b/app/Mcp/Tools/ManageTags.php index 37059bfe58..2c794bdfa7 100644 --- a/app/Mcp/Tools/ManageTags.php +++ b/app/Mcp/Tools/ManageTags.php @@ -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'], ]; } diff --git a/app/Mcp/Tools/SearchResources.php b/app/Mcp/Tools/SearchResources.php index 28ea617e38..37f24aef35 100644 --- a/app/Mcp/Tools/SearchResources.php +++ b/app/Mcp/Tools/SearchResources.php @@ -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) diff --git a/app/Models/Application.php b/app/Models/Application.php index 457c1940e8..d82288a5ce 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -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 */ - 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; diff --git a/app/Models/ApplicationDeploymentQueue.php b/app/Models/ApplicationDeploymentQueue.php index 2979cab170..2b30f3bed6 100644 --- a/app/Models/ApplicationDeploymentQueue.php +++ b/app/Models/ApplicationDeploymentQueue.php @@ -4,7 +4,9 @@ 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; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Carbon; @@ -48,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. * diff --git a/app/Models/ApplicationSetting.php b/app/Models/ApplicationSetting.php index abe317530f..211aef3bd1 100644 --- a/app/Models/ApplicationSetting.php +++ b/app/Models/ApplicationSetting.php @@ -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; @@ -143,10 +144,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 diff --git a/app/Models/Environment.php b/app/Models/Environment.php index 5521bd31c5..8eeecb0754 100644 --- a/app/Models/Environment.php +++ b/app/Models/Environment.php @@ -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 = [ diff --git a/app/Models/PersonalAccessToken.php b/app/Models/PersonalAccessToken.php index 503377bec1..b1b60414dc 100644 --- a/app/Models/PersonalAccessToken.php +++ b/app/Models/PersonalAccessToken.php @@ -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', ]; } } diff --git a/app/Models/Project.php b/app/Models/Project.php index 7df5ecad50..ea9afa94bb 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -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 = [ diff --git a/app/Models/Scopes/VisibleThroughAccessGrants.php b/app/Models/Scopes/VisibleThroughAccessGrants.php new file mode 100644 index 0000000000..8557c109e8 --- /dev/null +++ b/app/Models/Scopes/VisibleThroughAccessGrants.php @@ -0,0 +1,43 @@ +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())); + } + } + } +} diff --git a/app/Models/Service.php b/app/Models/Service.php index 2ebff2ad9c..790bd1c978 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -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'; diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php index eeaa59c97c..50a4bd56ad 100644 --- a/app/Models/StandaloneClickhouse.php +++ b/app/Models/StandaloneClickhouse.php @@ -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']; diff --git a/app/Models/StandaloneDragonfly.php b/app/Models/StandaloneDragonfly.php index 23d7131244..f9d7b3a29d 100644 --- a/app/Models/StandaloneDragonfly.php +++ b/app/Models/StandaloneDragonfly.php @@ -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', diff --git a/app/Models/StandaloneKeydb.php b/app/Models/StandaloneKeydb.php index d2aa105bd3..8b3aea2e01 100644 --- a/app/Models/StandaloneKeydb.php +++ b/app/Models/StandaloneKeydb.php @@ -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', diff --git a/app/Models/StandaloneMariadb.php b/app/Models/StandaloneMariadb.php index be5c464e0d..4168e92cb5 100644 --- a/app/Models/StandaloneMariadb.php +++ b/app/Models/StandaloneMariadb.php @@ -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', diff --git a/app/Models/StandaloneMongodb.php b/app/Models/StandaloneMongodb.php index 208b5e917a..09de0f7d9f 100644 --- a/app/Models/StandaloneMongodb.php +++ b/app/Models/StandaloneMongodb.php @@ -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', diff --git a/app/Models/StandaloneMysql.php b/app/Models/StandaloneMysql.php index 68a73ae573..29c21cd710 100644 --- a/app/Models/StandaloneMysql.php +++ b/app/Models/StandaloneMysql.php @@ -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', diff --git a/app/Models/StandalonePostgresql.php b/app/Models/StandalonePostgresql.php index 7829ab0d0e..6f8dbc3394 100644 --- a/app/Models/StandalonePostgresql.php +++ b/app/Models/StandalonePostgresql.php @@ -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', diff --git a/app/Models/StandaloneRedis.php b/app/Models/StandaloneRedis.php index d3c8004ae3..f7efb017b2 100644 --- a/app/Models/StandaloneRedis.php +++ b/app/Models/StandaloneRedis.php @@ -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']; diff --git a/app/Models/StandaloneSqlite.php b/app/Models/StandaloneSqlite.php index 2f6993865c..5162a72fc0 100644 --- a/app/Models/StandaloneSqlite.php +++ b/app/Models/StandaloneSqlite.php @@ -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'; diff --git a/app/Models/Team.php b/app/Models/Team.php index a6a6f82dae..cc74899e2e 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -316,7 +316,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() diff --git a/app/Models/User.php b/app/Models/User.php index 1257f3c857..d92f620dcf 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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 */ diff --git a/app/Policies/ApiTokenPolicy.php b/app/Policies/ApiTokenPolicy.php index e9ef4d7c9e..9ec28e9942 100644 --- a/app/Policies/ApiTokenPolicy.php +++ b/app/Policies/ApiTokenPolicy.php @@ -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 diff --git a/app/Policies/ApplicationPolicy.php b/app/Policies/ApplicationPolicy.php index 3dca4cdf7e..de2c732a3f 100644 --- a/app/Policies/ApplicationPolicy.php +++ b/app/Policies/ApplicationPolicy.php @@ -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); } } diff --git a/app/Policies/ApplicationPreviewPolicy.php b/app/Policies/ApplicationPreviewPolicy.php index 6e1b597249..8f203b2212 100644 --- a/app/Policies/ApplicationPreviewPolicy.php +++ b/app/Policies/ApplicationPreviewPolicy.php @@ -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); } } diff --git a/app/Policies/ApplicationSettingPolicy.php b/app/Policies/ApplicationSettingPolicy.php index be2137cb85..c37c84fd38 100644 --- a/app/Policies/ApplicationSettingPolicy.php +++ b/app/Policies/ApplicationSettingPolicy.php @@ -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); } } diff --git a/app/Policies/DatabasePolicy.php b/app/Policies/DatabasePolicy.php index 4217432b50..cbc94aa855 100644 --- a/app/Policies/DatabasePolicy.php +++ b/app/Policies/DatabasePolicy.php @@ -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; } } diff --git a/app/Policies/EnvironmentPolicy.php b/app/Policies/EnvironmentPolicy.php index e400ec9037..1bfd47f8d2 100644 --- a/app/Policies/EnvironmentPolicy.php +++ b/app/Policies/EnvironmentPolicy.php @@ -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; - } } diff --git a/app/Policies/EnvironmentVariablePolicy.php b/app/Policies/EnvironmentVariablePolicy.php index dd0f58918e..f2da0356dc 100644 --- a/app/Policies/EnvironmentVariablePolicy.php +++ b/app/Policies/EnvironmentVariablePolicy.php @@ -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); } } diff --git a/app/Policies/ProjectPolicy.php b/app/Policies/ProjectPolicy.php index 9d65b9130d..687c5e167b 100644 --- a/app/Policies/ProjectPolicy.php +++ b/app/Policies/ProjectPolicy.php @@ -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); } /** diff --git a/app/Policies/ResourceCreatePolicy.php b/app/Policies/ResourceCreatePolicy.php index ee2876d956..7ad6d48bf6 100644 --- a/app/Policies/ResourceCreatePolicy.php +++ b/app/Policies/ResourceCreatePolicy.php @@ -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); } } diff --git a/app/Policies/ScheduledTaskPolicy.php b/app/Policies/ScheduledTaskPolicy.php index fac7e7b228..fd4fbe7e21 100644 --- a/app/Policies/ScheduledTaskPolicy.php +++ b/app/Policies/ScheduledTaskPolicy.php @@ -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; + } } diff --git a/app/Policies/ServicePolicy.php b/app/Policies/ServicePolicy.php index 272ea1e930..2ab4f09ca5 100644 --- a/app/Policies/ServicePolicy.php +++ b/app/Policies/ServicePolicy.php @@ -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); } } diff --git a/app/Policies/SharedEnvironmentVariablePolicy.php b/app/Policies/SharedEnvironmentVariablePolicy.php index 21b6acb27b..08e9ba70f4 100644 --- a/app/Policies/SharedEnvironmentVariablePolicy.php +++ b/app/Policies/SharedEnvironmentVariablePolicy.php @@ -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); } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 1c36b36298..e0d6709015 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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. diff --git a/app/Services/Access/AccessGrants.php b/app/Services/Access/AccessGrants.php new file mode 100644 index 0000000000..73b8d23b5f --- /dev/null +++ b/app/Services/Access/AccessGrants.php @@ -0,0 +1,124 @@ + $teamLevels expanded levels of team-wide grants + * @param array> $projectLevels project ID => expanded levels + * @param array> $environmentLevels environment ID => expanded levels + * @param array> $resourceLevels resource UUID => expanded levels + * @param array $environmentProjects environment ID => project ID, for granted environments and parents of granted resources + * @param array $resourceEnvironments resource UUID => environment ID + * @param list $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 $levels + * @return array + */ + 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 + */ + 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 + */ + 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 + */ + public function fullEnvironmentIds(): array + { + return array_values(array_unique([ + ...array_keys($this->environmentLevels), + ...$this->projectEnvironmentIds, + ])); + } + + /** + * @return list + */ + public function resourceUuids(): array + { + return array_map('strval', array_keys($this->resourceLevels)); + } +} diff --git a/app/Services/Access/AccessResolver.php b/app/Services/Access/AccessResolver.php new file mode 100644 index 0000000000..19fa8ecb4e --- /dev/null +++ b/app/Services/Access/AccessResolver.php @@ -0,0 +1,468 @@ +> + */ + 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 */ + 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 + */ + public static function defaultMemberGrants(): array + { + return [Grant::team([AccessLevel::Read])]; + } + + /** + * @return list|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|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 $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 + */ + 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 + */ + 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 $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 $grants + */ + public function resolve(int $teamId, array $grants): AccessGrants + { + $this->resolving = true; + try { + return $this->build($teamId, $grants); + } finally { + $this->resolving = false; + } + } + + /** + * @param list $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], + }; + } +} diff --git a/app/Services/Access/Grant.php b/app/Services/Access/Grant.php new file mode 100644 index 0000000000..e7a880841f --- /dev/null +++ b/app/Services/Access/Grant.php @@ -0,0 +1,94 @@ + $levels + */ + public function __construct( + public AccessScope $scope, + public ?string $uuid, + public array $levels, + ) {} + + /** + * @param list $levels + */ + public static function team(array $levels): self + { + return new self(AccessScope::Team, null, $levels); + } + + /** + * @param array{scope?: string, uuid?: string|null, levels?: list} $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 + */ + 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 $grants + * @return list}> + */ + 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} + */ + public function toArray(): array + { + return [ + 'scope' => $this->scope->value, + 'uuid' => $this->uuid, + 'levels' => array_map(fn (AccessLevel $level) => $level->value, $this->levels), + ]; + } +} diff --git a/app/Traits/AuthorizesResourceCreation.php b/app/Traits/AuthorizesResourceCreation.php index 01ae7c8d95..4e18d45fed 100644 --- a/app/Traits/AuthorizesResourceCreation.php +++ b/app/Traits/AuthorizesResourceCreation.php @@ -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]); } } diff --git a/app/Traits/ScopedByAccessGrants.php b/app/Traits/ScopedByAccessGrants.php new file mode 100644 index 0000000000..ef93669dbc --- /dev/null +++ b/app/Traits/ScopedByAccessGrants.php @@ -0,0 +1,16 @@ + app(AccessResolver::class)->flush()); + static::deleted(fn () => app(AccessResolver::class)->flush()); + } +} diff --git a/bootstrap/helpers/databases.php b/bootstrap/helpers/databases.php index a34fdde9a8..97a5f87161 100644 --- a/bootstrap/helpers/databases.php +++ b/bootstrap/helpers/databases.php @@ -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(); } diff --git a/bootstrap/helpers/domains.php b/bootstrap/helpers/domains.php index 704c90988f..88d55994dc 100644 --- a/bootstrap/helpers/domains.php +++ b/bootstrap/helpers/domains.php @@ -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', diff --git a/database/migrations/2026_10_25_100000_add_access_grants_to_team_members_and_tokens.php b/database/migrations/2026_10_25_100000_add_access_grants_to_team_members_and_tokens.php new file mode 100644 index 0000000000..20d860b9bb --- /dev/null +++ b/database/migrations/2026_10_25_100000_add_access_grants_to_team_members_and_tokens.php @@ -0,0 +1,46 @@ +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'); + }); + } +}; diff --git a/resources/views/components/domain-conflict-modal.blade.php b/resources/views/components/domain-conflict-modal.blade.php index 5ad53bc787..e476c1e9d0 100644 --- a/resources/views/components/domain-conflict-modal.blade.php +++ b/resources/views/components/domain-conflict-modal.blade.php @@ -39,7 +39,7 @@
  • {{ $conflict['domain'] }} is used by - @if ($conflict['resource_type'] === 'instance') + @if ($conflict['resource_type'] === 'instance' || blank($conflict['resource_link'] ?? null)) {{ $conflict['resource_name'] }} @else @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' => [ // The dashboard lists projects and links to all of them, so project pages count as Dashboard. ['label' => 'Dashboard', 'icon' => 'dashboard', 'href' => '/', 'active' => request()->is('/', 'dashboard/*', 'projects', 'projects/*', 'project/*', 'canvas')], - ['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')], @@ -141,15 +143,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')], diff --git a/resources/views/livewire/dashboard.blade.php b/resources/views/livewire/dashboard.blade.php index 352060701a..95323131de 100644 --- a/resources/views/livewire/dashboard.blade.php +++ b/resources/views/livewire/dashboard.blade.php @@ -264,6 +264,7 @@ @endif + @if ($showsTeamSections)
    Shared variables - @can('createAnyResource') + @can('createAnyResource', [$environment]) @@ -50,7 +50,7 @@ @if ($environment->isEmpty()) - @can('createAnyResource') + @can('createAnyResource', [$environment]) diff --git a/resources/views/livewire/security/api-tokens.blade.php b/resources/views/livewire/security/api-tokens.blade.php index 2fd7a387c3..d863dc18b7 100644 --- a/resources/views/livewire/security/api-tokens.blade.php +++ b/resources/views/livewire/security/api-tokens.blade.php @@ -65,6 +65,13 @@ :checked="in_array('write', $permissions)" :disabled="in_array('root', $permissions) || !$canUseWritePermissions" />
    +
    + +
    + +
    +
    +

    Access

    + +
    +
    + + @if ($scopeMode === 'selected') + + @endif +
    +
    @endcan @@ -181,14 +207,23 @@
    -
    - {{ $token->name }} +
    +
    + {{ $token->name }} +
    +
    + @if ($token->scopes === null) + All resources in the team + @else + {{ collect($token->scopes)->map(fn ($scope) => $scopeLabels[($scope['scope'] ?? '').':'.($scope['uuid'] ?? '')] ?? 'Unavailable '.($scope['scope'] ?? 'scope'))->join(', ') }} + @endif +
    @foreach ($token->abilities ?? [] as $ability) $ability === 'root', + 'border-error/20 bg-error/10 text-error' => in_array($ability, ['root', 'delete']), 'border-warning/20 bg-warning/10 text-warning' => in_array($ability, ['write', 'write:sensitive']), 'border-coollabs/20 bg-coollabs/10 text-coollabs dark:border-warning/20 dark:bg-warning/10 dark:text-warning' => $ability === 'deploy', 'border-neutral-200 bg-neutral-100 text-neutral-600 dark:border-white/[0.08] dark:bg-white/[0.06] dark:text-fg-dim' => in_array($ability, ['read', 'read:sensitive']), diff --git a/resources/views/livewire/team/member.blade.php b/resources/views/livewire/team/member.blade.php index 4b1261a07a..f6a7260bd4 100644 --- a/resources/views/livewire/team/member.blade.php +++ b/resources/views/livewire/team/member.blade.php @@ -31,6 +31,13 @@ class="inline-flex rounded-full bg-neutral-100 px-2 py-0.5 text-[10px] font-medium capitalize text-neutral-600 dark:bg-white/[0.06] dark:text-fg-dim"> {{ data_get($member, 'pivot.role') }} + @can('manageMembers', currentTeam()) + @if (data_get($member, 'pivot.role') === 'member') +
    + {{ App\Livewire\Team\Member\Access::summary(data_get($member, 'pivot.access')) }} +
    + @endif + @endcan
    @can('manageMembers', currentTeam())
    @@ -81,6 +88,12 @@ @endif @endif + @if (data_get($member, 'pivot.role') === 'member') + + @endif
    + @if ($projectOptions->isNotEmpty()) +
    + + +
    + @endif + + +
    + + @if (count($grants) === 0) + + This member will not see any project, environment, or resource in this team. + + @endif + + @foreach ($grants as $index => $grant) +
    +
    +
    + +
    + +
    +
    + @foreach ($levels as $level) + + @endforeach +
    + @error("grants.{$index}.levels") +

    {{ $message }}

    + @enderror +
    + @endforeach + +
    + +
    +
    + + + Cancel + Save + + + @endif +
    diff --git a/resources/views/livewire/team/member/index.blade.php b/resources/views/livewire/team/member/index.blade.php index 687f870a3b..c5fe270b21 100644 --- a/resources/views/livewire/team/member/index.blade.php +++ b/resources/views/livewire/team/member/index.blade.php @@ -136,6 +136,10 @@ next-disabled="page >= lastPage" /> + @can('manageMembers', currentTeam()) + + @endcan + @can('manageInvitations', currentTeam()) diff --git a/routes/api.php b/routes/api.php index f985b94332..3c0f13a5ba 100644 --- a/routes/api.php +++ b/routes/api.php @@ -95,7 +95,7 @@ Route::group([ Route::get('/team/envs', [SharedEnvironmentVariablesController::class, 'team_envs'])->middleware(['api.ability:read']); Route::post('/team/envs', [SharedEnvironmentVariablesController::class, 'team_create_env'])->middleware(['api.ability:write']); Route::patch('/team/envs/{env_id}', [SharedEnvironmentVariablesController::class, 'team_update_env'])->middleware(['api.ability:write']); - Route::delete('/team/envs/{env_id}', [SharedEnvironmentVariablesController::class, 'team_delete_env'])->middleware(['api.ability:write']); + Route::delete('/team/envs/{env_id}', [SharedEnvironmentVariablesController::class, 'team_delete_env'])->middleware(['api.ability:delete']); Route::get('/teams/{id}', [TeamController::class, 'team_by_id'])->middleware(['api.ability:read']); Route::get('/teams/{id}/members', [TeamController::class, 'members_by_id'])->middleware(['api.ability:read']); @@ -106,46 +106,46 @@ Route::group([ Route::get('/projects/{uuid}/envs', [SharedEnvironmentVariablesController::class, 'project_envs'])->middleware(['api.ability:read']); Route::post('/projects/{uuid}/envs', [SharedEnvironmentVariablesController::class, 'project_create_env'])->middleware(['api.ability:write']); Route::patch('/projects/{uuid}/envs/{env_id}', [SharedEnvironmentVariablesController::class, 'project_update_env'])->middleware(['api.ability:write']); - Route::delete('/projects/{uuid}/envs/{env_id}', [SharedEnvironmentVariablesController::class, 'project_delete_env'])->middleware(['api.ability:write']); + Route::delete('/projects/{uuid}/envs/{env_id}', [SharedEnvironmentVariablesController::class, 'project_delete_env'])->middleware(['api.ability:delete']); Route::get('/projects/{uuid}/environments/{environment_name_or_uuid}/envs', [SharedEnvironmentVariablesController::class, 'environment_envs'])->middleware(['api.ability:read']); Route::post('/projects/{uuid}/environments/{environment_name_or_uuid}/envs', [SharedEnvironmentVariablesController::class, 'environment_create_env'])->middleware(['api.ability:write']); Route::patch('/projects/{uuid}/environments/{environment_name_or_uuid}/envs/{env_id}', [SharedEnvironmentVariablesController::class, 'environment_update_env'])->middleware(['api.ability:write']); - Route::delete('/projects/{uuid}/environments/{environment_name_or_uuid}/envs/{env_id}', [SharedEnvironmentVariablesController::class, 'environment_delete_env'])->middleware(['api.ability:write']); + Route::delete('/projects/{uuid}/environments/{environment_name_or_uuid}/envs/{env_id}', [SharedEnvironmentVariablesController::class, 'environment_delete_env'])->middleware(['api.ability:delete']); Route::get('/projects/{uuid}/{environment_name_or_uuid}', [ProjectController::class, 'environment_details'])->middleware(['api.ability:read']); Route::post('/projects/{uuid}/environments', [ProjectController::class, 'create_environment'])->middleware(['api.ability:write']); Route::patch('/projects/{uuid}/environments/{environment_name_or_uuid}', [ProjectController::class, 'update_environment'])->middleware(['api.ability:write']); - Route::delete('/projects/{uuid}/environments/{environment_name_or_uuid}', [ProjectController::class, 'delete_environment'])->middleware(['api.ability:write']); + Route::delete('/projects/{uuid}/environments/{environment_name_or_uuid}', [ProjectController::class, 'delete_environment'])->middleware(['api.ability:delete']); Route::post('/projects', [ProjectController::class, 'create_project'])->middleware(['api.ability:write']); Route::patch('/projects/{uuid}', [ProjectController::class, 'update_project'])->middleware(['api.ability:write']); - Route::delete('/projects/{uuid}', [ProjectController::class, 'delete_project'])->middleware(['api.ability:write']); + Route::delete('/projects/{uuid}', [ProjectController::class, 'delete_project'])->middleware(['api.ability:delete']); Route::get('/security/keys', [SecurityController::class, 'keys'])->middleware(['api.ability:read']); Route::post('/security/keys', [SecurityController::class, 'create_key'])->middleware(['api.ability:write']); Route::get('/security/keys/{uuid}', [SecurityController::class, 'key_by_uuid'])->middleware(['api.ability:read']); Route::patch('/security/keys/{uuid}', [SecurityController::class, 'update_key'])->middleware(['api.ability:write']); - Route::delete('/security/keys/{uuid}', [SecurityController::class, 'delete_key'])->middleware(['api.ability:write']); + Route::delete('/security/keys/{uuid}', [SecurityController::class, 'delete_key'])->middleware(['api.ability:delete']); Route::post('/security/integration-tokens', [IntegrationTokensController::class, 'store'])->middleware(['api.ability:write']); Route::get('/cloud-tokens', [CloudProviderTokensController::class, 'index'])->middleware(['api.ability:read']); Route::post('/cloud-tokens', [CloudProviderTokensController::class, 'store'])->middleware(['api.ability:write']); Route::get('/cloud-tokens/{uuid}', [CloudProviderTokensController::class, 'show'])->middleware(['api.ability:read']); Route::patch('/cloud-tokens/{uuid}', [CloudProviderTokensController::class, 'update'])->middleware(['api.ability:write']); - Route::delete('/cloud-tokens/{uuid}', [CloudProviderTokensController::class, 'destroy'])->middleware(['api.ability:write']); + Route::delete('/cloud-tokens/{uuid}', [CloudProviderTokensController::class, 'destroy'])->middleware(['api.ability:delete']); Route::post('/cloud-tokens/{uuid}/validate', [CloudProviderTokensController::class, 'validateToken'])->middleware(['api.ability:write']); Route::get('/cloud-init-scripts', [CloudInitScriptsController::class, 'index'])->middleware(['api.ability:read']); Route::post('/cloud-init-scripts', [CloudInitScriptsController::class, 'store'])->middleware(['api.ability:write']); Route::get('/cloud-init-scripts/{uuid}', [CloudInitScriptsController::class, 'show'])->middleware(['api.ability:read']); Route::patch('/cloud-init-scripts/{uuid}', [CloudInitScriptsController::class, 'update'])->middleware(['api.ability:write']); - Route::delete('/cloud-init-scripts/{uuid}', [CloudInitScriptsController::class, 'destroy'])->middleware(['api.ability:write']); + Route::delete('/cloud-init-scripts/{uuid}', [CloudInitScriptsController::class, 'destroy'])->middleware(['api.ability:delete']); Route::get('/s3-storages', [S3StoragesController::class, 'index'])->middleware(['api.ability:read']); Route::post('/s3-storages', [S3StoragesController::class, 'store'])->middleware(['api.ability:write']); Route::get('/s3-storages/{uuid}', [S3StoragesController::class, 'show'])->middleware(['api.ability:read']); Route::patch('/s3-storages/{uuid}', [S3StoragesController::class, 'update'])->middleware(['api.ability:write']); - Route::delete('/s3-storages/{uuid}', [S3StoragesController::class, 'destroy'])->middleware(['api.ability:write']); + Route::delete('/s3-storages/{uuid}', [S3StoragesController::class, 'destroy'])->middleware(['api.ability:delete']); Route::post('/s3-storages/{uuid}/validate', [S3StoragesController::class, 'validateStorage'])->middleware(['api.ability:write']); Route::get('/deploy', [OtherController::class, 'post_required'])->middleware(['api.ability:deploy']); @@ -162,7 +162,7 @@ Route::group([ Route::get('/servers/{uuid}/envs', [SharedEnvironmentVariablesController::class, 'server_envs'])->middleware(['api.ability:read']); Route::post('/servers/{uuid}/envs', [SharedEnvironmentVariablesController::class, 'server_create_env'])->middleware(['api.ability:write']); Route::patch('/servers/{uuid}/envs/{env_id}', [SharedEnvironmentVariablesController::class, 'server_update_env'])->middleware(['api.ability:write']); - Route::delete('/servers/{uuid}/envs/{env_id}', [SharedEnvironmentVariablesController::class, 'server_delete_env'])->middleware(['api.ability:write']); + Route::delete('/servers/{uuid}/envs/{env_id}', [SharedEnvironmentVariablesController::class, 'server_delete_env'])->middleware(['api.ability:delete']); // Server subsystem APIs (Docker cleanup, log drains, Sentinel, Cloudflare Tunnel). Route::get('/servers/{uuid}/docker-cleanup', [ServerDockerCleanupController::class, 'show'])->middleware(['api.ability:read']); @@ -185,7 +185,7 @@ Route::group([ Route::get('/destinations', [DestinationsController::class, 'index'])->middleware(['api.ability:read']); Route::get('/destinations/{uuid}', [DestinationsController::class, 'show'])->middleware(['api.ability:read']); Route::patch('/destinations/{uuid}', [DestinationsController::class, 'update'])->middleware(['api.ability:write']); - Route::delete('/destinations/{uuid}', [DestinationsController::class, 'delete'])->middleware(['api.ability:write']); + Route::delete('/destinations/{uuid}', [DestinationsController::class, 'delete'])->middleware(['api.ability:delete']); Route::get('/servers/{server_uuid}/destinations', [DestinationsController::class, 'index_by_server'])->middleware(['api.ability:read']); Route::post('/servers/{server_uuid}/destinations', [DestinationsController::class, 'create'])->middleware(['api.ability:write']); @@ -195,7 +195,7 @@ Route::group([ Route::get('/servers/{uuid}/registries', [ServerDockerRegistriesController::class, 'index'])->middleware(['api.ability:read:sensitive']); Route::post('/servers/{uuid}/registries', [ServerDockerRegistriesController::class, 'login'])->middleware(['api.ability:write']); Route::post('/servers/{uuid}/registries/{registry}/check', [ServerDockerRegistriesController::class, 'check'])->middleware(['api.ability:write']); - Route::delete('/servers/{uuid}/registries/{registry}', [ServerDockerRegistriesController::class, 'logout'])->middleware(['api.ability:write']); + Route::delete('/servers/{uuid}/registries/{registry}', [ServerDockerRegistriesController::class, 'logout'])->middleware(['api.ability:delete']); Route::get('/servers/{uuid}/proxy', [ServerProxyController::class, 'show'])->middleware(['api.ability:read']); Route::patch('/servers/{uuid}/proxy', [ServerProxyController::class, 'update'])->middleware(['api.ability:write']); @@ -209,7 +209,7 @@ Route::group([ Route::post('/servers/{uuid}/transfer/complete', [ServerTransferController::class, 'complete'])->middleware(['api.ability:write']); Route::post('/servers/{uuid}/migrate', [ServerTransferController::class, 'migrate'])->middleware(['api.ability:write']); Route::patch('/servers/{uuid}', [ServersController::class, 'update_server'])->middleware(['api.ability:write']); - Route::delete('/servers/{uuid}', [ServersController::class, 'delete_server'])->middleware(['api.ability:write']); + Route::delete('/servers/{uuid}', [ServersController::class, 'delete_server'])->middleware(['api.ability:delete']); Route::get('/hetzner/locations', [HetznerController::class, 'locations'])->middleware(['api.ability:read']); Route::get('/hetzner/server-types', [HetznerController::class, 'serverTypes'])->middleware(['api.ability:read']); @@ -243,7 +243,7 @@ Route::group([ Route::get('/tags', [TagsController::class, 'tags'])->middleware(['api.ability:read']); Route::post('/tags', [TagsController::class, 'create'])->middleware(['api.ability:write']); Route::patch('/tags/{uuid}', [TagsController::class, 'update'])->middleware(['api.ability:write']); - Route::delete('/tags/{uuid}', [TagsController::class, 'delete'])->middleware(['api.ability:write']); + Route::delete('/tags/{uuid}', [TagsController::class, 'delete'])->middleware(['api.ability:delete']); Route::get('/applications', [ApplicationsController::class, 'applications'])->middleware(['api.ability:read']); Route::post('/applications/public', [ApplicationsController::class, 'create_public_application'])->middleware(['api.ability:write']); @@ -254,20 +254,20 @@ Route::group([ Route::get('/applications/{uuid}', [ApplicationsController::class, 'application_by_uuid'])->middleware(['api.ability:read']); Route::patch('/applications/{uuid}', [ApplicationsController::class, 'update_by_uuid'])->middleware(['api.ability:write']); - Route::delete('/applications/{uuid}', [ApplicationsController::class, 'delete_by_uuid'])->middleware(['api.ability:write']); + Route::delete('/applications/{uuid}', [ApplicationsController::class, 'delete_by_uuid'])->middleware(['api.ability:delete']); Route::patch('/applications/{uuid}/secret-manager', [ApplicationSecretManagerController::class, 'update'])->middleware(['api.ability:write']); Route::get('/applications/{uuid}/envs', [ApplicationsController::class, 'envs'])->middleware(['api.ability:read']); Route::post('/applications/{uuid}/envs', [ApplicationsController::class, 'create_env'])->middleware(['api.ability:write']); Route::patch('/applications/{uuid}/envs/bulk', [ApplicationsController::class, 'create_bulk_envs'])->middleware(['api.ability:write']); Route::patch('/applications/{uuid}/envs', [ApplicationsController::class, 'update_env_by_uuid'])->middleware(['api.ability:write']); - Route::delete('/applications/{uuid}/envs/{env_uuid}', [ApplicationsController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']); + Route::delete('/applications/{uuid}/envs/{env_uuid}', [ApplicationsController::class, 'delete_env_by_uuid'])->middleware(['api.ability:delete']); Route::get('/applications/{uuid}/logs', [ApplicationsController::class, 'logs_by_uuid'])->middleware(['api.ability:read:sensitive']); Route::get('/applications/{uuid}/previews/{pull_request_id}/logs', [ApplicationsController::class, 'logs_by_uuid'])->middleware(['api.ability:read:sensitive']); Route::get('/applications/{uuid}/storages', [ApplicationsController::class, 'storages'])->middleware(['api.ability:read']); Route::post('/applications/{uuid}/storages', [ApplicationsController::class, 'create_storage'])->middleware(['api.ability:write']); Route::patch('/applications/{uuid}/storages', [ApplicationsController::class, 'update_storage'])->middleware(['api.ability:write']); - Route::delete('/applications/{uuid}/storages/{storage_uuid}', [ApplicationsController::class, 'delete_storage'])->middleware(['api.ability:write']); + Route::delete('/applications/{uuid}/storages/{storage_uuid}', [ApplicationsController::class, 'delete_storage'])->middleware(['api.ability:delete']); Route::put('/applications/{uuid}/storages/{storage_uuid}/backups', [VolumeBackupsController::class, 'upsert']) ->defaults('resource_type', 'application') ->middleware(['api.ability:write']); @@ -276,11 +276,11 @@ Route::group([ ->middleware(['api.ability:write']); Route::delete('/applications/{uuid}/storages/{storage_uuid}/backups', [VolumeBackupsController::class, 'destroy']) ->defaults('resource_type', 'application') - ->middleware(['api.ability:write']); + ->middleware(['api.ability:delete']); Route::get('/applications/{uuid}/tags', [ApplicationsController::class, 'tags'])->middleware(['api.ability:read']); Route::post('/applications/{uuid}/tags', [ApplicationsController::class, 'create_tag'])->middleware(['api.ability:write']); - Route::delete('/applications/{uuid}/tags/{tag_uuid}', [ApplicationsController::class, 'delete_tag'])->middleware(['api.ability:write']); + Route::delete('/applications/{uuid}/tags/{tag_uuid}', [ApplicationsController::class, 'delete_tag'])->middleware(['api.ability:delete']); Route::post('/applications/{uuid}/move', [ApplicationsController::class, 'move_by_uuid'])->middleware(['api.ability:write']); Route::post('/applications/{uuid}/migrate', [ApplicationsController::class, 'migrate_by_uuid'])->middleware(['api.ability:write']); @@ -289,7 +289,7 @@ Route::group([ Route::post('/applications/{uuid}/rollback', [ApplicationsController::class, 'rollback_by_uuid'])->middleware(['api.ability:deploy']); Route::get('/applications/{uuid}/destinations', [ApplicationsController::class, 'destinations'])->middleware(['api.ability:read']); Route::post('/applications/{uuid}/destinations', [ApplicationsController::class, 'add_destination'])->middleware(['api.ability:write']); - Route::delete('/applications/{uuid}/destinations/{destination_uuid}', [ApplicationsController::class, 'remove_destination'])->middleware(['api.ability:write']); + Route::delete('/applications/{uuid}/destinations/{destination_uuid}', [ApplicationsController::class, 'remove_destination'])->middleware(['api.ability:delete']); Route::get('/applications/{uuid}/start', [OtherController::class, 'post_required'])->middleware(['api.ability:deploy']); Route::get('/applications/{uuid}/restart', [OtherController::class, 'post_required'])->middleware(['api.ability:deploy']); Route::get('/applications/{uuid}/stop', [OtherController::class, 'post_required'])->middleware(['api.ability:deploy']); @@ -301,19 +301,19 @@ Route::group([ Route::post('/applications/{uuid}/previews', [ApplicationPreviewsController::class, 'store'])->middleware(['api.ability:deploy']); Route::get('/applications/{uuid}/previews/{pull_request_id}', [ApplicationPreviewsController::class, 'show'])->middleware(['api.ability:read']); Route::patch('/applications/{uuid}/previews/{pull_request_id}', [ApplicationsController::class, 'update_preview_by_pull_request_id'])->middleware(['api.ability:write']); - Route::delete('/applications/{uuid}/previews/{pull_request_id}', [ApplicationsController::class, 'delete_preview_by_pull_request_id'])->middleware(['api.ability:write']); + Route::delete('/applications/{uuid}/previews/{pull_request_id}', [ApplicationsController::class, 'delete_preview_by_pull_request_id'])->middleware(['api.ability:delete']); Route::get('/github-apps', [GithubController::class, 'list_github_apps'])->middleware(['api.ability:read']); Route::post('/github-apps', [GithubController::class, 'create_github_app'])->middleware(['api.ability:write']); Route::patch('/github-apps/{github_app_id}', [GithubController::class, 'update_github_app'])->middleware(['api.ability:write']); - Route::delete('/github-apps/{github_app_id}', [GithubController::class, 'delete_github_app'])->middleware(['api.ability:write']); + Route::delete('/github-apps/{github_app_id}', [GithubController::class, 'delete_github_app'])->middleware(['api.ability:delete']); Route::get('/github-apps/{github_app_id}/repositories', [GithubController::class, 'load_repositories'])->middleware(['api.ability:read']); Route::get('/github-apps/{github_app_id}/repositories/{owner}/{repo}/branches', [GithubController::class, 'load_branches'])->middleware(['api.ability:read']); Route::get('/gitlab-apps', [GitlabController::class, 'list_gitlab_apps'])->middleware(['api.ability:read']); Route::post('/gitlab-apps', [GitlabController::class, 'create_gitlab_app'])->middleware(['api.ability:write']); Route::patch('/gitlab-apps/{gitlab_app_id}', [GitlabController::class, 'update_gitlab_app'])->middleware(['api.ability:write']); - Route::delete('/gitlab-apps/{gitlab_app_id}', [GitlabController::class, 'delete_gitlab_app'])->middleware(['api.ability:write']); + Route::delete('/gitlab-apps/{gitlab_app_id}', [GitlabController::class, 'delete_gitlab_app'])->middleware(['api.ability:delete']); Route::get('/databases', [DatabasesController::class, 'databases'])->middleware(['api.ability:read']); Route::post('/databases/postgresql', [DatabasesController::class, 'create_database_postgresql'])->middleware(['api.ability:write']); @@ -335,15 +335,15 @@ Route::group([ Route::patch('/databases/{uuid}', [DatabasesController::class, 'update_by_uuid'])->middleware(['api.ability:write']); Route::post('/databases/{uuid}/backups', [DatabasesController::class, 'create_backup'])->middleware(['api.ability:write']); Route::patch('/databases/{uuid}/backups/{scheduled_backup_uuid}', [DatabasesController::class, 'update_backup'])->middleware(['api.ability:write']); - Route::delete('/databases/{uuid}', [DatabasesController::class, 'delete_by_uuid'])->middleware(['api.ability:write']); + Route::delete('/databases/{uuid}', [DatabasesController::class, 'delete_by_uuid'])->middleware(['api.ability:delete']); Route::get('/databases/{uuid}/logs', [DatabasesController::class, 'logs_by_uuid'])->middleware(['api.ability:read:sensitive']); - Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}', [DatabasesController::class, 'delete_backup_by_uuid'])->middleware(['api.ability:write']); - Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}', [DatabasesController::class, 'delete_execution_by_uuid'])->middleware(['api.ability:write']); + Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}', [DatabasesController::class, 'delete_backup_by_uuid'])->middleware(['api.ability:delete']); + Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}', [DatabasesController::class, 'delete_execution_by_uuid'])->middleware(['api.ability:delete']); Route::get('/databases/{uuid}/storages', [DatabasesController::class, 'storages'])->middleware(['api.ability:read']); Route::post('/databases/{uuid}/storages', [DatabasesController::class, 'create_storage'])->middleware(['api.ability:write']); Route::patch('/databases/{uuid}/storages', [DatabasesController::class, 'update_storage'])->middleware(['api.ability:write']); - Route::delete('/databases/{uuid}/storages/{storage_uuid}', [DatabasesController::class, 'delete_storage'])->middleware(['api.ability:write']); + Route::delete('/databases/{uuid}/storages/{storage_uuid}', [DatabasesController::class, 'delete_storage'])->middleware(['api.ability:delete']); Route::put('/databases/{uuid}/storages/{storage_uuid}/backups', [VolumeBackupsController::class, 'upsert']) ->defaults('resource_type', 'database') ->middleware(['api.ability:write']); @@ -352,17 +352,17 @@ Route::group([ ->middleware(['api.ability:write']); Route::delete('/databases/{uuid}/storages/{storage_uuid}/backups', [VolumeBackupsController::class, 'destroy']) ->defaults('resource_type', 'database') - ->middleware(['api.ability:write']); + ->middleware(['api.ability:delete']); Route::get('/databases/{uuid}/envs', [DatabasesController::class, 'envs'])->middleware(['api.ability:read']); Route::post('/databases/{uuid}/envs', [DatabasesController::class, 'create_env'])->middleware(['api.ability:write']); Route::patch('/databases/{uuid}/envs/bulk', [DatabasesController::class, 'create_bulk_envs'])->middleware(['api.ability:write']); Route::patch('/databases/{uuid}/envs', [DatabasesController::class, 'update_env_by_uuid'])->middleware(['api.ability:write']); - Route::delete('/databases/{uuid}/envs/{env_uuid}', [DatabasesController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']); + Route::delete('/databases/{uuid}/envs/{env_uuid}', [DatabasesController::class, 'delete_env_by_uuid'])->middleware(['api.ability:delete']); Route::get('/databases/{uuid}/tags', [DatabasesController::class, 'tags'])->middleware(['api.ability:read']); Route::post('/databases/{uuid}/tags', [DatabasesController::class, 'create_tag'])->middleware(['api.ability:write']); - Route::delete('/databases/{uuid}/tags/{tag_uuid}', [DatabasesController::class, 'delete_tag'])->middleware(['api.ability:write']); + Route::delete('/databases/{uuid}/tags/{tag_uuid}', [DatabasesController::class, 'delete_tag'])->middleware(['api.ability:delete']); Route::post('/databases/{uuid}/move', [DatabasesController::class, 'move_by_uuid'])->middleware(['api.ability:write']); Route::post('/databases/{uuid}/migrate', [DatabasesController::class, 'migrate_by_uuid'])->middleware(['api.ability:write']); @@ -379,12 +379,12 @@ Route::group([ Route::get('/services/{uuid}', [ServicesController::class, 'service_by_uuid'])->middleware(['api.ability:read']); Route::patch('/services/{uuid}', [ServicesController::class, 'update_by_uuid'])->middleware(['api.ability:write']); - Route::delete('/services/{uuid}', [ServicesController::class, 'delete_by_uuid'])->middleware(['api.ability:write']); + Route::delete('/services/{uuid}', [ServicesController::class, 'delete_by_uuid'])->middleware(['api.ability:delete']); Route::get('/services/{uuid}/storages', [ServicesController::class, 'storages'])->middleware(['api.ability:read']); Route::post('/services/{uuid}/storages', [ServicesController::class, 'create_storage'])->middleware(['api.ability:write']); Route::patch('/services/{uuid}/storages', [ServicesController::class, 'update_storage'])->middleware(['api.ability:write']); - Route::delete('/services/{uuid}/storages/{storage_uuid}', [ServicesController::class, 'delete_storage'])->middleware(['api.ability:write']); + Route::delete('/services/{uuid}/storages/{storage_uuid}', [ServicesController::class, 'delete_storage'])->middleware(['api.ability:delete']); Route::put('/services/{uuid}/storages/{storage_uuid}/backups', [VolumeBackupsController::class, 'upsert']) ->defaults('resource_type', 'service') ->middleware(['api.ability:write']); @@ -393,18 +393,18 @@ Route::group([ ->middleware(['api.ability:write']); Route::delete('/services/{uuid}/storages/{storage_uuid}/backups', [VolumeBackupsController::class, 'destroy']) ->defaults('resource_type', 'service') - ->middleware(['api.ability:write']); + ->middleware(['api.ability:delete']); Route::get('/services/{uuid}/envs', [ServicesController::class, 'envs'])->middleware(['api.ability:read']); Route::post('/services/{uuid}/envs', [ServicesController::class, 'create_env'])->middleware(['api.ability:write']); Route::patch('/services/{uuid}/envs/bulk', [ServicesController::class, 'create_bulk_envs'])->middleware(['api.ability:write']); Route::patch('/services/{uuid}/envs', [ServicesController::class, 'update_env_by_uuid'])->middleware(['api.ability:write']); - Route::delete('/services/{uuid}/envs/{env_uuid}', [ServicesController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']); + Route::delete('/services/{uuid}/envs/{env_uuid}', [ServicesController::class, 'delete_env_by_uuid'])->middleware(['api.ability:delete']); Route::get('/services/{uuid}/logs', [ServicesController::class, 'logs_by_uuid'])->middleware(['api.ability:read:sensitive']); Route::get('/services/{uuid}/tags', [ServicesController::class, 'tags'])->middleware(['api.ability:read']); Route::post('/services/{uuid}/tags', [ServicesController::class, 'create_tag'])->middleware(['api.ability:write']); - Route::delete('/services/{uuid}/tags/{tag_uuid}', [ServicesController::class, 'delete_tag'])->middleware(['api.ability:write']); + Route::delete('/services/{uuid}/tags/{tag_uuid}', [ServicesController::class, 'delete_tag'])->middleware(['api.ability:delete']); Route::post('/services/{uuid}/move', [ServicesController::class, 'move_by_uuid'])->middleware(['api.ability:write']); Route::post('/services/{uuid}/migrate', [ServicesController::class, 'migrate_by_uuid'])->middleware(['api.ability:write']); @@ -441,14 +441,14 @@ Route::group([ Route::get('/applications/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'scheduled_tasks_by_application_uuid'])->middleware(['api.ability:read']); Route::post('/applications/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'create_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']); Route::patch('/applications/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'update_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']); - Route::delete('/applications/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'delete_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']); + Route::delete('/applications/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'delete_scheduled_task_by_application_uuid'])->middleware(['api.ability:delete']); Route::get('/applications/{uuid}/scheduled-tasks/{task_uuid}/executions', [ScheduledTasksController::class, 'executions_by_application_uuid'])->middleware(['api.ability:read']); Route::post('/applications/{uuid}/scheduled-tasks/{task_uuid}/execute', [ScheduledTasksController::class, 'execute_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']); Route::get('/services/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'scheduled_tasks_by_service_uuid'])->middleware(['api.ability:read']); Route::post('/services/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'create_scheduled_task_by_service_uuid'])->middleware(['api.ability:write']); Route::patch('/services/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'update_scheduled_task_by_service_uuid'])->middleware(['api.ability:write']); - Route::delete('/services/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'delete_scheduled_task_by_service_uuid'])->middleware(['api.ability:write']); + Route::delete('/services/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'delete_scheduled_task_by_service_uuid'])->middleware(['api.ability:delete']); Route::get('/services/{uuid}/scheduled-tasks/{task_uuid}/executions', [ScheduledTasksController::class, 'executions_by_service_uuid'])->middleware(['api.ability:read']); Route::post('/services/{uuid}/scheduled-tasks/{task_uuid}/execute', [ScheduledTasksController::class, 'execute_scheduled_task_by_service_uuid'])->middleware(['api.ability:write']); }); diff --git a/routes/web.php b/routes/web.php index 2efe33c643..8e94e150ee 100644 --- a/routes/web.php +++ b/routes/web.php @@ -192,11 +192,13 @@ Route::middleware(['auth', 'verified'])->group(function () { }); Route::get('/', Dashboard::class)->name('dashboard'); - Route::get('/dashboard/canvas', DashboardCanvas::class)->name('dashboard.canvas'); - Route::get('/analytics', Analytics::class)->name('analytics'); - Route::get('/metrics', FleetMetrics::class)->name('metrics'); - Route::get('/canvas', DeployCanvas::class)->name('deploy-canvas'); - Route::get('/canvas/tags', TagsCanvas::class)->name('tags.canvas'); + Route::middleware('access.team-wide')->group(function () { + Route::get('/dashboard/canvas', DashboardCanvas::class)->name('dashboard.canvas'); + Route::get('/analytics', Analytics::class)->name('analytics'); + Route::get('/metrics', FleetMetrics::class)->name('metrics'); + Route::get('/canvas', DeployCanvas::class)->name('deploy-canvas'); + Route::get('/canvas/tags', TagsCanvas::class)->name('tags.canvas'); + }); // The canvas calls these API controllers with the browser session (no API token). Route::prefix('canvas/api')->middleware([UseSessionTeamApiToken::class, 'api.token.team', 'api.sensitive'])->group(function () { Route::get('/projects', [ProjectController::class, 'projects'])->middleware(['api.ability:read']); @@ -210,26 +212,26 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::post('/projects/{uuid}/environments/{environment_name_or_uuid}/canvas/links', [EnvironmentCanvasController::class, 'storeLink'])->middleware(['api.ability:write']); Route::patch('/projects/{uuid}/environments/{environment_name_or_uuid}/canvas/links/{link_uuid}', [EnvironmentCanvasController::class, 'updateLink'])->middleware(['api.ability:write']); Route::delete('/projects/{uuid}/environments/{environment_name_or_uuid}/canvas/links/{link_uuid}', [EnvironmentCanvasController::class, 'destroyLink'])->middleware(['api.ability:write']); - Route::get('/tags/canvas', [TagCanvasController::class, 'show'])->middleware(['api.ability:read']); - Route::post('/tags/canvas/links', [TagCanvasController::class, 'storeLink'])->middleware(['api.ability:write']); - Route::delete('/tags/canvas/links/{link_id}', [TagCanvasController::class, 'destroyLink'])->middleware(['api.ability:write']); - Route::get('/shared-variables/team/canvas', [SharedVariableCanvasController::class, 'show'])->middleware(['api.ability:read']); - Route::post('/shared-variables/team/canvas/links', [SharedVariableCanvasController::class, 'storeLink'])->middleware(['api.ability:write']); - Route::delete('/shared-variables/team/canvas/links/{link_id}', [SharedVariableCanvasController::class, 'destroyLink'])->middleware(['api.ability:write']); + Route::get('/tags/canvas', [TagCanvasController::class, 'show'])->middleware(['access.team-wide', 'api.ability:read']); + Route::post('/tags/canvas/links', [TagCanvasController::class, 'storeLink'])->middleware(['access.team-wide', 'api.ability:write']); + Route::delete('/tags/canvas/links/{link_id}', [TagCanvasController::class, 'destroyLink'])->middleware(['access.team-wide', 'api.ability:write']); + Route::get('/shared-variables/team/canvas', [SharedVariableCanvasController::class, 'show'])->middleware(['access.team-wide', 'api.ability:read']); + Route::post('/shared-variables/team/canvas/links', [SharedVariableCanvasController::class, 'storeLink'])->middleware(['access.team-wide', 'api.ability:write']); + Route::delete('/shared-variables/team/canvas/links/{link_id}', [SharedVariableCanvasController::class, 'destroyLink'])->middleware(['access.team-wide', 'api.ability:write']); Route::get('/shared-variables/{scope}/{uuid}/canvas', [SharedVariableCanvasController::class, 'show'])->whereIn('scope', ['project', 'environment', 'server'])->middleware(['api.ability:read']); Route::post('/shared-variables/{scope}/{uuid}/canvas/links', [SharedVariableCanvasController::class, 'storeLink'])->whereIn('scope', ['project', 'environment', 'server'])->middleware(['api.ability:write']); Route::delete('/shared-variables/{scope}/{uuid}/canvas/links/{link_id}', [SharedVariableCanvasController::class, 'destroyLink'])->whereIn('scope', ['project', 'environment', 'server'])->middleware(['api.ability:write']); - Route::get('/servers', [ServersController::class, 'servers'])->middleware(['api.ability:read']); - Route::get('/servers/canvas', [ServerCanvasController::class, 'index'])->middleware(['api.ability:read']); - Route::get('/dashboard/canvas', [ServerCanvasController::class, 'dashboard'])->middleware(['api.ability:read']); - Route::get('/servers/{uuid}/canvas', [ServerCanvasController::class, 'show'])->middleware(['api.ability:read']); - Route::get('/servers/{uuid}/canvas/containers', [ServerCanvasController::class, 'containers'])->middleware(['api.ability:read']); - Route::get('/servers/{uuid}/canvas/usage', [ServerCanvasController::class, 'usage'])->middleware(['api.ability:read']); - Route::get('/servers/{server_uuid}/destinations', [DestinationsController::class, 'index_by_server'])->middleware(['api.ability:read']); - Route::get('/github-apps', [GithubController::class, 'list_github_apps'])->middleware(['api.ability:read']); - Route::get('/github-apps/{github_app_id}/repositories', [GithubController::class, 'load_repositories'])->middleware(['api.ability:read']); - Route::get('/github-apps/{github_app_id}/repositories/{owner}/{repo}/branches', [GithubController::class, 'load_branches'])->middleware(['api.ability:read']); - Route::get('/security/keys', [SecurityController::class, 'keys'])->middleware(['api.ability:read']); + Route::get('/servers', [ServersController::class, 'servers'])->middleware(['access.team-wide', 'api.ability:read']); + Route::get('/servers/canvas', [ServerCanvasController::class, 'index'])->middleware(['access.team-wide', 'api.ability:read']); + Route::get('/dashboard/canvas', [ServerCanvasController::class, 'dashboard'])->middleware(['access.team-wide', 'api.ability:read']); + Route::get('/servers/{uuid}/canvas', [ServerCanvasController::class, 'show'])->middleware(['access.team-wide', 'api.ability:read']); + Route::get('/servers/{uuid}/canvas/containers', [ServerCanvasController::class, 'containers'])->middleware(['access.team-wide', 'api.ability:read']); + Route::get('/servers/{uuid}/canvas/usage', [ServerCanvasController::class, 'usage'])->middleware(['access.team-wide', 'api.ability:read']); + Route::get('/servers/{server_uuid}/destinations', [DestinationsController::class, 'index_by_server'])->middleware(['access.team-wide', 'api.ability:read']); + Route::get('/github-apps', [GithubController::class, 'list_github_apps'])->middleware(['access.team-wide', 'api.ability:read']); + Route::get('/github-apps/{github_app_id}/repositories', [GithubController::class, 'load_repositories'])->middleware(['access.team-wide', 'api.ability:read']); + Route::get('/github-apps/{github_app_id}/repositories/{owner}/{repo}/branches', [GithubController::class, 'load_branches'])->middleware(['access.team-wide', 'api.ability:read']); + Route::get('/security/keys', [SecurityController::class, 'keys'])->middleware(['access.team-wide', 'api.ability:read']); Route::post('/applications/public', [ApplicationsController::class, 'create_public_application'])->middleware(['api.ability:write']); Route::post('/applications/private-github-app', [ApplicationsController::class, 'create_private_gh_app_application'])->middleware(['api.ability:write']); Route::post('/applications/private-deploy-key', [ApplicationsController::class, 'create_private_deploy_key_application'])->middleware(['api.ability:write']); @@ -254,19 +256,21 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/admin', AdminIndex::class)->name('admin.index'); Route::get('/onboarding', BoardingIndex::class)->name('onboarding'); - Route::get('/subscription', SubscriptionShow::class)->name('subscription.show'); - Route::get('/subscription/new', SubscriptionIndex::class)->name('subscription.index'); + Route::middleware('access.team-wide')->group(function () { + Route::get('/subscription', SubscriptionShow::class)->name('subscription.show'); + Route::get('/subscription/new', SubscriptionIndex::class)->name('subscription.index'); - Route::get('/settings', SettingsIndex::class)->name('settings.index'); - Route::get('/settings/advanced', SettingsAdvanced::class)->name('settings.advanced'); - Route::get('/settings/updates', SettingsUpdates::class)->name('settings.updates'); + Route::get('/settings', SettingsIndex::class)->name('settings.index'); + Route::get('/settings/advanced', SettingsAdvanced::class)->name('settings.advanced'); + Route::get('/settings/updates', SettingsUpdates::class)->name('settings.updates'); - Route::get('/settings/backup', SettingsBackup::class)->name('settings.backup'); - Route::get('/settings/email', SettingsEmail::class)->name('settings.email'); - Route::get('/settings/oauth', SettingsOauth::class)->name('settings.oauth'); - Route::get('/settings/oauth/{provider}', SettingsOauth::class) - ->where('provider', '[A-Za-z0-9_-]+') - ->name('settings.oauth.provider'); + Route::get('/settings/backup', SettingsBackup::class)->name('settings.backup'); + Route::get('/settings/email', SettingsEmail::class)->name('settings.email'); + Route::get('/settings/oauth', SettingsOauth::class)->name('settings.oauth'); + Route::get('/settings/oauth/{provider}', SettingsOauth::class) + ->where('provider', '[A-Za-z0-9_-]+') + ->name('settings.oauth.provider'); + }); Route::get('/profile', ProfileIndex::class)->name('profile'); Route::get('/profile/avatar', ProfileAvatarController::class)->name('profile.avatar'); Route::get('/profile/appearance', ProfileAppearance::class)->name('profile.appearance'); @@ -274,11 +278,11 @@ Route::middleware(['auth', 'verified'])->group(function () { ->middleware('password.confirm') ->name('profile.passkeys.confirm'); - Route::prefix('tags')->group(function () { + Route::prefix('tags')->middleware('access.team-wide')->group(function () { Route::get('/{tagName?}', TagsShow::class)->name('tags.show'); }); - Route::prefix('notifications')->group(function () { + Route::prefix('notifications')->middleware('access.team-wide')->group(function () { Route::get('/email', NotificationEmail::class)->name('notifications.email'); Route::get('/telegram', NotificationTelegram::class)->name('notifications.telegram'); Route::get('/discord', NotificationDiscord::class)->name('notifications.discord'); @@ -287,38 +291,38 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/webhook', NotificationWebhook::class)->name('notifications.webhook'); }); - Route::prefix('storages')->group(function () { + Route::prefix('storages')->middleware('access.team-wide')->group(function () { Route::get('/', StorageIndex::class)->name('storage.index'); Route::get('/{storage_uuid}', StorageShow::class)->name('storage.show'); Route::get('/{storage_uuid}/danger', StorageShow::class)->name('storage.danger'); Route::get('/{storage_uuid}/resources', StorageShow::class)->name('storage.resources'); }); Route::prefix('shared-variables')->group(function () { - Route::get('/', SharedVariablesIndex::class)->name('shared-variables.index'); - Route::get('/team', TeamSharedVariablesIndex::class)->name('shared-variables.team.index'); - Route::get('/team/canvas', SharedVariablesCanvas::class)->name('shared-variables.team.canvas'); + Route::get('/', SharedVariablesIndex::class)->name('shared-variables.index')->middleware('access.team-wide'); + Route::get('/team', TeamSharedVariablesIndex::class)->name('shared-variables.team.index')->middleware('access.team-wide'); + Route::get('/team/canvas', SharedVariablesCanvas::class)->name('shared-variables.team.canvas')->middleware('access.team-wide'); Route::get('/projects', ProjectSharedVariablesIndex::class)->name('shared-variables.project.index'); Route::get('/project/{project_uuid}', ProjectSharedVariablesShow::class)->name('shared-variables.project.show'); Route::get('/project/{project_uuid}/canvas', SharedVariablesCanvas::class)->name('shared-variables.project.canvas'); Route::get('/environments', EnvironmentSharedVariablesIndex::class)->name('shared-variables.environment.index'); Route::get('/environments/project/{project_uuid}/environment/{environment_uuid}', EnvironmentSharedVariablesShow::class)->name('shared-variables.environment.show'); Route::get('/environments/project/{project_uuid}/environment/{environment_uuid}/canvas', SharedVariablesCanvas::class)->name('shared-variables.environment.canvas'); - Route::get('/servers', ServerSharedVariablesIndex::class)->name('shared-variables.server.index'); - Route::get('/server/{server_uuid}', ServerSharedVariablesShow::class)->name('shared-variables.server.show'); - Route::get('/server/{server_uuid}/canvas', SharedVariablesCanvas::class)->name('shared-variables.server.canvas'); + Route::get('/servers', ServerSharedVariablesIndex::class)->name('shared-variables.server.index')->middleware('access.team-wide'); + Route::get('/server/{server_uuid}', ServerSharedVariablesShow::class)->name('shared-variables.server.show')->middleware('access.team-wide'); + Route::get('/server/{server_uuid}/canvas', SharedVariablesCanvas::class)->name('shared-variables.server.canvas')->middleware('access.team-wide'); }); Route::prefix('team')->group(function () { - Route::get('/', TeamIndex::class)->name('team.index'); + Route::get('/', TeamIndex::class)->name('team.index')->middleware('access.team-wide'); Route::get('/{team_id}/avatar', TeamAvatarController::class)->whereNumber('team_id')->name('team.avatar'); - Route::get('/members', TeamMemberIndex::class)->name('team.member.index'); + Route::get('/members', TeamMemberIndex::class)->name('team.member.index')->middleware('access.team-wide'); Route::get('/members/{user_id}/avatar', ProfileAvatarController::class)->whereNumber('user_id')->name('team.member.avatar'); - Route::get('/audit-log', TeamAuditLog::class)->name('team.audit-log'); - Route::get('/admin', TeamAdminView::class)->name('team.admin-view'); - Route::get('/danger', TeamDangerZone::class)->name('team.danger-zone'); + Route::get('/audit-log', TeamAuditLog::class)->name('team.audit-log')->middleware('access.team-wide'); + Route::get('/admin', TeamAdminView::class)->name('team.admin-view')->middleware('access.team-wide'); + Route::get('/danger', TeamDangerZone::class)->name('team.danger-zone')->middleware('access.team-wide'); }); - Route::get('/terminal', TerminalIndex::class)->name('terminal')->middleware('can.access.terminal'); + Route::get('/terminal', TerminalIndex::class)->name('terminal')->middleware('can.access.terminal')->middleware('access.team-wide'); Route::post('/terminal/auth', function () { if (auth()->check()) { return response()->json(['authenticated' => true], 200); @@ -478,64 +482,69 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/tasks/{task_uuid}', fn () => redirect()->route('project.service.scheduled-tasks.show', get_route_parameters()))->name('project.service.scheduled-tasks'); }); - Route::get('/servers', ServerIndex::class)->name('server.index'); - Route::get('/servers/canvas', ServerCanvas::class)->name('server.canvas.index'); - Route::get('/registries', ServerDockerRegistries::class)->name('registries.index'); - Route::get('/servers/new', ServerCreatePage::class)->name('server.create')->middleware('can:create,'.Server::class); - Route::get('/servers/new/{type}/{token_uuid}', ServerCreatePage::class)->name('server.create.token')->middleware('can:create,'.Server::class)->whereIn('type', ['hetzner', 'vultr', 'digital-ocean', 'hostinger']); - Route::get('/servers/new/{type}', ServerCreatePage::class)->name('server.create.type')->middleware('can:create,'.Server::class)->whereIn('type', ['hetzner', 'vultr', 'digital-ocean', 'hostinger', 'manual']); + Route::middleware('access.team-wide')->group(function () { + Route::get('/servers', ServerIndex::class)->name('server.index'); + Route::get('/servers/canvas', ServerCanvas::class)->name('server.canvas.index'); + Route::get('/registries', ServerDockerRegistries::class)->name('registries.index'); + Route::get('/servers/new', ServerCreatePage::class)->name('server.create')->middleware('can:create,'.Server::class); + Route::get('/servers/new/{type}/{token_uuid}', ServerCreatePage::class)->name('server.create.token')->middleware('can:create,'.Server::class)->whereIn('type', ['hetzner', 'vultr', 'digital-ocean', 'hostinger']); + Route::get('/servers/new/{type}', ServerCreatePage::class)->name('server.create.type')->middleware('can:create,'.Server::class)->whereIn('type', ['hetzner', 'vultr', 'digital-ocean', 'hostinger', 'manual']); - Route::prefix('server/{server_uuid}')->group(function () { - Route::get('/', ServerShow::class)->name('server.show'); - Route::get('/canvas', ServerCanvas::class)->name('server.canvas'); - Route::get('/advanced', ServerAdvanced::class)->name('server.advanced'); - Route::get('/swarm', ServerSwarm::class)->name('server.swarm'); - Route::get('/sentinel', SentinelShow::class)->name('server.sentinel'); - Route::get('/sentinel/logs', SentinelLogs::class)->name('server.sentinel.logs'); - Route::get('/private-key', PrivateKeyShow::class)->name('server.private-key'); - Route::get('/cloud-provider-token', CloudProviderTokenShow::class)->name('server.cloud-provider-token'); - Route::get('/ca-certificate', CaCertificateShow::class)->name('server.ca-certificate'); - Route::get('/resources', ResourcesShow::class)->name('server.resources'); - Route::get('/cloudflare-tunnel', CloudflareTunnel::class)->name('server.cloudflare-tunnel'); - Route::get('/destinations', ServerDestinations::class)->name('server.destinations'); - Route::get('/log-drains', LogDrains::class)->name('server.log-drains'); - Route::get('/metrics', ServerCharts::class)->name('server.metrics'); - Route::get('/analytics', ServerAnalytics::class)->name('server.analytics'); - Route::get('/danger', DeleteServer::class)->name('server.delete'); - Route::get('/transfer', ServerTransfer::class)->name('server.transfer'); - Route::get('/proxy', ProxyShow::class)->name('server.proxy'); - Route::get('/proxy/certificates', ProxyCertificatesShow::class)->name('server.proxy.certificates'); - Route::get('/proxy/dynamic', ProxyDynamicConfigurations::class)->name('server.proxy.dynamic-confs'); - Route::get('/proxy/logs', ProxyLogs::class)->name('server.proxy.logs'); - Route::get('/terminal', ExecuteContainerCommand::class)->name('server.command')->middleware('can.access.terminal'); - Route::get('/docker-cleanup', DockerCleanup::class)->name('server.docker-cleanup'); - Route::get('/images', DockerImages::class)->name('server.docker-images'); - Route::get('/github-runners', GithubRunners::class)->name('server.github-runners'); - Route::get('/registries', ServerRegistries::class)->name('server.registries')->middleware('can.update.resource'); - Route::get('/security', fn () => redirect(route('dashboard')))->name('server.security')->middleware('can.update.resource'); - Route::get('/security/patches', Patches::class)->name('server.security.patches')->middleware('can.update.resource'); - Route::get('/security/terminal-access', TerminalAccess::class)->name('server.security.terminal-access')->middleware('can.update.resource'); + Route::prefix('server/{server_uuid}')->group(function () { + Route::get('/', ServerShow::class)->name('server.show'); + Route::get('/canvas', ServerCanvas::class)->name('server.canvas'); + Route::get('/advanced', ServerAdvanced::class)->name('server.advanced'); + Route::get('/swarm', ServerSwarm::class)->name('server.swarm'); + Route::get('/sentinel', SentinelShow::class)->name('server.sentinel'); + Route::get('/sentinel/logs', SentinelLogs::class)->name('server.sentinel.logs'); + Route::get('/private-key', PrivateKeyShow::class)->name('server.private-key'); + Route::get('/cloud-provider-token', CloudProviderTokenShow::class)->name('server.cloud-provider-token'); + Route::get('/ca-certificate', CaCertificateShow::class)->name('server.ca-certificate'); + Route::get('/resources', ResourcesShow::class)->name('server.resources'); + Route::get('/cloudflare-tunnel', CloudflareTunnel::class)->name('server.cloudflare-tunnel'); + Route::get('/destinations', ServerDestinations::class)->name('server.destinations'); + Route::get('/log-drains', LogDrains::class)->name('server.log-drains'); + Route::get('/metrics', ServerCharts::class)->name('server.metrics'); + Route::get('/analytics', ServerAnalytics::class)->name('server.analytics'); + Route::get('/danger', DeleteServer::class)->name('server.delete'); + Route::get('/transfer', ServerTransfer::class)->name('server.transfer'); + Route::get('/proxy', ProxyShow::class)->name('server.proxy'); + Route::get('/proxy/certificates', ProxyCertificatesShow::class)->name('server.proxy.certificates'); + Route::get('/proxy/dynamic', ProxyDynamicConfigurations::class)->name('server.proxy.dynamic-confs'); + Route::get('/proxy/logs', ProxyLogs::class)->name('server.proxy.logs'); + Route::get('/terminal', ExecuteContainerCommand::class)->name('server.command')->middleware('can.access.terminal'); + Route::get('/docker-cleanup', DockerCleanup::class)->name('server.docker-cleanup'); + Route::get('/images', DockerImages::class)->name('server.docker-images'); + Route::get('/github-runners', GithubRunners::class)->name('server.github-runners'); + Route::get('/registries', ServerRegistries::class)->name('server.registries')->middleware('can.update.resource'); + Route::get('/security', fn () => redirect(route('dashboard')))->name('server.security')->middleware('can.update.resource'); + Route::get('/security/patches', Patches::class)->name('server.security.patches')->middleware('can.update.resource'); + Route::get('/security/terminal-access', TerminalAccess::class)->name('server.security.terminal-access')->middleware('can.update.resource'); + }); + Route::get('/destinations', DestinationIndex::class)->name('destination.index'); + Route::get('/destination/{destination_uuid}', DestinationShow::class)->name('destination.show'); + Route::get('/destination/{destination_uuid}/danger', DestinationShow::class)->name('destination.danger'); + Route::get('/destination/{destination_uuid}/resources', DestinationResources::class)->name('destination.resources'); + + // Route::get('/security', fn () => view('security.index'))->name('security.index'); + Route::get('/security/private-key', SecurityPrivateKeyIndex::class)->name('security.private-key.index'); + // Route::get('/security/private-key/new', SecurityPrivateKeyCreate::class)->name('security.private-key.create'); + Route::get('/security/private-key/{private_key_uuid}', SecurityPrivateKeyShow::class)->name('security.private-key.show'); + + Route::get('/security/cloud-tokens', CloudTokens::class)->name('security.cloud-tokens'); + Route::get('/security/integration-tokens', IntegrationTokens::class)->name('security.integration-tokens'); + Route::get('/security/cloud-tokens/{cloud_token_uuid}', SecurityCloudProviderTokenShow::class)->name('security.cloud-tokens.show'); + Route::get('/security/cloud-init-scripts', CloudInitScripts::class)->name('security.cloud-init-scripts'); + Route::get('/security/cloud-init-scripts/{cloud_init_script_uuid}', SecurityCloudInitScriptShow::class)->name('security.cloud-init-scripts.show'); }); - Route::get('/destinations', DestinationIndex::class)->name('destination.index'); - Route::get('/destination/{destination_uuid}', DestinationShow::class)->name('destination.show'); - Route::get('/destination/{destination_uuid}/danger', DestinationShow::class)->name('destination.danger'); - Route::get('/destination/{destination_uuid}/resources', DestinationResources::class)->name('destination.resources'); - - // Route::get('/security', fn () => view('security.index'))->name('security.index'); - Route::get('/security/private-key', SecurityPrivateKeyIndex::class)->name('security.private-key.index'); - // Route::get('/security/private-key/new', SecurityPrivateKeyCreate::class)->name('security.private-key.create'); - Route::get('/security/private-key/{private_key_uuid}', SecurityPrivateKeyShow::class)->name('security.private-key.show'); - - Route::get('/security/cloud-tokens', CloudTokens::class)->name('security.cloud-tokens'); - Route::get('/security/integration-tokens', IntegrationTokens::class)->name('security.integration-tokens'); - Route::get('/security/cloud-tokens/{cloud_token_uuid}', SecurityCloudProviderTokenShow::class)->name('security.cloud-tokens.show'); - Route::get('/security/cloud-init-scripts', CloudInitScripts::class)->name('security.cloud-init-scripts'); - Route::get('/security/cloud-init-scripts/{cloud_init_script_uuid}', SecurityCloudInitScriptShow::class)->name('security.cloud-init-scripts.show'); Route::get('/security/api-tokens', ApiTokens::class)->name('security.api-tokens'); }); Route::middleware(['auth'])->group(function () { Route::get('/select-team', SelectTeam::class)->name('team.select'); +}); + +Route::middleware(['auth', 'access.team-wide'])->group(function () { Route::get('/sources', function () { $sources = currentTeam()->sources(); diff --git a/tests/Feature/Access/AccessGrantsTest.php b/tests/Feature/Access/AccessGrantsTest.php new file mode 100644 index 0000000000..d82e596ade --- /dev/null +++ b/tests/Feature/Access/AccessGrantsTest.php @@ -0,0 +1,142 @@ +team = Team::factory()->create(); + $server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = $server->standaloneDockers()->firstOrFail(); + + $this->shop = Project::factory()->create(['team_id' => $this->team->id, 'name' => 'shop']); + $this->shopProduction = Environment::factory()->create(['project_id' => $this->shop->id, 'name' => 'prod']); + $this->blog = Project::factory()->create(['team_id' => $this->team->id, 'name' => 'blog']); + $this->blogStaging = Environment::factory()->create(['project_id' => $this->blog->id, 'name' => 'staging']); + + $this->shopApi = accessGrantsTestApplication($this->shopProduction, $this->destination); + $this->blogWeb = accessGrantsTestApplication($this->blogStaging, $this->destination); + $this->blogWorker = accessGrantsTestApplication($this->blogStaging, $this->destination); +}); + +function accessGrantsTestApplication(Environment $environment, $destination): Application +{ + return Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); +} + +function accessGrantsTestMember(Team $team, ?array $access, string $role = 'member'): User +{ + $user = User::factory()->create(); + $user->teams()->attach($team, ['role' => $role, 'access' => $access === null ? null : json_encode($access)]); + test()->actingAs($user); + session(['currentTeam' => $team]); + + return $user->fresh(); +} + +it('keeps team-wide read access for members without stored grants', function () { + $member = accessGrantsTestMember($this->team, null); + + expect(Project::query()->pluck('name')->sort()->values()->all())->toBe(['blog', 'shop']) + ->and(Application::query()->count())->toBe(3) + ->and($member->canAccess(AccessLevel::Read, $this->shopApi))->toBeTrue() + ->and($member->canAccess(AccessLevel::Write, $this->shopApi))->toBeFalse(); +}); + +it('limits a member with a project grant to that project', function () { + $member = accessGrantsTestMember($this->team, [ + ['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['read', 'deploy']], + ]); + + expect(Project::query()->pluck('name')->all())->toBe(['shop']) + ->and(Environment::query()->pluck('project_id')->unique()->values()->all())->toBe([$this->shop->id]) + ->and(Application::query()->pluck('id')->all())->toBe([$this->shopApi->id]) + ->and(Application::query()->where('uuid', $this->blogWeb->uuid)->first())->toBeNull() + ->and($member->canAccess(AccessLevel::Deploy, $this->shopApi))->toBeTrue() + ->and($member->canAccess(AccessLevel::Write, $this->shopApi))->toBeFalse() + ->and($member->canAccess(AccessLevel::Read, $this->blogWeb))->toBeFalse(); +}); + +it('shows only the granted resource and its parents for a resource grant', function () { + $member = accessGrantsTestMember($this->team, [ + ['scope' => 'resource', 'uuid' => $this->blogWeb->uuid, 'levels' => ['write']], + ]); + + expect(Project::query()->pluck('name')->all())->toBe(['blog']) + ->and(Environment::query()->pluck('id')->all())->toBe([$this->blogStaging->id]) + ->and(Application::query()->pluck('id')->all())->toBe([$this->blogWeb->id]) + ->and($this->blogStaging->applications()->pluck('id')->all())->toBe([$this->blogWeb->id]) + ->and($member->canAccess(AccessLevel::Write, $this->blogWeb))->toBeTrue() + ->and($member->canAccess(AccessLevel::Delete, $this->blogWeb))->toBeFalse() + ->and($member->canAccess(AccessLevel::Read, $this->blogWorker))->toBeFalse() + ->and($member->canAccess(AccessLevel::Read, $this->blogStaging))->toBeFalse(); +}); + +it('gives environment grants to every resource in the environment only', function () { + $member = accessGrantsTestMember($this->team, [ + ['scope' => 'environment', 'uuid' => $this->blogStaging->uuid, 'levels' => ['read:sensitive']], + ]); + + expect(Application::query()->pluck('id')->sort()->values()->all())->toBe([$this->blogWeb->id, $this->blogWorker->id]) + ->and($member->canAccess(AccessLevel::ReadSensitive, $this->blogWorker))->toBeTrue() + ->and($member->canAccess(AccessLevel::Deploy, $this->blogWorker))->toBeFalse() + ->and($member->canAccess(AccessLevel::Read, $this->shopApi))->toBeFalse(); +}); + +it('does not filter admins, even with stored grants', function () { + $admin = accessGrantsTestMember($this->team, [ + ['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['read']], + ], 'admin'); + + expect(Project::query()->count())->toBe(2) + ->and($admin->canAccess(AccessLevel::Delete, $this->blogWeb))->toBeTrue(); +}); + +it('ignores grants that point to another team', function () { + $otherTeam = Team::factory()->create(); + $foreignProject = Project::factory()->create(['team_id' => $otherTeam->id]); + + accessGrantsTestMember($this->team, [ + ['scope' => 'project', 'uuid' => $foreignProject->uuid, 'levels' => ['root']], + ]); + + expect(Project::query()->withoutGlobalScopes()->where('team_id', $this->team->id)->count())->toBe(2) + ->and(Project::query()->count())->toBe(0); +}); + +it('checks a resource with the grants of its own team after the session team changes', function () { + $member = accessGrantsTestMember($this->team, [ + ['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['write']], + ]); + $ownTeam = Team::factory()->create(); + $member->teams()->attach($ownTeam, ['role' => 'owner']); + $member = $member->fresh(); + session(['currentTeam' => $ownTeam]); + + expect($member->canAccess(AccessLevel::Write, $this->shopApi))->toBeTrue() + ->and($member->canAccess(AccessLevel::Write, $this->blogWeb))->toBeFalse(); +}); + +it('lets a member open the parents of a granted resource but not other projects', function () { + $member = accessGrantsTestMember($this->team, [ + ['scope' => 'resource', 'uuid' => $this->blogWeb->uuid, 'levels' => ['read']], + ]); + + expect($member->can('view', $this->blog))->toBeTrue() + ->and($member->can('view', $this->blogStaging))->toBeTrue() + ->and($member->can('view', $this->shop))->toBeFalse() + ->and($member->can('view', $this->shopProduction))->toBeFalse() + ->and($member->can('update', $this->blogStaging))->toBeFalse(); +}); diff --git a/tests/Feature/Access/AccessPoliciesTest.php b/tests/Feature/Access/AccessPoliciesTest.php new file mode 100644 index 0000000000..937e3bad2d --- /dev/null +++ b/tests/Feature/Access/AccessPoliciesTest.php @@ -0,0 +1,269 @@ + 0]); + Server::flushIdentityMap(); + + $this->team = Team::factory()->create(); + $server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = $server->standaloneDockers()->firstOrFail(); + $placement = [ + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]; + + $this->shop = Project::factory()->create(['team_id' => $this->team->id, 'name' => 'shop']); + $this->shopProduction = Environment::factory()->create(['project_id' => $this->shop->id, 'name' => 'prod']); + $this->shopStaging = Environment::factory()->create(['project_id' => $this->shop->id, 'name' => 'staging']); + $this->blog = Project::factory()->create(['team_id' => $this->team->id, 'name' => 'blog']); + $blogProduction = Environment::factory()->create(['project_id' => $this->blog->id, 'name' => 'prod']); + + $this->application = Application::factory()->create(['environment_id' => $this->shopProduction->id, ...$placement]); + $this->database = StandalonePostgresql::create([ + 'name' => 'orders-db', 'postgres_user' => 'postgres', 'postgres_password' => 'password', 'postgres_db' => 'db', + 'image' => 'postgres:17', 'environment_id' => $this->shopProduction->id, ...$placement, + ]); + $this->service = Service::factory()->create([ + 'environment_id' => $this->shopProduction->id, + 'server_id' => $server->id, + ...$placement, + ]); + $this->secret = EnvironmentVariable::create([ + 'key' => 'API_KEY', + 'value' => 'shop-secret-value', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + 'is_preview' => false, + ]); + $this->blogApplication = Application::factory()->create(['environment_id' => $blogProduction->id, ...$placement]); +}); + +function accessPoliciesMember(Team $team, ?array $access, string $role = 'member'): User +{ + $user = User::factory()->create(); + $user->teams()->attach($team, ['role' => $role, 'access' => $access === null ? null : json_encode($access)]); + $user = $user->fresh(); + test()->actingAs($user); + session(['currentTeam' => $team]); + + return $user; +} + +function accessPoliciesProjectGrant(Project $project, array $levels): array +{ + return [['scope' => 'project', 'uuid' => $project->uuid, 'levels' => $levels]]; +} + +/** + * @return array + */ +function accessPoliciesAbilities(User $user, object $test): array +{ + $gate = Gate::forUser($user); + + return [ + 'application.view' => $gate->allows('view', $test->application), + 'application.deploy' => $gate->allows('deploy', $test->application), + 'application.update' => $gate->allows('update', $test->application), + 'application.delete' => $gate->allows('delete', $test->application), + 'database.view' => $gate->allows('view', $test->database), + 'database.update' => $gate->allows('update', $test->database), + 'database.delete' => $gate->allows('delete', $test->database), + 'service.view' => $gate->allows('view', $test->service), + 'service.deploy' => $gate->allows('deploy', $test->service), + 'service.update' => $gate->allows('update', $test->service), + 'service.delete' => $gate->allows('delete', $test->service), + 'secret.view' => $gate->allows('view', $test->secret), + 'secret.viewSensitive' => $gate->allows('viewSensitive', $test->secret), + 'secret.update' => $gate->allows('update', $test->secret), + ]; +} + +/** + * @param list $allowed + * @return array + */ +function accessPoliciesExpected(array $allowed): array +{ + $keys = [ + 'application.view', 'application.deploy', 'application.update', 'application.delete', + 'database.view', 'database.update', 'database.delete', + 'service.view', 'service.deploy', 'service.update', 'service.delete', + 'secret.view', 'secret.viewSensitive', 'secret.update', + ]; + + return array_combine($keys, array_map(fn (string $key) => in_array($key, $allowed, true), $keys)); +} + +const ACCESS_POLICIES_VIEW = ['application.view', 'database.view', 'service.view', 'secret.view']; + +it('lets a member with a deploy grant deploy but not change or delete', function () { + $member = accessPoliciesMember($this->team, accessPoliciesProjectGrant($this->shop, ['deploy'])); + + expect(accessPoliciesAbilities($member, $this))->toBe(accessPoliciesExpected([ + ...ACCESS_POLICIES_VIEW, 'application.deploy', 'service.deploy', + ]))->and(Gate::forUser($member)->allows('view', $this->blogApplication))->toBeFalse(); +}); + +it('lets a member with a write grant change resources and read secrets but not delete', function () { + $member = accessPoliciesMember($this->team, accessPoliciesProjectGrant($this->shop, ['write'])); + + expect(accessPoliciesAbilities($member, $this))->toBe(accessPoliciesExpected([ + ...ACCESS_POLICIES_VIEW, 'application.deploy', 'application.update', 'database.update', + 'service.deploy', 'service.update', 'secret.viewSensitive', 'secret.update', + ])); +}); + +it('lets a member with a delete grant delete but not change', function () { + $member = accessPoliciesMember($this->team, accessPoliciesProjectGrant($this->shop, ['delete'])); + + expect(accessPoliciesAbilities($member, $this))->toBe(accessPoliciesExpected([ + ...ACCESS_POLICIES_VIEW, 'application.delete', 'database.delete', 'service.delete', + ])); +}); + +it('applies a resource grant only to that resource', function () { + $member = accessPoliciesMember($this->team, [ + ['scope' => 'resource', 'uuid' => $this->application->uuid, 'levels' => ['write']], + ]); + + expect(accessPoliciesAbilities($member, $this))->toBe(accessPoliciesExpected([ + 'application.view', 'application.deploy', 'application.update', + 'secret.view', 'secret.viewSensitive', 'secret.update', + ])); +}); + +it('keeps a member without stored grants read-only', function () { + $member = accessPoliciesMember($this->team, null); + + expect(accessPoliciesAbilities($member, $this))->toBe(accessPoliciesExpected(ACCESS_POLICIES_VIEW)) + ->and(Gate::forUser($member)->allows('view', $this->blogApplication))->toBeTrue() + ->and(Gate::forUser($member)->allows('createAnyResource'))->toBeFalse() + ->and(Gate::forUser($member)->allows('create', Project::class))->toBeFalse(); +}); + +it('gives admins full access', function () { + $admin = accessPoliciesMember($this->team, null, 'admin'); + + expect(accessPoliciesAbilities($admin, $this))->toBe(accessPoliciesExpected([ + ...ACCESS_POLICIES_VIEW, 'application.deploy', 'application.update', 'application.delete', + 'database.update', 'database.delete', 'service.deploy', 'service.update', 'service.delete', + 'secret.viewSensitive', 'secret.update', + ]))->and(Gate::forUser($admin)->allows('createAnyResource'))->toBeTrue() + ->and(Gate::forUser($admin)->allows('create', [Application::class, $this->shopStaging]))->toBeTrue() + ->and(Gate::forUser($admin)->allows('create', Project::class))->toBeTrue(); +}); + +it('uses the grants of the resource team after the session switches to another team', function () { + $member = User::factory()->create(); + $member->teams()->attach($this->team, ['role' => 'member', 'access' => json_encode(accessPoliciesProjectGrant($this->shop, ['write']))]); + $ownTeam = Team::factory()->create(); + $member->teams()->attach($ownTeam, ['role' => 'owner']); + $member = $member->fresh(); + $this->actingAs($member); + session(['currentTeam' => $ownTeam]); + + $gate = Gate::forUser($member); + + expect($gate->allows('update', $this->application))->toBeTrue() + ->and($gate->allows('viewSensitive', $this->secret))->toBeTrue() + ->and($gate->allows('delete', $this->application))->toBeFalse() + ->and($gate->allows('view', $this->blogApplication))->toBeFalse(); +}); + +it('lets a member create resources only in environments they can write to', function () { + $member = accessPoliciesMember($this->team, [ + ['scope' => 'environment', 'uuid' => $this->shopStaging->uuid, 'levels' => ['write']], + ]); + $gate = Gate::forUser($member); + + expect($gate->allows('create', [Application::class, $this->shopStaging]))->toBeTrue() + ->and($gate->allows('create', [Service::class, $this->shopStaging]))->toBeTrue() + ->and($gate->allows('create', [StandalonePostgresql::class, $this->shopStaging]))->toBeTrue() + ->and($gate->allows('createAnyResource', [$this->shopStaging]))->toBeTrue() + ->and($gate->allows('create', [Application::class, $this->shopProduction]))->toBeFalse() + ->and($gate->allows('create', Application::class))->toBeFalse() + ->and($gate->allows('create', [Environment::class, $this->shop]))->toBeFalse() + ->and($gate->allows('create', Project::class))->toBeFalse(); +}); + +it('shows secret values in the variable editor only with read-sensitive access', function (array $levels, ?string $expectedValue) { + accessPoliciesMember($this->team, accessPoliciesProjectGrant($this->shop, $levels)); + + $component = Livewire::test(EnvironmentVariableShow::class, ['env' => $this->secret, 'type' => 'application']); + + if ($expectedValue === null) { + expect($component->get('isValueHidden'))->toBeTrue() + ->and($component->get('value'))->toBeNull(); + $component->call('loadValues')->assertForbidden(); + + return; + } + + $component->call('loadValues'); + expect($component->get('value'))->toBe($expectedValue) + ->and($component->get('isValueHidden'))->toBeFalse(); +})->with([ + 'read' => [['read'], null], + 'deploy' => [['deploy'], null], + 'read:sensitive' => [['read:sensitive'], 'shop-secret-value'], + 'write' => [['write'], 'shop-secret-value'], +]); + +it('finds domain conflicts with resources the member cannot see without naming them', function () { + $this->blogApplication->update(['fqdn' => 'https://blog.example.com']); + $this->application->update(['fqdn' => 'https://blog.example.com']); + accessPoliciesMember($this->team, accessPoliciesProjectGrant($this->shop, ['write'])); + + $result = checkDomainUsage(resource: $this->application->fresh()); + + expect($result['hasConflicts'])->toBeTrue() + ->and($result['conflicts'][0]['message'])->toBe('Domain https://blog.example.com is already in use by another resource in this team') + ->and($result['conflicts'][0]['resource_name'])->toBe('another resource'); +}); + +function accessPoliciesBindEnvironmentRoute(Environment $environment): void +{ + Event::listen(RouteMatched::class, function (RouteMatched $event) use ($environment): void { + $event->route->setParameter('project_uuid', $environment->project->uuid); + $event->route->setParameter('environment_uuid', $environment->uuid); + }); +} + +it('opens the new resource page only for environments the member can write to', function () { + accessPoliciesMember($this->team, [ + ...accessPoliciesProjectGrant($this->shop, ['read']), + ['scope' => 'environment', 'uuid' => $this->shopStaging->uuid, 'levels' => ['write']], + ]); + + accessPoliciesBindEnvironmentRoute($this->shopStaging); + Livewire::test(ResourceCreate::class)->assertOk(); + + Event::forget(RouteMatched::class); + accessPoliciesBindEnvironmentRoute($this->shopProduction); + Livewire::test(ResourceCreate::class)->assertForbidden(); +}); + +afterEach(function () { + Event::forget(RouteMatched::class); +}); diff --git a/tests/Feature/Access/MemberGrantsTest.php b/tests/Feature/Access/MemberGrantsTest.php new file mode 100644 index 0000000000..a895283f78 --- /dev/null +++ b/tests/Feature/Access/MemberGrantsTest.php @@ -0,0 +1,240 @@ + 'array', + 'session.driver' => 'array', + 'queue.default' => 'sync', + 'app.maintenance.store' => 'array', + ]); + InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0])); + Server::flushIdentityMap(); + Cache::flush(); + + $this->team = Team::factory()->create(['show_boarding' => false]); + $this->server = Server::factory()->create(['team_id' => $this->team->id, 'name' => 'web-server']); + $destination = $this->server->standaloneDockers()->firstOrFail(); + + $this->shop = Project::factory()->create(['team_id' => $this->team->id, 'name' => 'shop']); + $shopProduction = Environment::factory()->create(['project_id' => $this->shop->id, 'name' => 'prod']); + $this->blog = Project::factory()->create(['team_id' => $this->team->id, 'name' => 'blog']); + $blogStaging = Environment::factory()->create(['project_id' => $this->blog->id, 'name' => 'staging']); + + $this->shopApi = Application::factory()->create([ + 'name' => 'shop-api', + 'environment_id' => $shopProduction->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + $this->blogWeb = Application::factory()->create([ + 'name' => 'blog-web', + 'environment_id' => $blogStaging->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + + $this->owner = memberGrantsUser($this->team, 'owner', null); +}); + +function memberGrantsUser(Team $team, string $role, ?array $access): User +{ + $user = User::factory()->create(); + $team->members()->attach($user->id, ['role' => $role, 'access' => $access === null ? null : json_encode($access)]); + + return $user; +} + +function memberGrantsActAs(User $user, Team $team): void +{ + test()->actingAs($user); + session(['currentTeam' => $team]); +} + +function memberGrantsStoredAccess(Team $team, User $user): mixed +{ + $stored = DB::table('team_user')->where('team_id', $team->id)->where('user_id', $user->id)->value('access'); + + return $stored === null ? null : json_decode($stored, true); +} + +it('lets an owner save grants for a member through the access editor', function () { + $member = memberGrantsUser($this->team, 'member', null); + memberGrantsActAs($this->owner, $this->team); + + Livewire::test(Access::class)->call('openFor', $member->id) + ->call('applyPreset', 'developer', $this->shop->uuid) + ->call('addGrant') + ->set('grants.1.target', 'resource:'.$this->blogWeb->uuid) + ->set('grants.1.levels.Read', true) + ->call('save') + ->assertHasNoErrors(); + + expect(memberGrantsStoredAccess($this->team, $member))->toBe([ + ['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['deploy', 'write']], + ['scope' => 'resource', 'uuid' => $this->blogWeb->uuid, 'levels' => ['read']], + ]); +}); + +it('stores an empty list when the owner removes every grant', function () { + $member = memberGrantsUser($this->team, 'member', null); + memberGrantsActAs($this->owner, $this->team); + + $editor = Livewire::test(Access::class)->call('openFor', $member->id); + $editor->call('removeGrant', $editor->get('grants.0.key')) + ->call('save') + ->assertHasNoErrors(); + + expect(memberGrantsStoredAccess($this->team, $member))->toBe([]) + ->and(Access::summary('[]'))->toBe('No access') + ->and(Access::summary(null))->toBe('Team-wide read (default)'); +}); + +it('does not let a member open the editor or save grants', function () { + $member = memberGrantsUser($this->team, 'member', null); + $target = memberGrantsUser($this->team, 'member', null); + + memberGrantsActAs($member, $this->team); + Livewire::test(Access::class)->assertForbidden(); + + memberGrantsActAs($this->owner, $this->team); + $editor = Livewire::test(Access::class) + ->call('openFor', $target->id) + ->call('applyPreset', 'full'); + + memberGrantsActAs($member->fresh(), $this->team); + $editor->call('save')->assertForbidden(); + + expect(memberGrantsStoredAccess($this->team, $target))->toBeNull(); +}); + +it('rejects a scope from another team', function () { + $member = memberGrantsUser($this->team, 'member', null); + $otherTeam = Team::factory()->create(); + $foreignProject = Project::factory()->create(['team_id' => $otherTeam->id]); + memberGrantsActAs($this->owner, $this->team); + + Livewire::test(Access::class)->call('openFor', $member->id) + ->set('grants.0.target', 'project:'.$foreignProject->uuid) + ->call('save') + ->assertHasErrors(['grants.0.target']); + + expect(memberGrantsStoredAccess($this->team, $member))->toBeNull(); +}); + +it('blocks team-level pages for a member with only a project grant', function (string $path) { + $member = memberGrantsUser($this->team, 'member', [ + ['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['read']], + ]); + + $this->actingAs($member)->withSession(['currentTeam' => $this->team])->get($path)->assertForbidden(); +})->with([ + '/servers', + '/destinations', + '/security/private-key', + '/sources', + '/storages', + '/notifications/email', + '/team', + '/team/members', + '/shared-variables/team', + '/tags', + '/analytics', + '/settings', +]); + +it('keeps team-level pages for legacy members and owners', function () { + $legacy = memberGrantsUser($this->team, 'member', null); + + $this->actingAs($legacy)->withSession(['currentTeam' => $this->team])->get('/servers')->assertOk(); + $this->actingAs($this->owner)->withSession(['currentTeam' => $this->team])->get('/servers')->assertOk(); +}); + +it('keeps the own API tokens page for a scoped member', function () { + $member = memberGrantsUser($this->team, 'member', [ + ['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['read']], + ]); + + $this->actingAs($member)->withSession(['currentTeam' => $this->team])->get('/security/api-tokens')->assertOk(); +}); + +it('lists only granted projects and hides other project pages', function () { + $member = memberGrantsUser($this->team, 'member', [ + ['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['read']], + ]); + memberGrantsActAs($member, $this->team); + + expect(Livewire::test(ProjectIndex::class)->get('projects')->pluck('uuid')->all())->toBe([$this->shop->uuid]); + + $this->get('/project/'.$this->shop->uuid)->assertOk(); + $this->get('/project/'.$this->blog->uuid)->assertNotFound(); +}); + +it('keeps hidden resources out of global search for a scoped member, and full results for an admin after', function () { + $member = memberGrantsUser($this->team, 'member', [ + ['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['read']], + ]); + + memberGrantsActAs($member, $this->team); + $memberItems = collect(Livewire::test(GlobalSearch::class)->call('openSearchModal')->get('allSearchableItems')); + + expect($memberItems->whereIn('type', ['application', 'project', 'server'])->pluck('name')->sort()->values()->all()) + ->toBe(['shop', 'shop-api']) + ->and($memberItems->where('type', 'navigation')->pluck('link')->all())->not->toContain(route('server.index'), route('team.member.index')); + + memberGrantsActAs($this->owner, $this->team); + $ownerItems = collect(Livewire::test(GlobalSearch::class)->call('openSearchModal')->get('allSearchableItems')); + + expect($ownerItems->whereIn('type', ['application', 'project', 'server'])->pluck('name')->sort()->values()->all()) + ->toBe(['blog', 'blog-web', 'shop', 'shop-api', 'web-server']); +}); + +it('shows no servers on the dashboard for a scoped member', function () { + $member = memberGrantsUser($this->team, 'member', [ + ['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['read']], + ]); + memberGrantsActAs($member, $this->team); + + $dashboard = Livewire::test(Dashboard::class); + + expect($dashboard->get('servers')->all())->toBe([]) + ->and($dashboard->get('projects')->pluck('uuid')->all())->toBe([$this->shop->uuid]); + + expect(fn () => $dashboard->set('showsTeamSections', true))->toThrow(CannotUpdateLockedPropertyException::class); +}); + +it('renders the members page and the editor with every scope of the team for an owner', function () { + $member = memberGrantsUser($this->team, 'member', [ + ['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['read']], + ['scope' => 'project', 'uuid' => $this->blog->uuid, 'levels' => ['read']], + ]); + memberGrantsActAs($this->owner, $this->team); + + $this->get('/team/members')->assertOk()->assertSee('2 projects'); + + $options = Livewire::test(Access::class)->call('openFor', $member->id)->assertOk()->instance()->scopeOptions(); + + expect(collect($options)->reject(fn (array $option) => $option['header'] ?? false)->pluck('label')->all())->toBe([ + 'Whole team', 'blog', 'shop', + 'blog / production', 'blog / staging', 'shop / prod', 'shop / production', + 'blog / staging / blog-web', 'shop / prod / shop-api', + ]); +}); diff --git a/tests/Feature/Access/ScopedApiTokenTest.php b/tests/Feature/Access/ScopedApiTokenTest.php new file mode 100644 index 0000000000..612979c316 --- /dev/null +++ b/tests/Feature/Access/ScopedApiTokenTest.php @@ -0,0 +1,346 @@ +set('app.maintenance.store', 'array'); + config()->set('cache.default', 'array'); + + InstanceSettings::query()->delete(); + $settings = new InstanceSettings(['is_api_enabled' => true, 'is_mcp_server_enabled' => true]); + $settings->id = 0; + $settings->save(); + Once::flush(); + + $this->team = Team::factory()->create(['is_mcp_server_enabled' => true]); + $this->admin = User::factory()->create(); + $this->team->members()->attach($this->admin->id, ['role' => 'admin']); + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = $this->server->standaloneDockers()->firstOrFail(); + + $this->shop = Project::factory()->create(['team_id' => $this->team->id, 'name' => 'shop']); + $this->shopProduction = Environment::factory()->create(['project_id' => $this->shop->id, 'name' => 'prod']); + $this->blog = Project::factory()->create(['team_id' => $this->team->id, 'name' => 'blog']); + $this->blogStaging = Environment::factory()->create(['project_id' => $this->blog->id, 'name' => 'staging']); + + $this->shopApi = scopedTokenTestApplication($this->shopProduction, $this->destination, 'shop-api'); + $this->shopWorker = scopedTokenTestApplication($this->shopProduction, $this->destination, 'shop-worker'); + $this->blogWeb = scopedTokenTestApplication($this->blogStaging, $this->destination, 'blog-web'); +}); + +afterEach(function () { + Server::flushIdentityMap(); +}); + +function scopedTokenTestApplication(Environment $environment, $destination, string $name): Application +{ + return Application::factory()->create([ + 'name' => $name, + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); +} + +/** + * @param list|null $scopes + */ +function scopedTokenTestToken(User $user, Team $team, array $abilities, ?array $scopes): string +{ + $token = $user->createToken('scoped-test', $abilities, null, $team->id); + $token->accessToken->forceFill(['scopes' => $scopes])->save(); + + return $token->plainTextToken; +} + +function scopedTokenTestMember(Team $team, ?array $access): User +{ + $member = User::factory()->create(); + $team->members()->attach($member->id, ['role' => 'member', 'access' => $access === null ? null : json_encode($access)]); + + return $member; +} + +function scopedTokenTestApi(string $token, string $method, string $uri, array $data = []) +{ + app('auth')->forgetGuards(); + + return test()->withHeaders(['Authorization' => 'Bearer '.$token])->json($method, $uri, $data); +} + +function scopedTokenTestMcp(string $token, string $method, array $params = []) +{ + app('auth')->forgetGuards(); + + return test()->withHeaders([ + 'Authorization' => 'Bearer '.$token, + 'Accept' => 'application/json, text/event-stream', + ])->postJson('/mcp', ['jsonrpc' => '2.0', 'id' => 1, 'method' => $method, 'params' => (object) $params]); +} + +describe('admin token scoped to one project', function () { + beforeEach(function () { + $this->token = scopedTokenTestToken($this->admin, $this->team, ['read', 'write'], [['scope' => 'project', 'uuid' => $this->shop->uuid]]); + }); + + test('lists only the applications and resources of the project', function () { + expect(scopedTokenTestApi($this->token, 'GET', '/api/v1/applications')->assertOk()->json('*.name')) + ->toEqualCanonicalizing(['shop-api', 'shop-worker']) + ->and(scopedTokenTestApi($this->token, 'GET', '/api/v1/resources')->assertOk()->json('*.name')) + ->toEqualCanonicalizing(['shop-api', 'shop-worker']) + ->and(scopedTokenTestApi($this->token, 'GET', '/api/v1/projects')->assertOk()->json('*.name')) + ->toBe(['shop']); + }); + + test('does not find an application in another project', function () { + scopedTokenTestApi($this->token, 'GET', "/api/v1/applications/{$this->blogWeb->uuid}")->assertNotFound(); + scopedTokenTestApi($this->token, 'GET', "/api/v1/projects/{$this->blog->uuid}")->assertNotFound(); + }); + + test('cannot use team-level endpoints or create projects', function () { + scopedTokenTestApi($this->token, 'GET', '/api/v1/servers') + ->assertForbidden() + ->assertExactJson(['message' => 'This API token is limited to selected projects, environments, or resources, so it cannot use this endpoint.']); + scopedTokenTestApi($this->token, 'GET', '/api/v1/security/keys')->assertForbidden(); + scopedTokenTestApi($this->token, 'GET', '/api/v1/team')->assertForbidden(); + scopedTokenTestApi($this->token, 'POST', '/api/v1/projects', ['name' => 'new'])->assertForbidden(); + + expect(Project::query()->where('name', 'new')->exists())->toBeFalse(); + }); + + test('lists only open deployments of the project', function () { + foreach ([$this->shopApi, $this->blogWeb] as $application) { + ApplicationDeploymentQueue::create([ + 'application_id' => (string) $application->id, + 'deployment_uuid' => 'deploy-'.$application->name, + 'server_id' => $this->server->id, + 'status' => 'queued', + ]); + } + + expect(scopedTokenTestApi($this->token, 'GET', '/api/v1/deployments')->assertOk()->json('*.deployment_uuid')) + ->toBe(['deploy-shop-api']); + scopedTokenTestApi($this->token, 'GET', '/api/v1/deployments/deploy-blog-web')->assertNotFound(); + }); + + test('can create an application in the project', function () { + scopedTokenTestApi($this->token, 'POST', '/api/v1/applications/dockerimage', [ + 'project_uuid' => $this->shop->uuid, + 'environment_uuid' => $this->shopProduction->uuid, + 'server_uuid' => $this->server->uuid, + 'docker_registry_image_name' => 'nginx', + 'ports_exposes' => '80', + ])->assertCreated(); + + expect($this->shopProduction->applications()->count())->toBe(3); + }); +}); + +describe('token scoped to one resource', function () { + test('deploys the resource with the deploy ability', function () { + $token = scopedTokenTestToken($this->admin, $this->team, ['deploy'], [['scope' => 'resource', 'uuid' => $this->shopApi->uuid]]); + + scopedTokenTestApi($token, 'POST', "/api/v1/deploy?uuid={$this->shopApi->uuid}") + ->assertOk() + ->assertJsonPath('deployments.0.resource_uuid', $this->shopApi->uuid); + scopedTokenTestApi($token, 'POST', "/api/v1/deploy?uuid={$this->shopWorker->uuid}")->assertNotFound(); + + expect(ApplicationDeploymentQueue::query()->pluck('application_id')->all())->toBe([(string) $this->shopApi->id]); + Queue::assertPushed(ApplicationDeploymentJob::class); + }); + + test('cannot create an application in the environment of the resource', function () { + $token = scopedTokenTestToken($this->admin, $this->team, ['read', 'write'], [['scope' => 'resource', 'uuid' => $this->shopApi->uuid]]); + + scopedTokenTestApi($token, 'POST', '/api/v1/applications/dockerimage', [ + 'project_uuid' => $this->shop->uuid, + 'environment_uuid' => $this->shopProduction->uuid, + 'server_uuid' => $this->server->uuid, + 'docker_registry_image_name' => 'nginx', + 'ports_exposes' => '80', + ])->assertForbidden(); + + expect(Application::withoutGlobalScope(VisibleThroughAccessGrants::class)->where('environment_id', $this->shopProduction->id)->count())->toBe(2); + }); +}); + +describe('delete ability', function () { + test('a write token without delete cannot delete an application', function () { + $token = scopedTokenTestToken($this->admin, $this->team, ['read', 'write'], null); + + scopedTokenTestApi($token, 'DELETE', "/api/v1/applications/{$this->shopApi->uuid}") + ->assertForbidden() + ->assertExactJson(['message' => 'Missing required permissions: delete']); + + expect(Application::query()->whereKey($this->shopApi->id)->exists())->toBeTrue(); + }); + + test('a token with delete can delete an application', function () { + $token = scopedTokenTestToken($this->admin, $this->team, ['read', 'write', 'delete'], null); + + scopedTokenTestApi($token, 'DELETE', "/api/v1/applications/{$this->shopApi->uuid}")->assertOk(); + + expect(Application::query()->whereKey($this->shopApi->id)->exists())->toBeFalse(); + }); +}); + +describe('member tokens', function () { + test('a legacy member can use a read token but not a write token', function () { + $member = scopedTokenTestMember($this->team, null); + + expect(scopedTokenTestApi(scopedTokenTestToken($member, $this->team, ['read'], null), 'GET', '/api/v1/applications')->assertOk()->json('*.name')) + ->toEqualCanonicalizing(['shop-api', 'shop-worker', 'blog-web']); + scopedTokenTestApi(scopedTokenTestToken($member, $this->team, ['read', 'write'], null), 'GET', '/api/v1/applications') + ->assertForbidden() + ->assertExactJson(['message' => 'This API token has more access than you have in this team. Revoke it and create a new token.']); + }); + + test('a member with a project grant can use a write token limited to that project', function () { + $member = scopedTokenTestMember($this->team, [['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['write', 'deploy']]]); + $token = scopedTokenTestToken($member, $this->team, ['read', 'write'], [['scope' => 'project', 'uuid' => $this->shop->uuid]]); + + scopedTokenTestApi($token, 'PATCH', "/api/v1/applications/{$this->shopApi->uuid}", ['name' => 'renamed'])->assertOk(); + scopedTokenTestApi($token, 'PATCH', "/api/v1/applications/{$this->blogWeb->uuid}", ['name' => 'renamed'])->assertNotFound(); + + expect($this->shopApi->fresh()->name)->toBe('renamed') + ->and($this->blogWeb->fresh()->name)->toBe('blog-web'); + }); + + test('a member with a project grant cannot use a team-wide write token', function () { + $member = scopedTokenTestMember($this->team, [['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['write', 'deploy']]]); + + scopedTokenTestApi(scopedTokenTestToken($member, $this->team, ['read', 'write'], null), 'GET', '/api/v1/applications')->assertForbidden(); + scopedTokenTestApi(scopedTokenTestToken($member, $this->team, ['delete'], [['scope' => 'project', 'uuid' => $this->shop->uuid]]), 'GET', '/api/v1/applications')->assertForbidden(); + }); + + test('a member with a write grant can create project and environment shared variables', function () { + $member = scopedTokenTestMember($this->team, [['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['write']]]); + $token = scopedTokenTestToken($member, $this->team, ['read', 'write'], [['scope' => 'project', 'uuid' => $this->shop->uuid]]); + + scopedTokenTestApi($token, 'POST', "/api/v1/projects/{$this->shop->uuid}/envs", ['key' => 'PROJECT_KEY', 'value' => 'v'])->assertCreated(); + scopedTokenTestApi($token, 'POST', "/api/v1/projects/{$this->shop->uuid}/environments/{$this->shopProduction->uuid}/envs", ['key' => 'ENVIRONMENT_KEY', 'value' => 'v'])->assertCreated(); + scopedTokenTestApi($token, 'POST', "/api/v1/projects/{$this->blog->uuid}/envs", ['key' => 'OTHER_KEY', 'value' => 'v'])->assertNotFound(); + + expect(SharedEnvironmentVariable::query()->whereIn('key', ['PROJECT_KEY', 'ENVIRONMENT_KEY', 'OTHER_KEY'])->pluck('key')->all())->toEqualCanonicalizing(['PROJECT_KEY', 'ENVIRONMENT_KEY']); + }); + + test('a member with a read grant cannot create project or environment shared variables', function () { + $member = scopedTokenTestMember($this->team, [['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['read']]]); + $token = scopedTokenTestToken($member, $this->team, ['read'], [['scope' => 'project', 'uuid' => $this->shop->uuid]]); + $writeToken = scopedTokenTestToken($member, $this->team, ['read', 'write'], [['scope' => 'project', 'uuid' => $this->shop->uuid]]); + + foreach ([$token, $writeToken] as $candidate) { + scopedTokenTestApi($candidate, 'POST', "/api/v1/projects/{$this->shop->uuid}/envs", ['key' => 'PROJECT_KEY', 'value' => 'v'])->assertForbidden(); + scopedTokenTestApi($candidate, 'POST', "/api/v1/projects/{$this->shop->uuid}/environments/{$this->shopProduction->uuid}/envs", ['key' => 'ENVIRONMENT_KEY', 'value' => 'v'])->assertForbidden(); + } + + expect(SharedEnvironmentVariable::query()->whereIn('key', ['PROJECT_KEY', 'ENVIRONMENT_KEY'])->exists())->toBeFalse(); + }); +}); + +describe('mcp', function () { + test('a scoped token lists only scoped applications and does not get team-level tools', function () { + $token = scopedTokenTestToken($this->admin, $this->team, ['read'], [['scope' => 'project', 'uuid' => $this->shop->uuid]]); + + $listed = json_decode((string) scopedTokenTestMcp($token, 'tools/call', ['name' => 'list_applications', 'arguments' => (object) []])->json('result.content.0.text'), true); + $tools = scopedTokenTestMcp($token, 'tools/list')->json('result.tools.*.name'); + + expect(collect($listed['data'])->pluck('name')->all())->toEqualCanonicalizing(['shop-api', 'shop-worker']) + ->and($tools)->toContain('list_applications') + ->and($tools)->not->toContain('list_servers') + ->and($tools)->not->toContain('manage_private_key'); + expect(scopedTokenTestMcp($token, 'tools/call', ['name' => 'list_servers', 'arguments' => (object) []])->json('error'))->not->toBeNull(); + }); + + test('a team-wide token still gets team-level tools', function () { + $tools = scopedTokenTestMcp(scopedTokenTestToken($this->admin, $this->team, ['read'], null), 'tools/list')->json('result.tools.*.name'); + + expect($tools)->toContain('list_servers'); + }); +}); + +describe('token form', function () { + beforeEach(function () { + session(['currentTeam' => $this->team]); + }); + + test('creates a token limited to the selected project', function () { + $this->actingAs($this->admin); + + Livewire::test(ApiTokens::class) + ->set('description', 'shop deploys') + ->set('permissions', ['deploy']) + ->set('scopeMode', 'selected') + ->set('selectedScopes', ["project:{$this->shop->uuid}"]) + ->call('addNewToken') + ->assertHasNoErrors() + ->assertNotDispatched('error'); + + $token = PersonalAccessToken::query()->sole(); + expect($token->abilities)->toBe(['deploy']) + ->and($token->scopes)->toBe([['scope' => 'project', 'uuid' => $this->shop->uuid]]) + ->and((int) $token->team_id)->toBe($this->team->id); + }); + + test('rejects a scope from another team', function () { + $otherTeam = Team::factory()->create(); + $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]); + $this->actingAs($this->admin); + + Livewire::test(ApiTokens::class) + ->set('description', 'foreign') + ->set('permissions', ['read']) + ->set('scopeMode', 'selected') + ->set('selectedScopes', ["project:{$otherProject->uuid}"]) + ->call('addNewToken') + ->assertDispatched('error'); + + expect(PersonalAccessToken::query()->count())->toBe(0); + }); + + test('a member can create a token only within their grants', function () { + $member = scopedTokenTestMember($this->team, [['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['write']]]); + $this->actingAs($member); + + $form = Livewire::test(ApiTokens::class)->assertSet('scopeMode', 'selected'); + expect(collect($form->instance()->scopeOptions())->pluck('value')->filter(fn ($value) => str_starts_with($value, 'project:'))->values()->all()) + ->toBe(["project:{$this->shop->uuid}"]); + + $form->set('description', 'too wide') + ->set('permissions', ['write']) + ->set('scopeMode', 'team') + ->call('addNewToken') + ->assertDispatched('error'); + $form->set('description', 'blog') + ->set('scopeMode', 'selected') + ->set('selectedScopes', ["project:{$this->blog->uuid}"]) + ->call('addNewToken') + ->assertDispatched('error'); + $form->set('description', 'shop') + ->set('selectedScopes', ["project:{$this->shop->uuid}"]) + ->call('addNewToken'); + + expect($member->tokens()->pluck('name')->all())->toBe(['shop']); + }); +}); diff --git a/tests/Feature/Api/ServerDeleteFromProviderApiTest.php b/tests/Feature/Api/ServerDeleteFromProviderApiTest.php index 190b759488..5b974ef774 100644 --- a/tests/Feature/Api/ServerDeleteFromProviderApiTest.php +++ b/tests/Feature/Api/ServerDeleteFromProviderApiTest.php @@ -20,7 +20,7 @@ beforeEach(function () { $this->team->members()->attach($this->user->id, ['role' => 'owner']); session(['currentTeam' => $this->team]); - $newToken = $this->user->createToken('write-token', ['write']); + $newToken = $this->user->createToken('write-token', ['write', 'delete']); $newToken->accessToken->forceFill(['team_id' => $this->team->id])->save(); $this->token = $newToken->plainTextToken; }); diff --git a/tests/Feature/Api/ServerDockerRegistriesApiTest.php b/tests/Feature/Api/ServerDockerRegistriesApiTest.php index 7693387cd4..ff6226147d 100644 --- a/tests/Feature/Api/ServerDockerRegistriesApiTest.php +++ b/tests/Feature/Api/ServerDockerRegistriesApiTest.php @@ -42,7 +42,7 @@ beforeEach(function () { $this->server->settings->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]); $this->server->refresh(); - $this->writeToken = serverRegistriesApiToken($this->user, $this->team, ['read', 'write']); + $this->writeToken = serverRegistriesApiToken($this->user, $this->team, ['read', 'write', 'delete']); $this->sensitiveToken = serverRegistriesApiToken($this->user, $this->team, ['read', 'read:sensitive']); }); diff --git a/tests/Feature/Api/ServiceDeleteMessageApiTest.php b/tests/Feature/Api/ServiceDeleteMessageApiTest.php index 6cc34f4ffd..fdf83e8947 100644 --- a/tests/Feature/Api/ServiceDeleteMessageApiTest.php +++ b/tests/Feature/Api/ServiceDeleteMessageApiTest.php @@ -24,7 +24,7 @@ beforeEach(function () { $this->user = User::factory()->create(); $this->team->members()->attach($this->user->id, ['role' => 'owner']); session(['currentTeam' => $this->team]); - $token = $this->user->createToken('service-delete-message', ['write']); + $token = $this->user->createToken('service-delete-message', ['write', 'delete']); $token->accessToken->forceFill(['team_id' => $this->team->id])->save(); $this->bearerToken = $token->plainTextToken; diff --git a/tests/Feature/ApplicationDomainsTest.php b/tests/Feature/ApplicationDomainsTest.php index e157269ef8..97be266ad0 100644 --- a/tests/Feature/ApplicationDomainsTest.php +++ b/tests/Feature/ApplicationDomainsTest.php @@ -2099,6 +2099,16 @@ it('shows the compose service name in the domain conflict modal', function () { expect($html)->toContain('(application: web)'); }); +it('does not link a hidden resource in the domain conflict modal', function () { + $html = Blade::render( + '', + ['conflicts' => [hiddenDomainConflict('https://hidden-taken.example.com', 'application')]], + ); + + expect($html)->toContain('another resource') + ->not->toContain('href=""'); +}); + it('checks each domain configured for one docker compose service', function () { Application::factory()->create([ 'uuid' => (string) Str::uuid(), diff --git a/tests/Feature/Authorization/LegacyMemberTokenTest.php b/tests/Feature/Authorization/LegacyMemberTokenTest.php index c51f827ad3..9ae077c564 100644 --- a/tests/Feature/Authorization/LegacyMemberTokenTest.php +++ b/tests/Feature/Authorization/LegacyMemberTokenTest.php @@ -29,8 +29,8 @@ function apiRequest($test, string $token, string $method = 'get', string $url = } /** - * The api.token.team middleware rejects elevated abilities on member tokens - * before ApiAbility runs, so REST clients get its generic role message. + * The api.token.team middleware rejects member tokens with more access than the member has + * before ApiAbility runs. */ describe('member with legacy elevated token is rejected', function () { test('member with legacy elevated token gets 403', function (array $abilities) { @@ -39,7 +39,7 @@ describe('member with legacy elevated token is rejected', function () { $response = apiRequest($this, $token->plainTextToken); $response->assertStatus(403); - $response->assertExactJson(['message' => 'Missing required team role.']); + $response->assertExactJson(['message' => 'This API token has more access than you have in this team. Revoke it and create a new token.']); })->with([ 'write' => [['read', 'write']], 'deploy' => [['read', 'deploy']], @@ -55,7 +55,7 @@ describe('member with legacy elevated token is rejected', function () { $response = apiRequest($this, $token->plainTextToken, 'patch', '/api/v1/team'); $response->assertStatus(403); - $response->assertExactJson(['message' => 'Missing required team role.']); + $response->assertExactJson(['message' => 'This API token has more access than you have in this team. Revoke it and create a new token.']); }); }); diff --git a/tests/Feature/Mcp/McpApplicationToolsTest.php b/tests/Feature/Mcp/McpApplicationToolsTest.php index 00c4e4d6dc..eb03a2ca0c 100644 --- a/tests/Feature/Mcp/McpApplicationToolsTest.php +++ b/tests/Feature/Mcp/McpApplicationToolsTest.php @@ -72,7 +72,7 @@ function mcpAppCreateApplication(Environment $environment, StandaloneDocker $des return $application; } -function mcpAppToken(User $user, array $abilities = ['write']): string +function mcpAppToken(User $user, array $abilities = ['write', 'delete']): string { return $user->createToken('mcp-app', $abilities)->plainTextToken; } @@ -406,7 +406,7 @@ test('manage_application rejects read-only and member tokens', function () { // manage_preview and get_application_preview test('manage_preview creates, updates and deletes a preview of the token team', function () { - $writeToken = mcpAppToken($this->user, ['write']); + $writeToken = mcpAppToken($this->user, ['write', 'delete']); $deployToken = mcpAppToken($this->user, ['deploy']); $writeOnly = mcpAppCall($writeToken, 'manage_preview', ['action' => 'create', 'uuid' => $this->application->uuid, 'data' => ['pull_request_id' => 7, 'git_type' => 'github', 'instant_deploy' => false]]); diff --git a/tests/Feature/Mcp/McpDataToolsTest.php b/tests/Feature/Mcp/McpDataToolsTest.php index 6e14c17ff2..83779522bb 100644 --- a/tests/Feature/Mcp/McpDataToolsTest.php +++ b/tests/Feature/Mcp/McpDataToolsTest.php @@ -102,7 +102,7 @@ function mcpDataSeedTeam(Team $team): array ]; } -function mcpDataToken(User $user, array $abilities = ['write']): string +function mcpDataToken(User $user, array $abilities = ['write', 'delete']): string { return $user->createToken('mcp-data', $abilities)->plainTextToken; } diff --git a/tests/Feature/Mcp/McpOperationsToolsTest.php b/tests/Feature/Mcp/McpOperationsToolsTest.php index 193639dfe9..0c8eb0c23f 100644 --- a/tests/Feature/Mcp/McpOperationsToolsTest.php +++ b/tests/Feature/Mcp/McpOperationsToolsTest.php @@ -116,7 +116,7 @@ function mcpOpsSeedTeam(Team $team): array return compact('server', 'destination', 'project', 'environment', 'application', 'database', 'service', 'serviceApplication', 'serviceDatabase', 'volume', 's3'); } -function mcpOpsToken(User $user, array $abilities = ['read', 'write']): string +function mcpOpsToken(User $user, array $abilities = ['read', 'write', 'delete']): string { return $user->createToken('mcp-ops', $abilities)->plainTextToken; } diff --git a/tests/Feature/Mcp/McpReadToolsTest.php b/tests/Feature/Mcp/McpReadToolsTest.php index 155941ffd7..c24f58488e 100644 --- a/tests/Feature/Mcp/McpReadToolsTest.php +++ b/tests/Feature/Mcp/McpReadToolsTest.php @@ -1634,7 +1634,7 @@ test('team members cannot retrieve logs with sensitive read ability', function ( // Elevated member tokens are rejected as JSON-RPC errors (HTTP 200) so MCP clients can parse them. $response->assertOk(); expect($response->json('error.message') ?? $response->json('result.content.0.text') ?? '') - ->toMatch('/team role|Missing required/i'); + ->toMatch('/more access than you have/'); }); test('get_logs returns structured next_tools when application is not running', function () { @@ -1811,7 +1811,7 @@ test('team member with deploy ability cannot call lifecycle tools', function () $response->assertOk(); expect($response->json('jsonrpc'))->toBe('2.0'); expect($response->json('error.message') ?? $response->json('result.content.0.text') ?? '') - ->toMatch('/team role|Missing required/i'); + ->toMatch('/more access than you have/'); }); test('control start with deploy ability queues application deployment', function () { diff --git a/tests/Feature/Mcp/McpServerToolsTest.php b/tests/Feature/Mcp/McpServerToolsTest.php index ca962a5391..db28413866 100644 --- a/tests/Feature/Mcp/McpServerToolsTest.php +++ b/tests/Feature/Mcp/McpServerToolsTest.php @@ -108,7 +108,7 @@ afterEach(function () { Server::flushIdentityMap(); }); -function mcpSrvToken(User $user, array $abilities = ['read', 'write']): string +function mcpSrvToken(User $user, array $abilities = ['read', 'write', 'delete']): string { return $user->createToken('mcp-srv', $abilities)->plainTextToken; } @@ -205,7 +205,7 @@ test('server group write actions reject member tokens', function (string $tool, $response = mcpSrvCall(mcpSrvToken($member, ['read', 'write']), $tool, $arguments + ['confirm' => true]); expect($response->json('result'))->toBeNull() - ->and($response->json('error.message'))->toBe('Missing required team role.'); + ->and($response->json('error.message'))->toBe('This API token has more access than you have in this team. Revoke it and create a new token.'); })->with([ 'manage_server update' => ['manage_server', ['action' => 'update', 'data' => ['name' => 'x']]], 'manage_private_key create' => ['manage_private_key', ['action' => 'create']], @@ -602,7 +602,7 @@ test('manage_git_source cannot change or delete GitHub apps of another team, inc ])->with(['update', 'delete']); test('manage_git_source creates, updates, and deletes a GitLab app without returning secrets', function () { - $token = mcpSrvToken($this->user, ['read', 'write']); + $token = mcpSrvToken($this->user, ['read', 'write', 'delete']); $create = mcpSrvCall($token, 'manage_git_source', ['action' => 'create', 'resource' => 'gitlab', 'data' => [ 'name' => 'mcp-gitlab', 'html_url' => 'https://gitlab.com', 'client_secret' => 'GL_CLIENT_SECRET_VALUE', 'webhook_token' => 'GL_WEBHOOK_TOKEN_VALUE', diff --git a/tests/Feature/Mcp/McpWriteToolsTest.php b/tests/Feature/Mcp/McpWriteToolsTest.php index 3db6c4ff08..02ce354f75 100644 --- a/tests/Feature/Mcp/McpWriteToolsTest.php +++ b/tests/Feature/Mcp/McpWriteToolsTest.php @@ -28,7 +28,7 @@ beforeEach(function () { session(['currentTeam' => $this->team]); }); -function mcpWriteToken(User $user, array $abilities = ['write']): string +function mcpWriteToken(User $user, array $abilities = ['write', 'delete']): string { return $user->createToken('mcp-write', $abilities)->plainTextToken; } diff --git a/tests/Feature/ServerDeleteFailsQueuedDeploymentsTest.php b/tests/Feature/ServerDeleteFailsQueuedDeploymentsTest.php index 16c5b49786..d6ace16946 100644 --- a/tests/Feature/ServerDeleteFailsQueuedDeploymentsTest.php +++ b/tests/Feature/ServerDeleteFailsQueuedDeploymentsTest.php @@ -59,7 +59,7 @@ afterEach(function () { test('deleting a server fails its queued deployments', function (string $path) { if ($path === 'api') { - $token = $this->user->createToken('server-delete', ['write'])->plainTextToken; + $token = $this->user->createToken('server-delete', ['write', 'delete'])->plainTextToken; $this->withHeaders(['Authorization' => 'Bearer '.$token]) ->deleteJson("/api/v1/servers/{$this->server->uuid}?force=true") ->assertOk(); diff --git a/tests/Unit/Policies/ApiTokenPolicyTest.php b/tests/Unit/Policies/ApiTokenPolicyTest.php index 055297b3cd..52e2ee67b7 100644 --- a/tests/Unit/Policies/ApiTokenPolicyTest.php +++ b/tests/Unit/Policies/ApiTokenPolicyTest.php @@ -159,6 +159,7 @@ it('denies member from using root permissions', function () { $user = Mockery::mock(User::class)->makePartial(); $user->shouldReceive('isAdmin')->andReturn(false); $user->shouldReceive('isOwner')->andReturn(false); + $user->shouldReceive('currentTeam')->andReturnNull(); $policy = new ApiTokenPolicy; expect($policy->useRootPermissions($user))->toBeFalse(); @@ -176,6 +177,7 @@ it('denies member from using write permissions', function () { $user = Mockery::mock(User::class)->makePartial(); $user->shouldReceive('isAdmin')->andReturn(false); $user->shouldReceive('isOwner')->andReturn(false); + $user->shouldReceive('currentTeam')->andReturnNull(); $policy = new ApiTokenPolicy; expect($policy->useWritePermissions($user))->toBeFalse(); @@ -202,6 +204,7 @@ it('denies member from using deploy permissions', function () { $user = Mockery::mock(User::class)->makePartial(); $user->shouldReceive('isAdmin')->andReturn(false); $user->shouldReceive('isOwner')->andReturn(false); + $user->shouldReceive('currentTeam')->andReturnNull(); $policy = new ApiTokenPolicy; expect($policy->useDeployPermissions($user))->toBeFalse(); @@ -228,6 +231,7 @@ it('denies member from using sensitive permissions', function () { $user = Mockery::mock(User::class)->makePartial(); $user->shouldReceive('isAdmin')->andReturn(false); $user->shouldReceive('isOwner')->andReturn(false); + $user->shouldReceive('currentTeam')->andReturnNull(); $policy = new ApiTokenPolicy; expect($policy->useSensitivePermissions($user))->toBeFalse(); diff --git a/tests/Unit/Policies/ApplicationPolicyTest.php b/tests/Unit/Policies/ApplicationPolicyTest.php index 04d37bbc99..25a1af3957 100644 --- a/tests/Unit/Policies/ApplicationPolicyTest.php +++ b/tests/Unit/Policies/ApplicationPolicyTest.php @@ -1,8 +1,16 @@ forgetInstance(AccessResolver::class); +}); it('allows any user to view any applications', function () { $user = Mockery::mock(User::class)->makePartial(); @@ -12,53 +20,45 @@ it('allows any user to view any applications', function () { }); it('allows team member to view their own team application', function () { - $teams = collect([ - (object) ['id' => 1], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Read, $application)->andReturn(true); $policy = new ApplicationPolicy; expect($policy->view($user, $application))->toBeTrue(); }); it('denies non-member to view another team application', function () { - $teams = collect([ - (object) ['id' => 1], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn((object) ['id' => 2]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Read, $application)->andReturn(false); $policy = new ApplicationPolicy; expect($policy->view($user, $application))->toBeFalse(); }); it('denies view when application has no team', function () { - $teams = collect([ - (object) ['id' => 1], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - $application = Mockery::mock(Application::class)->makePartial(); $application->shouldReceive('team')->andReturn(null); + $user->shouldReceive('canAccess')->with(AccessLevel::Read, $application)->andReturn(false); + $policy = new ApplicationPolicy; expect($policy->view($user, $application))->toBeFalse(); }); it('allows admin to create an application', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdmin')->andReturn(true); + $team = new Team; + $team->id = 1; + $user->shouldReceive('currentTeam')->andReturn($team); + + $resolver = Mockery::mock(AccessResolver::class); + $resolver->shouldReceive('allowsTeamWide')->with($user, AccessLevel::Write, 1)->andReturn(true); + Container::getInstance()->instance(AccessResolver::class, $resolver); $policy = new ApplicationPolicy; expect($policy->create($user))->toBeTrue(); @@ -66,7 +66,13 @@ it('allows admin to create an application', function () { it('denies member to create an application', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdmin')->andReturn(false); + $team = new Team; + $team->id = 1; + $user->shouldReceive('currentTeam')->andReturn($team); + + $resolver = Mockery::mock(AccessResolver::class); + $resolver->shouldReceive('allowsTeamWide')->with($user, AccessLevel::Write, 1)->andReturn(false); + Container::getInstance()->instance(AccessResolver::class, $resolver); $policy = new ApplicationPolicy; expect($policy->create($user))->toBeFalse(); @@ -74,10 +80,9 @@ it('denies member to create an application', function () { it('allows team admin to update their own team application', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $application)->andReturn(true); $policy = new ApplicationPolicy; expect($policy->update($user, $application)->allowed())->toBeTrue(); @@ -85,10 +90,9 @@ it('allows team admin to update their own team application', function () { it('denies team member to update their own team application', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $application)->andReturn(false); $policy = new ApplicationPolicy; expect($policy->update($user, $application)->allowed())->toBeFalse(); @@ -100,16 +104,17 @@ it('denies update when application has no team', function () { $application = Mockery::mock(Application::class)->makePartial(); $application->shouldReceive('team')->andReturn(null); + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $application)->andReturn(false); + $policy = new ApplicationPolicy; expect($policy->update($user, $application)->allowed())->toBeFalse(); }); it('allows team admin to delete their own team application', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $application)->andReturn(true); $policy = new ApplicationPolicy; expect($policy->delete($user, $application))->toBeTrue(); @@ -117,10 +122,9 @@ it('allows team admin to delete their own team application', function () { it('denies team member to delete their own team application', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $application)->andReturn(false); $policy = new ApplicationPolicy; expect($policy->delete($user, $application))->toBeFalse(); @@ -132,16 +136,17 @@ it('denies delete when application has no team', function () { $application = Mockery::mock(Application::class)->makePartial(); $application->shouldReceive('team')->andReturn(null); + $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $application)->andReturn(false); + $policy = new ApplicationPolicy; expect($policy->delete($user, $application))->toBeFalse(); }); it('allows team admin to deploy their own team application', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Deploy, $application)->andReturn(true); $policy = new ApplicationPolicy; expect($policy->deploy($user, $application))->toBeTrue(); @@ -149,10 +154,9 @@ it('allows team admin to deploy their own team application', function () { it('denies team member to deploy their own team application', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Deploy, $application)->andReturn(false); $policy = new ApplicationPolicy; expect($policy->deploy($user, $application))->toBeFalse(); @@ -160,10 +164,9 @@ it('denies team member to deploy their own team application', function () { it('allows team admin to manage environment variables', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $application)->andReturn(true); $policy = new ApplicationPolicy; expect($policy->manageEnvironment($user, $application))->toBeTrue(); @@ -171,10 +174,9 @@ it('allows team admin to manage environment variables', function () { it('denies team member to manage environment variables', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $application)->andReturn(false); $policy = new ApplicationPolicy; expect($policy->manageEnvironment($user, $application))->toBeFalse(); diff --git a/tests/Unit/Policies/ApplicationPreviewPolicyTest.php b/tests/Unit/Policies/ApplicationPreviewPolicyTest.php index 8e295406c2..83ff0d8149 100644 --- a/tests/Unit/Policies/ApplicationPreviewPolicyTest.php +++ b/tests/Unit/Policies/ApplicationPreviewPolicyTest.php @@ -1,5 +1,6 @@ 1, 'pivot' => (object) ['role' => 'member']], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - - $team = (object) ['id' => 1]; $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn($team); $preview = Mockery::mock(ApplicationPreview::class)->makePartial(); $preview->shouldReceive('getAttribute')->with('application')->andReturn($application); + $user->shouldReceive('canAccess')->with(AccessLevel::Read, $application)->andReturn(true); + $policy = new ApplicationPreviewPolicy; expect($policy->view($user, $preview))->toBeTrue(); }); it('denies non-team member from viewing application preview', function () { - $teams = collect([ - (object) ['id' => 2, 'pivot' => (object) ['role' => 'member']], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - - $team = (object) ['id' => 1]; $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn($team); $preview = Mockery::mock(ApplicationPreview::class)->makePartial(); $preview->shouldReceive('getAttribute')->with('application')->andReturn($application); + $user->shouldReceive('canAccess')->with(AccessLevel::Read, $application)->andReturn(false); + $policy = new ApplicationPreviewPolicy; expect($policy->view($user, $preview))->toBeFalse(); }); it('denies viewing application preview with null application', function () { - $teams = collect([ - (object) ['id' => 1, 'pivot' => (object) ['role' => 'admin']], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - $preview = Mockery::mock(ApplicationPreview::class)->makePartial(); $preview->shouldReceive('getAttribute')->with('application')->andReturn(null); @@ -83,30 +66,26 @@ it('denies non-admin user from creating application preview', function () { it('allows team admin to update application preview', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - - $team = (object) ['id' => 1]; $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn($team); $preview = Mockery::mock(ApplicationPreview::class)->makePartial(); $preview->shouldReceive('getAttribute')->with('application')->andReturn($application); + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $application)->andReturn(true); + $policy = new ApplicationPreviewPolicy; expect($policy->update($user, $preview)->allowed())->toBeTrue(); }); it('denies team member from updating application preview', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - - $team = (object) ['id' => 1]; $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn($team); $preview = Mockery::mock(ApplicationPreview::class)->makePartial(); $preview->shouldReceive('getAttribute')->with('application')->andReturn($application); + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $application)->andReturn(false); + $policy = new ApplicationPreviewPolicy; expect($policy->update($user, $preview)->allowed())->toBeFalse(); }); @@ -124,30 +103,26 @@ it('denies updating application preview with null application', function () { it('allows team admin to delete application preview', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - - $team = (object) ['id' => 1]; $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn($team); $preview = Mockery::mock(ApplicationPreview::class)->makePartial(); $preview->shouldReceive('getAttribute')->with('application')->andReturn($application); + $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $application)->andReturn(true); + $policy = new ApplicationPreviewPolicy; expect($policy->delete($user, $preview))->toBeTrue(); }); it('denies team member from deleting application preview', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - - $team = (object) ['id' => 1]; $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn($team); $preview = Mockery::mock(ApplicationPreview::class)->makePartial(); $preview->shouldReceive('getAttribute')->with('application')->andReturn($application); + $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $application)->andReturn(false); + $policy = new ApplicationPreviewPolicy; expect($policy->delete($user, $preview))->toBeFalse(); }); @@ -164,30 +139,26 @@ it('denies deleting application preview with null application', function () { it('allows team admin to deploy application preview', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - - $team = (object) ['id' => 1]; $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn($team); $preview = Mockery::mock(ApplicationPreview::class)->makePartial(); $preview->shouldReceive('getAttribute')->with('application')->andReturn($application); + $user->shouldReceive('canAccess')->with(AccessLevel::Deploy, $application)->andReturn(true); + $policy = new ApplicationPreviewPolicy; expect($policy->deploy($user, $preview))->toBeTrue(); }); it('denies team member from deploying application preview', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - - $team = (object) ['id' => 1]; $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn($team); $preview = Mockery::mock(ApplicationPreview::class)->makePartial(); $preview->shouldReceive('getAttribute')->with('application')->andReturn($application); + $user->shouldReceive('canAccess')->with(AccessLevel::Deploy, $application)->andReturn(false); + $policy = new ApplicationPreviewPolicy; expect($policy->deploy($user, $preview))->toBeFalse(); }); diff --git a/tests/Unit/Policies/ApplicationSettingPolicyTest.php b/tests/Unit/Policies/ApplicationSettingPolicyTest.php index c595a3e960..ece6e8a716 100644 --- a/tests/Unit/Policies/ApplicationSettingPolicyTest.php +++ b/tests/Unit/Policies/ApplicationSettingPolicyTest.php @@ -1,5 +1,6 @@ 1, 'pivot' => (object) ['role' => 'member']], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - - $team = (object) ['id' => 1]; $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn($team); $setting = Mockery::mock(ApplicationSetting::class)->makePartial(); $setting->shouldReceive('getAttribute')->with('application')->andReturn($application); + $user->shouldReceive('canAccess')->with(AccessLevel::Read, $application)->andReturn(true); + $policy = new ApplicationSettingPolicy; expect($policy->view($user, $setting))->toBeTrue(); }); it('denies non-team member from viewing application setting', function () { - $teams = collect([ - (object) ['id' => 2, 'pivot' => (object) ['role' => 'member']], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - - $team = (object) ['id' => 1]; $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn($team); $setting = Mockery::mock(ApplicationSetting::class)->makePartial(); $setting->shouldReceive('getAttribute')->with('application')->andReturn($application); + $user->shouldReceive('canAccess')->with(AccessLevel::Read, $application)->andReturn(false); + $policy = new ApplicationSettingPolicy; expect($policy->view($user, $setting))->toBeFalse(); }); it('denies viewing application setting with null application', function () { - $teams = collect([ - (object) ['id' => 1, 'pivot' => (object) ['role' => 'admin']], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - $setting = Mockery::mock(ApplicationSetting::class)->makePartial(); $setting->shouldReceive('getAttribute')->with('application')->andReturn(null); @@ -83,30 +66,26 @@ it('denies non-admin user from creating application setting', function () { it('allows team admin to update application setting', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - - $team = (object) ['id' => 1]; $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn($team); $setting = Mockery::mock(ApplicationSetting::class)->makePartial(); $setting->shouldReceive('getAttribute')->with('application')->andReturn($application); + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $application)->andReturn(true); + $policy = new ApplicationSettingPolicy; expect($policy->update($user, $setting))->toBeTrue(); }); it('denies team member from updating application setting', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - - $team = (object) ['id' => 1]; $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn($team); $setting = Mockery::mock(ApplicationSetting::class)->makePartial(); $setting->shouldReceive('getAttribute')->with('application')->andReturn($application); + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $application)->andReturn(false); + $policy = new ApplicationSettingPolicy; expect($policy->update($user, $setting))->toBeFalse(); }); @@ -123,30 +102,26 @@ it('denies updating application setting with null application', function () { it('allows team admin to delete application setting', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - - $team = (object) ['id' => 1]; $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn($team); $setting = Mockery::mock(ApplicationSetting::class)->makePartial(); $setting->shouldReceive('getAttribute')->with('application')->andReturn($application); + $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $application)->andReturn(true); + $policy = new ApplicationSettingPolicy; expect($policy->delete($user, $setting))->toBeTrue(); }); it('denies team member from deleting application setting', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - - $team = (object) ['id' => 1]; $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn($team); $setting = Mockery::mock(ApplicationSetting::class)->makePartial(); $setting->shouldReceive('getAttribute')->with('application')->andReturn($application); + $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $application)->andReturn(false); + $policy = new ApplicationSettingPolicy; expect($policy->delete($user, $setting))->toBeFalse(); }); diff --git a/tests/Unit/Policies/DatabasePolicyTest.php b/tests/Unit/Policies/DatabasePolicyTest.php index 9924a43cb2..7bf05609fe 100644 --- a/tests/Unit/Policies/DatabasePolicyTest.php +++ b/tests/Unit/Policies/DatabasePolicyTest.php @@ -1,8 +1,16 @@ forgetInstance(AccessResolver::class); +}); it('allows any user to view any databases', function () { $user = Mockery::mock(User::class)->makePartial(); @@ -12,53 +20,45 @@ it('allows any user to view any databases', function () { }); it('allows team member to view their own team database', function () { - $teams = collect([ - (object) ['id' => 1], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - $database = Mockery::mock(StandalonePostgresql::class)->makePartial(); - $database->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Read, $database)->andReturn(true); $policy = new DatabasePolicy; expect($policy->view($user, $database))->toBeTrue(); }); it('denies non-member to view another team database', function () { - $teams = collect([ - (object) ['id' => 1], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - $database = Mockery::mock(StandalonePostgresql::class)->makePartial(); - $database->shouldReceive('team')->andReturn((object) ['id' => 2]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Read, $database)->andReturn(false); $policy = new DatabasePolicy; expect($policy->view($user, $database))->toBeFalse(); }); it('denies view when database has no team', function () { - $teams = collect([ - (object) ['id' => 1], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - $database = Mockery::mock(StandalonePostgresql::class)->makePartial(); $database->shouldReceive('team')->andReturn(null); + $user->shouldReceive('canAccess')->with(AccessLevel::Read, $database)->andReturn(false); + $policy = new DatabasePolicy; expect($policy->view($user, $database))->toBeFalse(); }); it('allows admin to create a database', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdmin')->andReturn(true); + $team = new Team; + $team->id = 1; + $user->shouldReceive('currentTeam')->andReturn($team); + + $resolver = Mockery::mock(AccessResolver::class); + $resolver->shouldReceive('allowsTeamWide')->with($user, AccessLevel::Write, 1)->andReturn(true); + Container::getInstance()->instance(AccessResolver::class, $resolver); $policy = new DatabasePolicy; expect($policy->create($user))->toBeTrue(); @@ -66,7 +66,13 @@ it('allows admin to create a database', function () { it('denies member to create a database', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdmin')->andReturn(false); + $team = new Team; + $team->id = 1; + $user->shouldReceive('currentTeam')->andReturn($team); + + $resolver = Mockery::mock(AccessResolver::class); + $resolver->shouldReceive('allowsTeamWide')->with($user, AccessLevel::Write, 1)->andReturn(false); + Container::getInstance()->instance(AccessResolver::class, $resolver); $policy = new DatabasePolicy; expect($policy->create($user))->toBeFalse(); @@ -74,10 +80,9 @@ it('denies member to create a database', function () { it('allows team admin to update their own team database', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $database = Mockery::mock(StandalonePostgresql::class)->makePartial(); - $database->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $database)->andReturn(true); $policy = new DatabasePolicy; expect($policy->update($user, $database)->allowed())->toBeTrue(); @@ -85,10 +90,9 @@ it('allows team admin to update their own team database', function () { it('denies team member to update their own team database', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $database = Mockery::mock(StandalonePostgresql::class)->makePartial(); - $database->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $database)->andReturn(false); $policy = new DatabasePolicy; expect($policy->update($user, $database)->allowed())->toBeFalse(); @@ -100,16 +104,17 @@ it('denies update when database has no team', function () { $database = Mockery::mock(StandalonePostgresql::class)->makePartial(); $database->shouldReceive('team')->andReturn(null); + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $database)->andReturn(false); + $policy = new DatabasePolicy; expect($policy->update($user, $database)->allowed())->toBeFalse(); }); it('allows team admin to delete their own team database', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $database = Mockery::mock(StandalonePostgresql::class)->makePartial(); - $database->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $database)->andReturn(true); $policy = new DatabasePolicy; expect($policy->delete($user, $database))->toBeTrue(); @@ -117,10 +122,9 @@ it('allows team admin to delete their own team database', function () { it('denies team member to delete their own team database', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $database = Mockery::mock(StandalonePostgresql::class)->makePartial(); - $database->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $database)->andReturn(false); $policy = new DatabasePolicy; expect($policy->delete($user, $database))->toBeFalse(); @@ -132,16 +136,17 @@ it('denies delete when database has no team', function () { $database = Mockery::mock(StandalonePostgresql::class)->makePartial(); $database->shouldReceive('team')->andReturn(null); + $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $database)->andReturn(false); + $policy = new DatabasePolicy; expect($policy->delete($user, $database))->toBeFalse(); }); it('allows team admin to manage their own team database', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $database = Mockery::mock(StandalonePostgresql::class)->makePartial(); - $database->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $database)->andReturn(true); $policy = new DatabasePolicy; expect($policy->manage($user, $database))->toBeTrue(); @@ -149,10 +154,9 @@ it('allows team admin to manage their own team database', function () { it('denies team member to manage their own team database', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $database = Mockery::mock(StandalonePostgresql::class)->makePartial(); - $database->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $database)->andReturn(false); $policy = new DatabasePolicy; expect($policy->manage($user, $database))->toBeFalse(); @@ -160,10 +164,9 @@ it('denies team member to manage their own team database', function () { it('allows team admin to manage backups', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $database = Mockery::mock(StandalonePostgresql::class)->makePartial(); - $database->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $database)->andReturn(true); $policy = new DatabasePolicy; expect($policy->manageBackups($user, $database))->toBeTrue(); @@ -171,10 +174,9 @@ it('allows team admin to manage backups', function () { it('denies team member to manage backups', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $database = Mockery::mock(StandalonePostgresql::class)->makePartial(); - $database->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $database)->andReturn(false); $policy = new DatabasePolicy; expect($policy->manageBackups($user, $database))->toBeFalse(); @@ -182,10 +184,9 @@ it('denies team member to manage backups', function () { it('allows team admin to manage database environment variables', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $database = Mockery::mock(StandalonePostgresql::class)->makePartial(); - $database->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $database)->andReturn(true); $policy = new DatabasePolicy; expect($policy->manageEnvironment($user, $database))->toBeTrue(); @@ -193,10 +194,9 @@ it('allows team admin to manage database environment variables', function () { it('denies team member to manage database environment variables', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $database = Mockery::mock(StandalonePostgresql::class)->makePartial(); - $database->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $database)->andReturn(false); $policy = new DatabasePolicy; expect($policy->manageEnvironment($user, $database))->toBeFalse(); diff --git a/tests/Unit/Policies/EnvironmentPolicyTest.php b/tests/Unit/Policies/EnvironmentPolicyTest.php index 3fe8f17038..22c4ba86d8 100644 --- a/tests/Unit/Policies/EnvironmentPolicyTest.php +++ b/tests/Unit/Policies/EnvironmentPolicyTest.php @@ -1,8 +1,16 @@ forgetInstance(AccessResolver::class); +}); it('allows any user to view any environments', function () { $user = Mockery::mock(User::class)->makePartial(); @@ -12,53 +20,49 @@ it('allows any user to view any environments', function () { }); it('allows team member to view their own team environment', function () { - $teams = collect([ - (object) ['id' => 1], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - $environment = Mockery::mock(Environment::class)->makePartial(); - $environment->shouldReceive('getAttribute')->with('project')->andReturn((object) ['team_id' => 1]); + + $resolver = Mockery::mock(AccessResolver::class); + $resolver->shouldReceive('canOpen')->with($user, $environment)->andReturn(true); + Container::getInstance()->instance(AccessResolver::class, $resolver); $policy = new EnvironmentPolicy; expect($policy->view($user, $environment))->toBeTrue(); }); it('denies non-member to view another team environment', function () { - $teams = collect([ - (object) ['id' => 1], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - $environment = Mockery::mock(Environment::class)->makePartial(); - $environment->shouldReceive('getAttribute')->with('project')->andReturn((object) ['team_id' => 2]); + + $resolver = Mockery::mock(AccessResolver::class); + $resolver->shouldReceive('canOpen')->with($user, $environment)->andReturn(false); + Container::getInstance()->instance(AccessResolver::class, $resolver); $policy = new EnvironmentPolicy; expect($policy->view($user, $environment))->toBeFalse(); }); it('denies view when environment has no project', function () { - $teams = collect([ - (object) ['id' => 1], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - $environment = Mockery::mock(Environment::class)->makePartial(); $environment->shouldReceive('getAttribute')->with('project')->andReturn(null); + $user->shouldReceive('canAccess')->with(AccessLevel::Read, $environment)->andReturn(false); + $policy = new EnvironmentPolicy; expect($policy->view($user, $environment))->toBeFalse(); }); it('allows admin to create an environment', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdmin')->andReturn(true); + $team = new Team; + $team->id = 1; + $user->shouldReceive('currentTeam')->andReturn($team); + + $resolver = Mockery::mock(AccessResolver::class); + $resolver->shouldReceive('allowsTeamWide')->with($user, AccessLevel::Write, 1)->andReturn(true); + Container::getInstance()->instance(AccessResolver::class, $resolver); $policy = new EnvironmentPolicy; expect($policy->create($user))->toBeTrue(); @@ -66,7 +70,13 @@ it('allows admin to create an environment', function () { it('denies member to create an environment', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdmin')->andReturn(false); + $team = new Team; + $team->id = 1; + $user->shouldReceive('currentTeam')->andReturn($team); + + $resolver = Mockery::mock(AccessResolver::class); + $resolver->shouldReceive('allowsTeamWide')->with($user, AccessLevel::Write, 1)->andReturn(false); + Container::getInstance()->instance(AccessResolver::class, $resolver); $policy = new EnvironmentPolicy; expect($policy->create($user))->toBeFalse(); @@ -74,10 +84,9 @@ it('denies member to create an environment', function () { it('allows team admin to update their own team environment', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $environment = Mockery::mock(Environment::class)->makePartial(); - $environment->shouldReceive('getAttribute')->with('project')->andReturn((object) ['team_id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $environment)->andReturn(true); $policy = new EnvironmentPolicy; expect($policy->update($user, $environment))->toBeTrue(); @@ -85,10 +94,9 @@ it('allows team admin to update their own team environment', function () { it('denies team member to update their own team environment', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $environment = Mockery::mock(Environment::class)->makePartial(); - $environment->shouldReceive('getAttribute')->with('project')->andReturn((object) ['team_id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $environment)->andReturn(false); $policy = new EnvironmentPolicy; expect($policy->update($user, $environment))->toBeFalse(); @@ -100,16 +108,17 @@ it('denies update when environment has no project', function () { $environment = Mockery::mock(Environment::class)->makePartial(); $environment->shouldReceive('getAttribute')->with('project')->andReturn(null); + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $environment)->andReturn(false); + $policy = new EnvironmentPolicy; expect($policy->update($user, $environment))->toBeFalse(); }); it('allows team admin to delete their own team environment', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $environment = Mockery::mock(Environment::class)->makePartial(); - $environment->shouldReceive('getAttribute')->with('project')->andReturn((object) ['team_id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $environment)->andReturn(true); $policy = new EnvironmentPolicy; expect($policy->delete($user, $environment))->toBeTrue(); @@ -117,10 +126,9 @@ it('allows team admin to delete their own team environment', function () { it('denies team member to delete their own team environment', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $environment = Mockery::mock(Environment::class)->makePartial(); - $environment->shouldReceive('getAttribute')->with('project')->andReturn((object) ['team_id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $environment)->andReturn(false); $policy = new EnvironmentPolicy; expect($policy->delete($user, $environment))->toBeFalse(); @@ -132,6 +140,8 @@ it('denies delete when environment has no project', function () { $environment = Mockery::mock(Environment::class)->makePartial(); $environment->shouldReceive('getAttribute')->with('project')->andReturn(null); + $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $environment)->andReturn(false); + $policy = new EnvironmentPolicy; expect($policy->delete($user, $environment))->toBeFalse(); }); diff --git a/tests/Unit/Policies/EnvironmentVariablePolicyTest.php b/tests/Unit/Policies/EnvironmentVariablePolicyTest.php index d5d3ec410a..947677573e 100644 --- a/tests/Unit/Policies/EnvironmentVariablePolicyTest.php +++ b/tests/Unit/Policies/EnvironmentVariablePolicyTest.php @@ -4,7 +4,13 @@ use App\Models\Application; use App\Models\EnvironmentVariable; use App\Models\User; use App\Policies\EnvironmentVariablePolicy; +use Illuminate\Contracts\Auth\Access\Gate as GateContract; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Gate; + +afterEach(function () { + Gate::clearResolvedInstance('gate'); +}); it('allows any user to view any environment variables', function () { $user = Mockery::mock(User::class)->makePartial(); @@ -15,28 +21,28 @@ it('allows any user to view any environment variables', function () { it('allows team member to view environment variable', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->teams = new Collection([(object) ['id' => 1]]); - $resource = Mockery::mock(Application::class)->makePartial(); - $resource->shouldReceive('team')->andReturn((object) ['id' => 1]); - $envVar = Mockery::mock(EnvironmentVariable::class)->makePartial(); $envVar->shouldReceive('getAttribute')->with('resourceable')->andReturn($resource); + $gate = Mockery::mock(GateContract::class); + $gate->shouldReceive('allows')->with('view', $resource)->andReturn(true); + Gate::shouldReceive('forUser')->with($user)->andReturn($gate); + $policy = new EnvironmentVariablePolicy; expect($policy->view($user, $envVar))->toBeTrue(); }); it('denies non-team member from viewing environment variable', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->teams = new Collection([(object) ['id' => 2]]); - $resource = Mockery::mock(Application::class)->makePartial(); - $resource->shouldReceive('team')->andReturn((object) ['id' => 1]); - $envVar = Mockery::mock(EnvironmentVariable::class)->makePartial(); $envVar->shouldReceive('getAttribute')->with('resourceable')->andReturn($resource); + $gate = Mockery::mock(GateContract::class); + $gate->shouldReceive('allows')->with('view', $resource)->andReturn(false); + Gate::shouldReceive('forUser')->with($user)->andReturn($gate); + $policy = new EnvironmentVariablePolicy; expect($policy->view($user, $envVar))->toBeFalse(); }); @@ -70,28 +76,28 @@ it('denies member from creating environment variable', function () { it('allows team admin to update environment variable', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $resource = Mockery::mock(Application::class)->makePartial(); - $resource->shouldReceive('team')->andReturn((object) ['id' => 1]); - $envVar = Mockery::mock(EnvironmentVariable::class)->makePartial(); $envVar->shouldReceive('getAttribute')->with('resourceable')->andReturn($resource); + $gate = Mockery::mock(GateContract::class); + $gate->shouldReceive('allows')->with('manageEnvironment', $resource)->andReturn(true); + Gate::shouldReceive('forUser')->with($user)->andReturn($gate); + $policy = new EnvironmentVariablePolicy; expect($policy->update($user, $envVar))->toBeTrue(); }); it('denies non-admin from updating environment variable', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $resource = Mockery::mock(Application::class)->makePartial(); - $resource->shouldReceive('team')->andReturn((object) ['id' => 1]); - $envVar = Mockery::mock(EnvironmentVariable::class)->makePartial(); $envVar->shouldReceive('getAttribute')->with('resourceable')->andReturn($resource); + $gate = Mockery::mock(GateContract::class); + $gate->shouldReceive('allows')->with('manageEnvironment', $resource)->andReturn(false); + Gate::shouldReceive('forUser')->with($user)->andReturn($gate); + $policy = new EnvironmentVariablePolicy; expect($policy->update($user, $envVar))->toBeFalse(); }); @@ -108,28 +114,28 @@ it('denies update when resourceable is null', function () { it('allows team admin to delete environment variable', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $resource = Mockery::mock(Application::class)->makePartial(); - $resource->shouldReceive('team')->andReturn((object) ['id' => 1]); - $envVar = Mockery::mock(EnvironmentVariable::class)->makePartial(); $envVar->shouldReceive('getAttribute')->with('resourceable')->andReturn($resource); + $gate = Mockery::mock(GateContract::class); + $gate->shouldReceive('allows')->with('manageEnvironment', $resource)->andReturn(true); + Gate::shouldReceive('forUser')->with($user)->andReturn($gate); + $policy = new EnvironmentVariablePolicy; expect($policy->delete($user, $envVar))->toBeTrue(); }); it('denies non-admin from deleting environment variable', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $resource = Mockery::mock(Application::class)->makePartial(); - $resource->shouldReceive('team')->andReturn((object) ['id' => 1]); - $envVar = Mockery::mock(EnvironmentVariable::class)->makePartial(); $envVar->shouldReceive('getAttribute')->with('resourceable')->andReturn($resource); + $gate = Mockery::mock(GateContract::class); + $gate->shouldReceive('allows')->with('manageEnvironment', $resource)->andReturn(false); + Gate::shouldReceive('forUser')->with($user)->andReturn($gate); + $policy = new EnvironmentVariablePolicy; expect($policy->delete($user, $envVar))->toBeFalse(); }); @@ -162,28 +168,28 @@ it('denies force delete for environment variable', function () { it('allows team admin to manage environment', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $resource = Mockery::mock(Application::class)->makePartial(); - $resource->shouldReceive('team')->andReturn((object) ['id' => 1]); - $envVar = Mockery::mock(EnvironmentVariable::class)->makePartial(); $envVar->shouldReceive('getAttribute')->with('resourceable')->andReturn($resource); + $gate = Mockery::mock(GateContract::class); + $gate->shouldReceive('allows')->with('manageEnvironment', $resource)->andReturn(true); + Gate::shouldReceive('forUser')->with($user)->andReturn($gate); + $policy = new EnvironmentVariablePolicy; expect($policy->manageEnvironment($user, $envVar))->toBeTrue(); }); it('denies non-admin from managing environment', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $resource = Mockery::mock(Application::class)->makePartial(); - $resource->shouldReceive('team')->andReturn((object) ['id' => 1]); - $envVar = Mockery::mock(EnvironmentVariable::class)->makePartial(); $envVar->shouldReceive('getAttribute')->with('resourceable')->andReturn($resource); + $gate = Mockery::mock(GateContract::class); + $gate->shouldReceive('allows')->with('manageEnvironment', $resource)->andReturn(false); + Gate::shouldReceive('forUser')->with($user)->andReturn($gate); + $policy = new EnvironmentVariablePolicy; expect($policy->manageEnvironment($user, $envVar))->toBeFalse(); }); diff --git a/tests/Unit/Policies/ProjectPolicyTest.php b/tests/Unit/Policies/ProjectPolicyTest.php index 8b4a824f6a..6bc3471709 100644 --- a/tests/Unit/Policies/ProjectPolicyTest.php +++ b/tests/Unit/Policies/ProjectPolicyTest.php @@ -1,8 +1,16 @@ forgetInstance(AccessResolver::class); +}); it('allows any user to view any projects', function () { $user = Mockery::mock(User::class)->makePartial(); @@ -12,38 +20,40 @@ it('allows any user to view any projects', function () { }); it('allows team member to view their own team project', function () { - $teams = collect([ - (object) ['id' => 1], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - $project = Mockery::mock(Project::class)->makePartial(); $project->team_id = 1; + $resolver = Mockery::mock(AccessResolver::class); + $resolver->shouldReceive('canOpen')->with($user, $project)->andReturn(true); + Container::getInstance()->instance(AccessResolver::class, $resolver); + $policy = new ProjectPolicy; expect($policy->view($user, $project))->toBeTrue(); }); it('denies non-member to view another team project', function () { - $teams = collect([ - (object) ['id' => 1], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - $project = Mockery::mock(Project::class)->makePartial(); $project->team_id = 2; + $resolver = Mockery::mock(AccessResolver::class); + $resolver->shouldReceive('canOpen')->with($user, $project)->andReturn(false); + Container::getInstance()->instance(AccessResolver::class, $resolver); + $policy = new ProjectPolicy; expect($policy->view($user, $project))->toBeFalse(); }); it('allows admin to create a project', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdmin')->andReturn(true); + $team = new Team; + $team->id = 1; + $user->shouldReceive('currentTeam')->andReturn($team); + + $resolver = Mockery::mock(AccessResolver::class); + $resolver->shouldReceive('allowsTeamWide')->with($user, AccessLevel::Write, 1)->andReturn(true); + Container::getInstance()->instance(AccessResolver::class, $resolver); $policy = new ProjectPolicy; expect($policy->create($user))->toBeTrue(); @@ -51,7 +61,13 @@ it('allows admin to create a project', function () { it('denies member to create a project', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdmin')->andReturn(false); + $team = new Team; + $team->id = 1; + $user->shouldReceive('currentTeam')->andReturn($team); + + $resolver = Mockery::mock(AccessResolver::class); + $resolver->shouldReceive('allowsTeamWide')->with($user, AccessLevel::Write, 1)->andReturn(false); + Container::getInstance()->instance(AccessResolver::class, $resolver); $policy = new ProjectPolicy; expect($policy->create($user))->toBeFalse(); @@ -59,44 +75,44 @@ it('denies member to create a project', function () { it('allows team admin to update their own team project', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $project = Mockery::mock(Project::class)->makePartial(); $project->team_id = 1; + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $project)->andReturn(true); + $policy = new ProjectPolicy; expect($policy->update($user, $project))->toBeTrue(); }); it('denies team member to update their own team project', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $project = Mockery::mock(Project::class)->makePartial(); $project->team_id = 1; + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $project)->andReturn(false); + $policy = new ProjectPolicy; expect($policy->update($user, $project))->toBeFalse(); }); it('allows team admin to delete their own team project', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $project = Mockery::mock(Project::class)->makePartial(); $project->team_id = 1; + $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $project)->andReturn(true); + $policy = new ProjectPolicy; expect($policy->delete($user, $project))->toBeTrue(); }); it('denies team member to delete their own team project', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $project = Mockery::mock(Project::class)->makePartial(); $project->team_id = 1; + $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $project)->andReturn(false); + $policy = new ProjectPolicy; expect($policy->delete($user, $project))->toBeFalse(); }); diff --git a/tests/Unit/Policies/ResourceCreatePolicyTest.php b/tests/Unit/Policies/ResourceCreatePolicyTest.php index eb6fc513c4..51a400edc9 100644 --- a/tests/Unit/Policies/ResourceCreatePolicyTest.php +++ b/tests/Unit/Policies/ResourceCreatePolicyTest.php @@ -1,19 +1,39 @@ forgetInstance(AccessResolver::class); +}); it('allows admin to create any resource', function () { + $team = new Team; + $team->id = 1; $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdmin')->andReturn(true); + $user->shouldReceive('currentTeam')->andReturn($team); + + $resolver = Mockery::mock(AccessResolver::class); + $resolver->shouldReceive('allowsTeamWide')->with($user, AccessLevel::Write, 1)->andReturn(true); + Container::getInstance()->instance(AccessResolver::class, $resolver); $policy = new ResourceCreatePolicy; expect($policy->createAny($user))->toBeTrue(); }); it('denies member from creating any resource', function () { + $team = new Team; + $team->id = 1; $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdmin')->andReturn(false); + $user->shouldReceive('currentTeam')->andReturn($team); + + $resolver = Mockery::mock(AccessResolver::class); + $resolver->shouldReceive('allowsTeamWide')->with($user, AccessLevel::Write, 1)->andReturn(false); + Container::getInstance()->instance(AccessResolver::class, $resolver); $policy = new ResourceCreatePolicy; expect($policy->createAny($user))->toBeFalse(); diff --git a/tests/Unit/Policies/ServicePolicyTest.php b/tests/Unit/Policies/ServicePolicyTest.php index e9a0b4da0a..beb7ccff3b 100644 --- a/tests/Unit/Policies/ServicePolicyTest.php +++ b/tests/Unit/Policies/ServicePolicyTest.php @@ -1,8 +1,16 @@ forgetInstance(AccessResolver::class); +}); it('allows any user to view any services', function () { $user = Mockery::mock(User::class)->makePartial(); @@ -12,53 +20,45 @@ it('allows any user to view any services', function () { }); it('allows team member to view their own team service', function () { - $teams = collect([ - (object) ['id' => 1], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - $service = Mockery::mock(Service::class)->makePartial(); - $service->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Read, $service)->andReturn(true); $policy = new ServicePolicy; expect($policy->view($user, $service))->toBeTrue(); }); it('denies non-member to view another team service', function () { - $teams = collect([ - (object) ['id' => 1], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - $service = Mockery::mock(Service::class)->makePartial(); - $service->shouldReceive('team')->andReturn((object) ['id' => 2]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Read, $service)->andReturn(false); $policy = new ServicePolicy; expect($policy->view($user, $service))->toBeFalse(); }); it('denies view when service has no team', function () { - $teams = collect([ - (object) ['id' => 1], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - $service = Mockery::mock(Service::class)->makePartial(); $service->shouldReceive('team')->andReturn(null); + $user->shouldReceive('canAccess')->with(AccessLevel::Read, $service)->andReturn(false); + $policy = new ServicePolicy; expect($policy->view($user, $service))->toBeFalse(); }); it('allows admin to create a service', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdmin')->andReturn(true); + $team = new Team; + $team->id = 1; + $user->shouldReceive('currentTeam')->andReturn($team); + + $resolver = Mockery::mock(AccessResolver::class); + $resolver->shouldReceive('allowsTeamWide')->with($user, AccessLevel::Write, 1)->andReturn(true); + Container::getInstance()->instance(AccessResolver::class, $resolver); $policy = new ServicePolicy; expect($policy->create($user))->toBeTrue(); @@ -66,7 +66,13 @@ it('allows admin to create a service', function () { it('denies member to create a service', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdmin')->andReturn(false); + $team = new Team; + $team->id = 1; + $user->shouldReceive('currentTeam')->andReturn($team); + + $resolver = Mockery::mock(AccessResolver::class); + $resolver->shouldReceive('allowsTeamWide')->with($user, AccessLevel::Write, 1)->andReturn(false); + Container::getInstance()->instance(AccessResolver::class, $resolver); $policy = new ServicePolicy; expect($policy->create($user))->toBeFalse(); @@ -74,10 +80,9 @@ it('denies member to create a service', function () { it('allows team admin to update their own team service', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $service = Mockery::mock(Service::class)->makePartial(); - $service->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $service)->andReturn(true); $policy = new ServicePolicy; expect($policy->update($user, $service))->toBeTrue(); @@ -85,10 +90,9 @@ it('allows team admin to update their own team service', function () { it('denies team member to update their own team service', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $service = Mockery::mock(Service::class)->makePartial(); - $service->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $service)->andReturn(false); $policy = new ServicePolicy; expect($policy->update($user, $service))->toBeFalse(); @@ -96,10 +100,9 @@ it('denies team member to update their own team service', function () { it('allows team admin to delete their own team service', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $service = Mockery::mock(Service::class)->makePartial(); - $service->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $service)->andReturn(true); $policy = new ServicePolicy; expect($policy->delete($user, $service))->toBeTrue(); @@ -107,10 +110,9 @@ it('allows team admin to delete their own team service', function () { it('denies team member to delete their own team service', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $service = Mockery::mock(Service::class)->makePartial(); - $service->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $service)->andReturn(false); $policy = new ServicePolicy; expect($policy->delete($user, $service))->toBeFalse(); @@ -122,16 +124,17 @@ it('denies delete when service has no team', function () { $service = Mockery::mock(Service::class)->makePartial(); $service->shouldReceive('team')->andReturn(null); + $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $service)->andReturn(false); + $policy = new ServicePolicy; expect($policy->delete($user, $service))->toBeFalse(); }); it('allows team admin to deploy their own team service', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $service = Mockery::mock(Service::class)->makePartial(); - $service->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Deploy, $service)->andReturn(true); $policy = new ServicePolicy; expect($policy->deploy($user, $service))->toBeTrue(); @@ -139,10 +142,9 @@ it('allows team admin to deploy their own team service', function () { it('denies team member to deploy their own team service', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $service = Mockery::mock(Service::class)->makePartial(); - $service->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Deploy, $service)->andReturn(false); $policy = new ServicePolicy; expect($policy->deploy($user, $service))->toBeFalse(); @@ -150,10 +152,9 @@ it('denies team member to deploy their own team service', function () { it('allows team admin to stop their own team service', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $service = Mockery::mock(Service::class)->makePartial(); - $service->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Deploy, $service)->andReturn(true); $policy = new ServicePolicy; expect($policy->stop($user, $service))->toBeTrue(); @@ -161,10 +162,9 @@ it('allows team admin to stop their own team service', function () { it('denies team member to stop their own team service', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $service = Mockery::mock(Service::class)->makePartial(); - $service->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Deploy, $service)->andReturn(false); $policy = new ServicePolicy; expect($policy->stop($user, $service))->toBeFalse(); @@ -172,10 +172,9 @@ it('denies team member to stop their own team service', function () { it('allows team admin to manage service environment variables', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $service = Mockery::mock(Service::class)->makePartial(); - $service->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $service)->andReturn(true); $policy = new ServicePolicy; expect($policy->manageEnvironment($user, $service))->toBeTrue(); @@ -183,10 +182,9 @@ it('allows team admin to manage service environment variables', function () { it('denies team member to manage service environment variables', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $service = Mockery::mock(Service::class)->makePartial(); - $service->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $service)->andReturn(false); $policy = new ServicePolicy; expect($policy->manageEnvironment($user, $service))->toBeFalse(); diff --git a/tests/Unit/Policies/SharedEnvironmentVariablePolicyTest.php b/tests/Unit/Policies/SharedEnvironmentVariablePolicyTest.php index d2a287a510..0d9b6c60f4 100644 --- a/tests/Unit/Policies/SharedEnvironmentVariablePolicyTest.php +++ b/tests/Unit/Policies/SharedEnvironmentVariablePolicyTest.php @@ -1,8 +1,15 @@ forgetInstance(AccessResolver::class); +}); it('allows any user to view any shared environment variables', function () { $user = Mockery::mock(User::class)->makePartial(); @@ -12,29 +19,27 @@ it('allows any user to view any shared environment variables', function () { }); it('allows team member to view their team shared environment variable', function () { - $teams = collect([ - (object) ['id' => 1, 'pivot' => (object) ['role' => 'member']], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); $model = mockSharedEnvironmentVariable(teamId: 1); + $resolver = Mockery::mock(AccessResolver::class); + $resolver->shouldReceive('allowsTeamWide')->with($user, AccessLevel::Read, 1)->andReturn(true); + Container::getInstance()->instance(AccessResolver::class, $resolver); + $policy = new SharedEnvironmentVariablePolicy; expect($policy->view($user, $model))->toBeTrue(); }); it('denies non-team member to view shared environment variable', function () { - $teams = collect([ - (object) ['id' => 1, 'pivot' => (object) ['role' => 'member']], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); $model = mockSharedEnvironmentVariable(teamId: 2); + $resolver = Mockery::mock(AccessResolver::class); + $resolver->shouldReceive('allowsTeamWide')->with($user, AccessLevel::Read, 2)->andReturn(false); + Container::getInstance()->instance(AccessResolver::class, $resolver); + $policy = new SharedEnvironmentVariablePolicy; expect($policy->view($user, $model))->toBeFalse(); });