diff --git a/app/Actions/Database/StartDatabaseImport.php b/app/Actions/Database/StartDatabaseImport.php index f8c3c861ba..5bdf249a8d 100644 --- a/app/Actions/Database/StartDatabaseImport.php +++ b/app/Actions/Database/StartDatabaseImport.php @@ -19,6 +19,7 @@ use App\Support\ResourceStartActivity; use App\Support\ValidationPatterns; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Lorisleiva\Actions\Concerns\AsAction; @@ -33,6 +34,8 @@ class StartDatabaseImport public const LOCK_SECONDS = 1800; + public const OWN_BACKUPS_ONLY_MESSAGE = 'You can import only backups of this database. Importing other files needs write access on the whole team.'; + /** * Added to the SSH command timeout, so the remote restore ends (or times out) before the job. * The job stays this far below the Horizon worker timeout too. @@ -59,6 +62,32 @@ class StartDatabaseImport return "database-import:{$resourceUuid}"; } + /** + * True when the server path or S3 key can be imported into the database. Without write access on the whole + * team, a user can import only the backups of this database, not the files of other resources. + */ + public static function allowsSourcePath(Model $resource, string $path): bool + { + if (Gate::allows('useTeamCredentials', $resource)) { + return true; + } + + $teamId = $resource->team()?->id; + if ($teamId === null) { + return false; + } + + // Same folders as DatabaseBackupJob. S3 keys are the local backup path without the leading slash. + // The folder names end with the team ID and the database container name, so a rename keeps old backups. + $directorySuffix = $resource instanceof ServiceDatabase + ? $resource->name.'-'.$resource->service?->uuid + : $resource->uuid; + $pattern = '#^'.preg_quote(backup_dir().'/databases/', '#').'[^/]*-'.preg_quote((string) $teamId, '#') + .'/[^/]*-'.preg_quote((string) $directorySuffix, '#').'/[^/]+$#'; + + return preg_match($pattern, '/'.ltrim($path, '/')) === 1; + } + public function handle(Model $resource, DatabaseImportSource $source, int $teamId): Activity { if (! $this->commands->supports($resource)) { @@ -139,6 +168,9 @@ class StartDatabaseImport $cleanup['serverTmpPath'] = $serverPath; } elseif ($source->type === 'server') { $this->assertServerPath($source->path); + if (! self::allowsSourcePath($resource, $source->path)) { + throw new DatabaseImportException(self::OWN_BACKUPS_ONLY_MESSAGE, 403); + } $size = (int) trim((string) instant_remote_process(['stat -c %s -- '.escapeshellarg($source->path)], $server)); if ($size < 1 || $size > self::MAX_BYTES) { throw new DatabaseImportException('The backup file is empty or exceeds the 10 GiB limit.'); @@ -154,6 +186,9 @@ class StartDatabaseImport } $key = ltrim((string) $source->path, '/'); $this->assertS3Path($key); + if (! self::allowsSourcePath($resource, $key)) { + throw new DatabaseImportException(self::OWN_BACKUPS_ONLY_MESSAGE, 403); + } $disk = $storage->filesystem(); if (! $disk->exists($key) || $disk->size($key) > self::MAX_BYTES) { throw new DatabaseImportException('The S3 backup was not found or exceeds the 10 GiB limit.'); diff --git a/app/Events/ApplicationStopFailed.php b/app/Events/ApplicationStopFailed.php index 37884978c0..13717c24a0 100644 --- a/app/Events/ApplicationStopFailed.php +++ b/app/Events/ApplicationStopFailed.php @@ -24,4 +24,18 @@ class ApplicationStopFailed implements ShouldBroadcastNow { return [new PrivateChannel("team.{$this->teamId}")]; } + + /** + * The team channel also reaches members without access to the application, so only Coolify can read the data. + * + * @return array{payload: string} + */ + public function broadcastWith(): array + { + return ['payload' => encrypt([ + 'teamId' => $this->teamId, + 'applicationUuid' => $this->applicationUuid, + 'message' => $this->message, + ])]; + } } diff --git a/app/Events/DnsRecordConfigurationFinished.php b/app/Events/DnsRecordConfigurationFinished.php index b180dba4bb..bb0259c439 100644 --- a/app/Events/DnsRecordConfigurationFinished.php +++ b/app/Events/DnsRecordConfigurationFinished.php @@ -25,4 +25,22 @@ class DnsRecordConfigurationFinished implements ShouldBroadcastNow { return [new PrivateChannel("team.{$this->teamId}")]; } + + /** + * The team channel also reaches members without access to the resource, so only Coolify can read the data. + * + * @return array{payload: string} + */ + public function broadcastWith(): array + { + return ['payload' => encrypt([ + 'teamId' => $this->teamId, + 'resourceType' => $this->resourceType, + 'resourceId' => $this->resourceId, + 'hostname' => $this->hostname, + 'successful' => $this->successful, + 'credential' => $this->credential, + 'message' => $this->message, + ])]; + } } diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index bb549191fb..fc41f559a8 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -5771,7 +5771,7 @@ class ApplicationsController extends Controller return response()->json(['message' => 'Application not found.'], 404); } - $this->authorize('update', $application); + $this->authorize('delete', $application); $storageUuid = $request->route('storage_uuid'); diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 286ab564ac..43afd00019 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -3069,7 +3069,7 @@ class DatabasesController extends Controller return response()->json(['message' => 'Database not found.'], 404); } - $this->authorize('update', $database); + $this->authorize('delete', $database); // Find the backup configuration by its UUID $backup = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->where('database_id', $database->id)->where('database_type', $database->getMorphClass()) @@ -3205,7 +3205,7 @@ class DatabasesController extends Controller return response()->json(['message' => 'Database not found.'], 404); } - $this->authorize('update', $database); + $this->authorize('delete', $database); // Find the backup configuration by its UUID $backup = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->where('database_id', $database->id)->where('database_type', $database->getMorphClass()) @@ -4999,7 +4999,7 @@ class DatabasesController extends Controller return response()->json(['message' => 'Database not found.'], 404); } - $this->authorize('update', $database); + $this->authorize('delete', $database); $storageUuid = $request->route('storage_uuid'); diff --git a/app/Http/Controllers/Api/OtherController.php b/app/Http/Controllers/Api/OtherController.php index 8e36629921..31083f8506 100644 --- a/app/Http/Controllers/Api/OtherController.php +++ b/app/Http/Controllers/Api/OtherController.php @@ -93,7 +93,7 @@ class OtherController extends Controller if (is_null($teamId)) { return invalidTokenResponse(); } - if ($teamId !== '0') { + if ($teamId !== '0' || ! $request->user()?->can('update', instanceSettings())) { auditLog('api.instance.disable_denied', ['team_id' => $teamId], 'warning'); return response()->json(['message' => 'You are not allowed to disable the API.'], 403); @@ -149,7 +149,7 @@ class OtherController extends Controller if (is_null($teamId)) { return invalidTokenResponse(); } - if ($teamId !== '0') { + if ($teamId !== '0' || ! $request->user()?->can('update', instanceSettings())) { auditLog('api.mcp.enable_denied', ['team_id' => $teamId], 'warning'); return response()->json(['message' => 'You are not allowed to enable the MCP server.'], 403); @@ -205,7 +205,7 @@ class OtherController extends Controller if (is_null($teamId)) { return invalidTokenResponse(); } - if ($teamId !== '0') { + if ($teamId !== '0' || ! $request->user()?->can('update', instanceSettings())) { auditLog('api.mcp.disable_denied', ['team_id' => $teamId], 'warning'); return response()->json(['message' => 'You are not allowed to disable the MCP server.'], 403); diff --git a/app/Http/Controllers/Api/ScheduledTasksController.php b/app/Http/Controllers/Api/ScheduledTasksController.php index bc14c11600..2f120eccbb 100644 --- a/app/Http/Controllers/Api/ScheduledTasksController.php +++ b/app/Http/Controllers/Api/ScheduledTasksController.php @@ -218,7 +218,7 @@ class ScheduledTasksController extends Controller private function deleteTask(Request $request, Application|Service $resource): JsonResponse { - $this->authorize('update', $resource); + $this->authorize('delete', $resource); $task = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->first(); if (! $task) { diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index e8b8457b81..7fa1eb34da 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -3040,7 +3040,7 @@ class ServicesController extends Controller return response()->json(['message' => 'Service not found.'], 404); } - $this->authorize('update', $service); + $this->authorize('delete', $service); $storageUuid = $request->route('storage_uuid'); diff --git a/app/Http/Controllers/Api/SharedEnvironmentVariablesController.php b/app/Http/Controllers/Api/SharedEnvironmentVariablesController.php index d87750e335..6429bb9c9c 100644 --- a/app/Http/Controllers/Api/SharedEnvironmentVariablesController.php +++ b/app/Http/Controllers/Api/SharedEnvironmentVariablesController.php @@ -7,6 +7,7 @@ use App\Models\Environment; use App\Models\Project; use App\Models\Server; use App\Models\SharedEnvironmentVariable; +use App\Models\Team; use App\Support\ValidationPatterns; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -337,7 +338,7 @@ class SharedEnvironmentVariablesController extends Controller return $teamId; } - $this->authorize('viewAny', SharedEnvironmentVariable::class); + $this->authorize('viewSharedVariables', Team::findOrFail($teamId)); return $this->listEnvs($teamId, 'team'); } @@ -463,7 +464,7 @@ class SharedEnvironmentVariablesController extends Controller return $project; } - $this->authorize('view', $project); + $this->authorize('viewSharedVariables', $project); return $this->listEnvs($teamId, 'project', ['project_id' => $project->id]); } @@ -619,7 +620,7 @@ class SharedEnvironmentVariablesController extends Controller return $environment; } - $this->authorize('view', $environment); + $this->authorize('viewSharedVariables', $environment); return $this->listEnvs($teamId, 'environment', ['environment_id' => $environment->id]); } @@ -787,7 +788,7 @@ class SharedEnvironmentVariablesController extends Controller return $server; } - $this->authorize('view', $server); + $this->authorize('viewSharedVariables', $server); return $this->listEnvs($teamId, 'server', ['server_id' => $server->id]); } diff --git a/app/Http/Controllers/Api/VolumeBackupsController.php b/app/Http/Controllers/Api/VolumeBackupsController.php index 16edac3bab..7d1460dec0 100644 --- a/app/Http/Controllers/Api/VolumeBackupsController.php +++ b/app/Http/Controllers/Api/VolumeBackupsController.php @@ -396,7 +396,7 @@ class VolumeBackupsController extends Controller return response()->json(['message' => 'Resource not found.'], 404); } - $this->authorize('update', $resource); + $this->authorize('delete', $resource); $storage = $this->findStorage($resource, $request->route('storage_uuid')); if (! $storage) { diff --git a/app/Http/Middleware/EnsureTeamWideAccess.php b/app/Http/Middleware/EnsureTeamWideAccess.php index c41928e4e0..f8ca08a9c4 100644 --- a/app/Http/Middleware/EnsureTeamWideAccess.php +++ b/app/Http/Middleware/EnsureTeamWideAccess.php @@ -19,7 +19,7 @@ class EnsureTeamWideAccess public function handle(Request $request, Closure $next): Response { $user = $request->user(); - $teamId = $user?->currentTeam()?->id; + $teamId = data_get($user?->currentAccessToken(), 'team_id') ?? $user?->currentTeam()?->id; if ($teamId !== null && ! app(AccessResolver::class)->coversWholeTeam($user, $teamId)) { abort(403, 'Your access in this team is limited to some projects.'); diff --git a/app/Livewire/Boarding/Index.php b/app/Livewire/Boarding/Index.php index 50bad00678..a875c383e8 100644 --- a/app/Livewire/Boarding/Index.php +++ b/app/Livewire/Boarding/Index.php @@ -87,7 +87,7 @@ class Index extends Component public function mount() { - if (auth()->user()?->isMember() && auth()->user()->currentTeam()->show_boarding === true) { + if (auth()->user()?->isMember()) { return redirect()->route('dashboard'); } diff --git a/app/Livewire/Concerns/InteractsWithDnsProviders.php b/app/Livewire/Concerns/InteractsWithDnsProviders.php index 9af1ed93ba..cc58fa51fe 100644 --- a/app/Livewire/Concerns/InteractsWithDnsProviders.php +++ b/app/Livewire/Concerns/InteractsWithDnsProviders.php @@ -250,6 +250,8 @@ trait InteractsWithDnsProviders return; } + $event = $this->teamChannelPayload($event); + if (! is_string($event['hostname'] ?? null) || ! is_string($event['resourceType'] ?? null) || ! is_scalar($event['resourceId'] ?? null) || ! is_bool($event['successful'] ?? null) || ! is_string($event['credential'] ?? null) || ! is_string($event['message'] ?? null) diff --git a/app/Livewire/GlobalSearch.php b/app/Livewire/GlobalSearch.php index cd2b9b0134..531b6a5a63 100644 --- a/app/Livewire/GlobalSearch.php +++ b/app/Livewire/GlobalSearch.php @@ -6,6 +6,7 @@ use App\Models\Application; use App\Models\CloudInitScript; use App\Models\CloudProviderToken; use App\Models\Environment; +use App\Models\GithubApp; use App\Models\IntegrationToken; use App\Models\Project; use App\Models\Server; @@ -239,9 +240,13 @@ class GlobalSearch extends Component return true; } + if ($type === 'source') { + return $user->can('create', GithubApp::class); + } + // Applications, Databases, Services, and other resources if (in_array($type, [ - 'project', 'source', + 'project', // Applications 'public', 'private-gh-app', 'private-gitea-app', 'private-deploy-key', 'dockerfile', 'docker-compose-empty', 'docker-image', @@ -1192,8 +1197,8 @@ class GlobalSearch extends Component ]); } - // GitHub Source - can be created if user has createAnyResource permission - if ($user->can('createAnyResource')) { + // GitHub Source - can be created if user can create a GitHub App + if ($user->can('create', GithubApp::class)) { $items->push([ 'name' => 'GitHub App', 'description' => 'Connect a GitHub app for source control', @@ -1448,6 +1453,9 @@ class GlobalSearch extends Component public function loadServers() { + if (! auth()->user()->can('createAnyResource')) { + return; + } $this->loadingServers = true; $servers = Server::isUsable()->get()->sortBy('name'); $this->availableServers = $servers->map(fn ($s) => [ @@ -1474,6 +1482,9 @@ class GlobalSearch extends Component public function loadDestinations() { + if (! auth()->user()->can('createAnyResource')) { + return; + } $this->loadingDestinations = true; $server = Server::ownedByCurrentTeam()->find($this->selectedServerId); diff --git a/app/Livewire/Project/Application/Heading.php b/app/Livewire/Project/Application/Heading.php index 182f96ae75..190e8e19a8 100644 --- a/app/Livewire/Project/Application/Heading.php +++ b/app/Livewire/Project/Application/Heading.php @@ -129,6 +129,7 @@ class Heading extends Component */ public function stopFailed(array $event): void { + $event = $this->teamChannelPayload($event); if (($event['applicationUuid'] ?? null) !== $this->application->uuid || ! is_string($event['message'] ?? null)) { $this->skipRender(); diff --git a/app/Livewire/Project/Application/Previews.php b/app/Livewire/Project/Application/Previews.php index 6ae092610f..b11ccccecb 100644 --- a/app/Livewire/Project/Application/Previews.php +++ b/app/Livewire/Project/Application/Previews.php @@ -198,6 +198,7 @@ class Previews extends Component { try { $this->authorize('deploy', $this->application); + $this->authorize('update', $this->application); $this->add($pull_request_id, $pull_request_html_url, $docker_registry_image_tag); $this->deploy($pull_request_id, $pull_request_html_url, force_rebuild: false, docker_registry_image_tag: $docker_registry_image_tag); @@ -213,6 +214,7 @@ class Previews extends Component $this->setDeploymentUuid(); $found = ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', $pull_request_id)->first(); if (! $found && (! is_null($pull_request_html_url) || ($this->application->build_pack === 'dockerimage' && str($docker_registry_image_tag)->isNotEmpty()))) { + $this->authorize('update', $this->application); $found = ApplicationPreview::create([ 'application_id' => $this->application->id, 'pull_request_id' => $pull_request_id, @@ -221,7 +223,8 @@ class Previews extends Component ]); $this->auditPreviewEvent('ui.application.preview_created', $pull_request_id); } - if ($found && $this->application->build_pack === 'dockerimage' && str($docker_registry_image_tag)->isNotEmpty()) { + if ($found && $this->application->build_pack === 'dockerimage' && str($docker_registry_image_tag)->isNotEmpty() && $docker_registry_image_tag !== $found->docker_registry_image_tag) { + $this->authorize('update', $this->application); $this->saveDockerTagWithAudit($found, $docker_registry_image_tag); } $result = queue_application_deployment( @@ -230,7 +233,7 @@ class Previews extends Component force_rebuild: $force_rebuild, pull_request_id: $pull_request_id, git_type: $found->git_type ?? null, - docker_registry_image_tag: $docker_registry_image_tag, + docker_registry_image_tag: $this->application->build_pack === 'dockerimage' ? $found?->docker_registry_image_tag : null, ); if ($result['status'] === 'queue_full') { $this->dispatch('error', 'Deployment queue full', $result['message']); diff --git a/app/Livewire/Project/Application/Source.php b/app/Livewire/Project/Application/Source.php index 74f6255cb5..84dbffbc80 100644 --- a/app/Livewire/Project/Application/Source.php +++ b/app/Livewire/Project/Application/Source.php @@ -9,6 +9,7 @@ use App\Models\GitlabApp; use App\Models\PrivateKey; use App\Rules\ValidGitBranch; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\Facades\Gate; use Illuminate\Validation\ValidationException; use Livewire\Attributes\Locked; use Livewire\Attributes\Validate; @@ -111,8 +112,23 @@ class Source extends Component } } + /** + * Other keys and sources of the team. Users without write access on the whole team keep the ones the + * application already uses, so they only see those. + */ + private function canUseTeamCredentials(): bool + { + return Gate::allows('useTeamCredentials', $this->application); + } + private function getPrivateKeys() { + if (! $this->canUseTeamCredentials()) { + $this->privateKeys = collect(); + + return; + } + $this->privateKeys = PrivateKey::where('team_id', $this->application->team()->id)->get()->reject(function ($key) { return $key->id == $this->privateKeyId; }); @@ -120,6 +136,12 @@ class Source extends Component private function getSources() { + if (! $this->canUseTeamCredentials()) { + $this->sources = collect(); + + return; + } + $this->sources = $this->application->team()->sources()->filter(function ($source) { if ($source->id === $this->application->source_id && $source->getMorphClass() === $this->application->source_type) { @@ -140,6 +162,7 @@ class Source extends Component { try { $this->authorize('update', $this->application); + $this->authorize('useTeamCredentials', $this->application); $key = PrivateKey::where('team_id', $this->application->team()->id)->findOrFail($privateKeyId); $this->privateKeyId = $key->id; $this->syncData(true); @@ -192,6 +215,7 @@ class Source extends Component { try { $this->authorize('update', $this->application); + $this->authorize('useTeamCredentials', $this->application); $allowedSourceTypes = [GithubApp::class, GitlabApp::class, GiteaApp::class]; abort_unless(in_array($sourceType, $allowedSourceTypes, true), 404); $teamId = $this->application->team()->id; diff --git a/app/Livewire/Project/CloneMe.php b/app/Livewire/Project/CloneMe.php index 19a32b70be..14b6c8e94d 100644 --- a/app/Livewire/Project/CloneMe.php +++ b/app/Livewire/Project/CloneMe.php @@ -3,13 +3,16 @@ namespace App\Livewire\Project; use App\Actions\Shared\CloneVolumeDataWithSourceStopped; +use App\Enums\AccessLevel; use App\Jobs\VolumeCloneJob; use App\Models\Environment; use App\Models\Project; use App\Models\Server; use App\Models\StandaloneDocker; use App\Models\SwarmDocker; +use App\Services\Access\AccessResolver; use App\Support\ValidationPatterns; +use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\DB; use Livewire\Component; @@ -99,6 +102,9 @@ class CloneMe extends Component 'newName' => ValidationPatterns::nameRules(), ]); $teamId = $this->project->team_id; + if ($type === 'project' && ! app(AccessResolver::class)->allowsTeamWide(auth()->user(), AccessLevel::Write, $teamId)) { + throw new AuthorizationException('You are not allowed to create projects in this team.'); + } if (! $this->environment || (int) $this->environment->project_id !== (int) $this->project->id) { throw new \Exception('Environment not found.'); } diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index 97da01715e..e80619ec24 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -222,7 +222,7 @@ class BackupEdit extends Component public function delete($password, $selectedActions = []) { $database = $this->backup->database; - $this->authorize('manageBackups', $database); + $this->authorize('delete', $database); if (! verifyPasswordConfirmation($password, $this)) { return 'The provided password is incorrect.'; diff --git a/app/Livewire/Project/Database/BackupExecutions.php b/app/Livewire/Project/Database/BackupExecutions.php index 168076238f..e2f91012c1 100644 --- a/app/Livewire/Project/Database/BackupExecutions.php +++ b/app/Livewire/Project/Database/BackupExecutions.php @@ -50,7 +50,7 @@ class BackupExecutions extends Component public function cleanupFailed() { try { - $this->authorize('manageBackups', $this->database); + $this->authorize('delete', $this->database); if ($this->backup) { $this->backup->executions()->where('status', 'failed')->delete(); $this->refreshBackupExecutions(); @@ -64,7 +64,7 @@ class BackupExecutions extends Component public function cleanupDeleted() { try { - $this->authorize('manageBackups', $this->database); + $this->authorize('delete', $this->database); if ($this->backup) { $deletedCount = $this->backup->executions()->where('local_storage_deleted', true)->count(); if ($deletedCount > 0) { @@ -83,7 +83,7 @@ class BackupExecutions extends Component public function deleteBackup($executionId, $password, $selectedActions = []) { try { - $this->authorize('manageBackups', $this->database); + $this->authorize('delete', $this->database); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Project/Database/ImportForm.php b/app/Livewire/Project/Database/ImportForm.php index a1a6783b8e..1106972beb 100644 --- a/app/Livewire/Project/Database/ImportForm.php +++ b/app/Livewire/Project/Database/ImportForm.php @@ -430,6 +430,12 @@ class ImportForm extends Component return; } + if (! StartDatabaseImport::allowsSourcePath($this->resource, $this->customLocation)) { + $this->dispatch('error', StartDatabaseImport::OWN_BACKUPS_ONLY_MESSAGE); + + return; + } + if (! $this->server) { $this->dispatch('error', 'Server not found. Please refresh the page.'); @@ -627,6 +633,12 @@ class ImportForm extends Component return; } + if (! StartDatabaseImport::allowsSourcePath($this->resource, $cleanPath)) { + $this->dispatch('error', StartDatabaseImport::OWN_BACKUPS_ONLY_MESSAGE); + + return; + } + try { $s3Storage = S3Storage::ownedByCurrentTeamAPI((int) $this->resourceTeamId())->findOrFail($this->s3StorageId); diff --git a/app/Livewire/Project/Database/ScheduledBackups.php b/app/Livewire/Project/Database/ScheduledBackups.php index b9da614ea3..fe16ebea6b 100644 --- a/app/Livewire/Project/Database/ScheduledBackups.php +++ b/app/Livewire/Project/Database/ScheduledBackups.php @@ -56,7 +56,7 @@ class ScheduledBackups extends Component public function delete($scheduled_backup_id): void { try { - $this->authorize('manageBackups', $this->database); + $this->authorize('delete', $this->database); $backup = $this->database->scheduledBackups->find($scheduled_backup_id); $backup->delete(); diff --git a/app/Livewire/Project/Database/Sqlite/ConnectApplication.php b/app/Livewire/Project/Database/Sqlite/ConnectApplication.php index d52ebc8573..8dd04c2d4b 100644 --- a/app/Livewire/Project/Database/Sqlite/ConnectApplication.php +++ b/app/Livewire/Project/Database/Sqlite/ConnectApplication.php @@ -68,7 +68,7 @@ class ConnectApplication extends Component */ public function connect() { - $this->authorize('view', $this->database); + $this->authorize('update', $this->database); $this->composeSnippet = null; $this->composeApplicationName = null; diff --git a/app/Livewire/Project/New/GiteaPrivateRepository.php b/app/Livewire/Project/New/GiteaPrivateRepository.php index 9c1fb558c8..1fda4b380b 100644 --- a/app/Livewire/Project/New/GiteaPrivateRepository.php +++ b/app/Livewire/Project/New/GiteaPrivateRepository.php @@ -66,6 +66,8 @@ class GiteaPrivateRepository extends Component private function getGiteaApp(): GiteaApp { + $this->authorize('useTeamCredentials'); + return GiteaApp::private()->where('id', $this->gitea_app_id)->firstOrFail(); } diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php index e1a1d8e7f4..3aa195643f 100644 --- a/app/Livewire/Project/New/GithubPrivateRepository.php +++ b/app/Livewire/Project/New/GithubPrivateRepository.php @@ -110,6 +110,7 @@ class GithubPrivateRepository extends Component $this->branches = collect(); $this->total_branches_count = 0; $this->page = 1; + $this->authorize('useTeamCredentials'); $this->selected_github_app_id = $github_app_id; $this->github_app = GithubApp::ownedByCurrentTeam() ->where('is_public', false) @@ -230,6 +231,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]); + $this->authorize('useTeamCredentials'); $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 f7f57acd02..abceaba1b8 100644 --- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php +++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php @@ -13,6 +13,7 @@ use App\Rules\ValidGitRepositoryUrl; use App\Support\ValidationPatterns; use App\Traits\HasRepositoryDetection; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\Facades\Gate; use Illuminate\Support\Str; use Livewire\Component; use Spatie\Url\Url; @@ -93,7 +94,9 @@ class GithubPrivateRepositoryDeployKey extends Component } $this->parameters = get_route_parameters(); $this->query = request()->query(); - if (isDev()) { + if (! Gate::allows('useTeamCredentials')) { + $this->private_keys = []; + } elseif (isDev()) { $this->private_keys = PrivateKey::where('team_id', currentTeam()->id)->get(); } else { $this->private_keys = PrivateKey::where('team_id', currentTeam()->id)->where('id', '!=', 0)->get(); @@ -133,6 +136,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]); + $this->authorize('useTeamCredentials'); $privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($private_key_id); $this->private_key_id = $privateKey->id; @@ -172,6 +176,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]); + $this->authorize('useTeamCredentials'); 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 fe30d8bb50..2828700726 100644 --- a/app/Livewire/Project/New/GitlabPrivateRepository.php +++ b/app/Livewire/Project/New/GitlabPrivateRepository.php @@ -63,6 +63,8 @@ class GitlabPrivateRepository extends Component private function getGitlabApp(): GitlabApp { + $this->authorize('useTeamCredentials'); + return GitlabApp::private()->where('id', $this->gitlab_app_id)->firstOrFail(); } diff --git a/app/Livewire/Project/Service/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php index fcf042c4fb..f48a1da77b 100644 --- a/app/Livewire/Project/Service/FileStorage.php +++ b/app/Livewire/Project/Service/FileStorage.php @@ -232,7 +232,7 @@ class FileStorage extends Component public function delete($password, $selectedActions = []) { - $this->authorize('update', $this->resource); + $this->authorize('delete', $this->resource); if (! verifyPasswordConfirmation($password, $this)) { return 'The provided password is incorrect.'; diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php index 8adba953db..9a5df71596 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php @@ -14,6 +14,7 @@ use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Database\Eloquent\Model; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Computed; +use Livewire\Attributes\Locked; use Livewire\Component; class Add extends Component @@ -31,6 +32,7 @@ class Add extends Component public $resource; + #[Locked] public $parameters; public bool $shared = false; @@ -107,7 +109,7 @@ class Add extends Component // Check if user can view team variables try { - $this->authorize('view', $team); + $this->authorize('viewSharedVariables', $team); $result['team'] = $team->environment_variables() ->pluck('key') ->toArray(); @@ -124,32 +126,32 @@ class Add extends Component if ($project) { try { - $this->authorize('view', $project); + $this->authorize('viewSharedVariables', $project); $result['project'] = $project->environment_variables() ->pluck('key') ->toArray(); - - // Get environment variables if we have an environment_uuid in route - $environmentUuid = data_get($this->parameters, 'environment_uuid'); - if ($environmentUuid) { - $environment = $project->environments() - ->where('uuid', $environmentUuid) - ->first(); - - if ($environment) { - try { - $this->authorize('view', $environment); - $result['environment'] = $environment->environment_variables() - ->pluck('key') - ->toArray(); - } catch (AuthorizationException $e) { - // User not authorized to view environment variables - } - } - } } catch (AuthorizationException $e) { // User not authorized to view project variables } + + // Get environment variables if we have an environment_uuid in route + $environmentUuid = data_get($this->parameters, 'environment_uuid'); + if ($environmentUuid) { + $environment = $project->environments() + ->where('uuid', $environmentUuid) + ->first(); + + if ($environment) { + try { + $this->authorize('viewSharedVariables', $environment); + $result['environment'] = $environment->environment_variables() + ->pluck('key') + ->toArray(); + } catch (AuthorizationException $e) { + // User not authorized to view environment variables + } + } + } } } @@ -163,7 +165,7 @@ class Add extends Component if ($server) { try { - $this->authorize('view', $server); + $this->authorize('viewSharedVariables', $server); $result['server'] = $server->environment_variables() ->pluck('key') ->toArray(); @@ -182,7 +184,7 @@ class Add extends Component if ($application && $application->destination && $application->destination->server) { try { - $this->authorize('view', $application->destination->server); + $this->authorize('viewSharedVariables', $application->destination->server); $result['server'] = $application->destination->server->environment_variables() ->pluck('key') ->toArray(); @@ -201,7 +203,7 @@ class Add extends Component if ($service && $service->server) { try { - $this->authorize('view', $service->server); + $this->authorize('viewSharedVariables', $service->server); $result['server'] = $service->server->environment_variables() ->pluck('key') ->toArray(); diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php index 185d7b10b1..b5dbdf5007 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php @@ -20,6 +20,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Validation\ValidationException; use Livewire\Attributes\Computed; +use Livewire\Attributes\Locked; use Livewire\Component; class Show extends Component @@ -36,6 +37,7 @@ class Show extends Component return $this->isSharedVariable ? null : $this->env->resourceable; } + #[Locked] public $parameters; public ModelsEnvironmentVariable|SharedEnvironmentVariable $env; @@ -202,6 +204,7 @@ class Show extends Component $this->env->resolveReferencedValue(), $this->env->resourceable, revealLockedSharedVariables: false, + canReveal: ModelsEnvironmentVariable::sharedVariablesReadableBy(auth()->user()), ); } @@ -457,7 +460,7 @@ class Show extends Component // Check if user can view team variables try { - $this->authorize('view', $team); + $this->authorize('viewSharedVariables', $team); $result['team'] = $team->environment_variables() ->pluck('key') ->toArray(); @@ -474,32 +477,32 @@ class Show extends Component if ($project) { try { - $this->authorize('view', $project); + $this->authorize('viewSharedVariables', $project); $result['project'] = $project->environment_variables() ->pluck('key') ->toArray(); - - // Get environment variables if we have an environment_uuid in route - $environmentUuid = data_get($this->parameters, 'environment_uuid'); - if ($environmentUuid) { - $environment = $project->environments() - ->where('uuid', $environmentUuid) - ->first(); - - if ($environment) { - try { - $this->authorize('view', $environment); - $result['environment'] = $environment->environment_variables() - ->pluck('key') - ->toArray(); - } catch (AuthorizationException $e) { - // User not authorized to view environment variables - } - } - } } catch (AuthorizationException $e) { // User not authorized to view project variables } + + // Get environment variables if we have an environment_uuid in route + $environmentUuid = data_get($this->parameters, 'environment_uuid'); + if ($environmentUuid) { + $environment = $project->environments() + ->where('uuid', $environmentUuid) + ->first(); + + if ($environment) { + try { + $this->authorize('viewSharedVariables', $environment); + $result['environment'] = $environment->environment_variables() + ->pluck('key') + ->toArray(); + } catch (AuthorizationException $e) { + // User not authorized to view environment variables + } + } + } } } @@ -513,7 +516,7 @@ class Show extends Component if ($server) { try { - $this->authorize('view', $server); + $this->authorize('viewSharedVariables', $server); $result['server'] = $server->environment_variables() ->pluck('key') ->toArray(); @@ -532,7 +535,7 @@ class Show extends Component if ($application && $application->destination && $application->destination->server) { try { - $this->authorize('view', $application->destination->server); + $this->authorize('viewSharedVariables', $application->destination->server); $result['server'] = $application->destination->server->environment_variables() ->pluck('key') ->toArray(); @@ -551,7 +554,7 @@ class Show extends Component if ($service && $service->server) { try { - $this->authorize('view', $service->server); + $this->authorize('viewSharedVariables', $service->server); $result['server'] = $service->server->environment_variables() ->pluck('key') ->toArray(); diff --git a/app/Livewire/Project/Shared/ResourceOperations.php b/app/Livewire/Project/Shared/ResourceOperations.php index 6173215742..33743305c1 100644 --- a/app/Livewire/Project/Shared/ResourceOperations.php +++ b/app/Livewire/Project/Shared/ResourceOperations.php @@ -82,6 +82,7 @@ class ResourceOperations extends Component return $this->addError('environment_id', 'Environment not found.'); } } + $this->authorize('createAnyResource', [$new_environment]); $new_destination = $this->findResourceTeamDestination($destination_uuid); if (! $new_destination) { @@ -281,6 +282,7 @@ class ResourceOperations extends Component if (! $new_environment) { return $this->addError('environment_id', 'Environment not found.'); } + $this->authorize('createAnyResource', [$new_environment]); $this->resource->fill([ 'environment_id' => $new_environment->id, ])->save(); diff --git a/app/Livewire/Project/Shared/ScheduledTask/Show.php b/app/Livewire/Project/Shared/ScheduledTask/Show.php index 1ba42b9d4e..7af32d8381 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Show.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Show.php @@ -145,7 +145,7 @@ class Show extends Component public function delete() { try { - $this->authorize('update', $this->resource); + $this->authorize('delete', $this->resource); $this->authorize('delete', $this->task); $this->task->delete(); auditLog('ui.scheduled_task.deleted', $this->taskAuditContext()); diff --git a/app/Livewire/Project/Shared/SecretManagerLinks.php b/app/Livewire/Project/Shared/SecretManagerLinks.php index 11911d8849..2a24713640 100644 --- a/app/Livewire/Project/Shared/SecretManagerLinks.php +++ b/app/Livewire/Project/Shared/SecretManagerLinks.php @@ -135,6 +135,7 @@ class SecretManagerLinks extends Component public function saveSettings(): void { $this->authorize('update', $this->resource); + $this->authorize('useTeamCredentials', $this->resource); if (! $this->link) { return; diff --git a/app/Livewire/Project/Shared/Storages/All.php b/app/Livewire/Project/Shared/Storages/All.php index 5cace5c2f1..1a86da9fd9 100644 --- a/app/Livewire/Project/Shared/Storages/All.php +++ b/app/Livewire/Project/Shared/Storages/All.php @@ -134,7 +134,7 @@ class All extends Component public function delete(int $storageId, $password = '', $selectedActions = []) { - $this->authorize('update', $this->resource); + $this->authorize('delete', $this->resource); if (! verifyPasswordConfirmation($password, $this)) { return 'The provided password is incorrect.'; diff --git a/app/Livewire/Project/Shared/Storages/VolumeBackups.php b/app/Livewire/Project/Shared/Storages/VolumeBackups.php index bfa122d0e2..7edafc9a16 100644 --- a/app/Livewire/Project/Shared/Storages/VolumeBackups.php +++ b/app/Livewire/Project/Shared/Storages/VolumeBackups.php @@ -246,7 +246,7 @@ class VolumeBackups extends Component public function delete(?string $password = null, array $selectedActions = []): bool|string { - $this->authorize('update', $this->resource); + $this->authorize('delete', $this->resource); if (! verifyPasswordConfirmation($password, $this)) { return 'The provided password is incorrect.'; @@ -282,7 +282,7 @@ class VolumeBackups extends Component public function cleanupFailed(): void { - $this->authorize('update', $this->resource); + $this->authorize('delete', $this->resource); $deletedCount = $this->backup?->executions() ->where('status', 'failed') @@ -301,7 +301,7 @@ class VolumeBackups extends Component public function cleanupDeleted(): void { - $this->authorize('update', $this->resource); + $this->authorize('delete', $this->resource); $deletedCount = $this->backup?->executions() ->where('local_storage_deleted', true) @@ -369,7 +369,7 @@ class VolumeBackups extends Component public function deleteBackup(int $executionId, string $password, array $selectedActions = []): bool|string { - $this->authorize('update', $this->resource); + $this->authorize('delete', $this->resource); if (! verifyPasswordConfirmation($password, $this)) { return 'The provided password is incorrect.'; diff --git a/app/Livewire/Server/Resources.php b/app/Livewire/Server/Resources.php index 130d11eeea..a0822c26f4 100644 --- a/app/Livewire/Server/Resources.php +++ b/app/Livewire/Server/Resources.php @@ -168,6 +168,7 @@ class Resources extends Component } $this->activeTab = 'unmanaged'; try { + $this->authorize('view', $this->server); $this->unmanagedContainers = $this->server->loadUnmanagedContainers()->toArray(); } catch (\Throwable $e) { return handleError($e, $this); @@ -182,6 +183,7 @@ class Resources extends Component if (is_null($this->server)) { return redirect()->route('server.index'); } + $this->authorize('view', $this->server); if ($this->prefersCanvasView('server_resources')) { return $this->redirectRoute('server.canvas', ['server_uuid' => $this->server->uuid], navigate: true); } diff --git a/app/Livewire/SharedVariables/Server/Show.php b/app/Livewire/SharedVariables/Server/Show.php index 3c4d747e29..70243a7a61 100644 --- a/app/Livewire/SharedVariables/Server/Show.php +++ b/app/Livewire/SharedVariables/Server/Show.php @@ -59,7 +59,7 @@ class Show extends Component if (! $server) { return redirect()->route('dashboard'); } - $this->authorize('view', $server); + $this->authorize('viewSharedVariables', $server); $this->server = $server; if ($this->prefersCanvasView('server_variables')) { return $this->redirectRoute('shared-variables.server.canvas', ['server_uuid' => $server->uuid], navigate: true); @@ -69,7 +69,7 @@ class Show extends Component public function switch() { - $this->authorize('view', $this->server); + $this->authorize('viewSharedVariables', $this->server); $this->view = $this->view === 'normal' ? 'dev' : 'normal'; $this->getDevView(); } diff --git a/app/Livewire/SharedVariables/Team/Index.php b/app/Livewire/SharedVariables/Team/Index.php index 682cbeee18..e7bd718c75 100644 --- a/app/Livewire/SharedVariables/Team/Index.php +++ b/app/Livewire/SharedVariables/Team/Index.php @@ -53,13 +53,14 @@ class Index extends Component return $this->redirectRoute('shared-variables.team.canvas', navigate: true); } $this->team = currentTeam(); + $this->authorize('viewSharedVariables', $this->team); $this->getDevView(); } public function switch() { try { - $this->authorize('view', $this->team); + $this->authorize('viewSharedVariables', $this->team); $this->view = $this->view === 'normal' ? 'dev' : 'normal'; $this->getDevView(); } catch (\Throwable $e) { diff --git a/app/Livewire/Source/Gitea/Create.php b/app/Livewire/Source/Gitea/Create.php index a3c1247db9..b119b948bf 100644 --- a/app/Livewire/Source/Gitea/Create.php +++ b/app/Livewire/Source/Gitea/Create.php @@ -50,7 +50,7 @@ class Create extends Component public function createGiteaApp() { try { - $this->authorize('createAnyResource'); + $this->authorize('create', GiteaApp::class); $this->html_url = rtrim($this->html_url, '/'); $this->api_url = filled($this->api_url) diff --git a/app/Livewire/Source/Github/Create.php b/app/Livewire/Source/Github/Create.php index 329528aaa4..ca48209644 100644 --- a/app/Livewire/Source/Github/Create.php +++ b/app/Livewire/Source/Github/Create.php @@ -53,7 +53,7 @@ class Create extends Component public function createGitHubApp() { try { - $this->authorize('createAnyResource'); + $this->authorize('create', GithubApp::class); $this->organization = normalizeGithubOrganization($this->organization); $this->api_url = filled($this->api_url) diff --git a/app/Livewire/Source/Gitlab/Create.php b/app/Livewire/Source/Gitlab/Create.php index 5156047faf..d575041cf9 100644 --- a/app/Livewire/Source/Gitlab/Create.php +++ b/app/Livewire/Source/Gitlab/Create.php @@ -50,7 +50,7 @@ class Create extends Component public function createGitLabApp() { try { - $this->authorize('createAnyResource'); + $this->authorize('create', GitlabApp::class); $this->html_url = rtrim($this->html_url, '/'); $this->api_url = filled($this->api_url) diff --git a/app/Models/Application.php b/app/Models/Application.php index 6add0a4e20..bc5782ec12 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -337,6 +337,9 @@ class Application extends BaseModel ]); }); static::saving(function ($application) { + if ($application->isDirty('docker_compose_raw')) { + EnvironmentVariable::authorizeAddedSharedVariableReferencesIn($application, $application->getOriginal('docker_compose_raw'), $application->docker_compose_raw, 'docker_compose_raw'); + } $payload = []; if ($application->isDirty('fqdn')) { if ($application->fqdn === '') { diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index 93df2afc47..9378bac8eb 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -8,10 +8,14 @@ use App\Support\RemoteSecretReferences; use App\Support\RemoteSecretValueFormatter; use App\Support\ValidationPatterns; use App\Traits\Auditable; +use Closure; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; +use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Log; +use Illuminate\Validation\ValidationException; use OpenApi\Attributes as OA; #[OA\Schema( @@ -172,6 +176,7 @@ class EnvironmentVariable extends BaseModel if ($environmentVariable->preserve_compose_null && $environmentVariable->isDirty('value') && $environmentVariable->value !== null) { $environmentVariable->preserve_compose_null = false; } + $environmentVariable->authorizeAddedSharedVariableReferences(); $environmentVariable->updateIsShared(); }); } @@ -257,18 +262,29 @@ class EnvironmentVariable extends BaseModel } /** - * Resolved value for display and copy. References to locked shared variables keep their - * reference text, so the UI never reveals a value that is only shown once. + * Resolved value for display and copy. References to locked shared variables, and to shared + * variables whose value the user cannot read, keep their reference text. */ public function displayRealValue(): ?string { - return $this->resolveRealValue(revealLockedSharedVariables: false); + return $this->resolveRealValue(revealLockedSharedVariables: false, canReveal: self::sharedVariablesReadableBy(auth()->user())); + } + + /** + * Filter for the shared variables whose value the user can read. Without a user, none. + * + * @return Closure(SharedEnvironmentVariable): bool + */ + public static function sharedVariablesReadableBy(?User $user): Closure + { + return fn (SharedEnvironmentVariable $shared): bool => $user !== null && Gate::forUser($user)->allows('viewSensitive', $shared); } /** * @param Collection|null $sharedVariables + * @param (Closure(SharedEnvironmentVariable): bool)|null $canReveal */ - private function resolveRealValue(bool $revealLockedSharedVariables = true, bool $escapeLegacyValue = true, ?Collection $sharedVariables = null): ?string + private function resolveRealValue(bool $revealLockedSharedVariables = true, bool $escapeLegacyValue = true, ?Collection $sharedVariables = null, ?Closure $canReveal = null): ?string { if (! $this->relationLoaded('resourceable')) { $this->load('resourceable'); @@ -289,7 +305,7 @@ class EnvironmentVariable extends BaseModel $resource->load('destination.server'); } - $value = $this->get_real_environment_variables_internal($this->value, $resource, null, $revealLockedSharedVariables, $sharedVariables); + $value = $this->get_real_environment_variables_internal($this->value, $resource, null, $revealLockedSharedVariables, $sharedVariables, $canReveal); return $value === null || ! $escapeLegacyValue || ! $this->uses_legacy_escaping ? $value : $this->legacyEscapedValue($value); } @@ -354,9 +370,12 @@ class EnvironmentVariable extends BaseModel return preg_match('/^{{\s*(?:'.$types.')\..*}}$/s', trim($this->value)) === 1; } - public function get_real_environment_variables_with_server(?string $environment_variable = null, $resource = null, $server = null, bool $revealLockedSharedVariables = true) + /** + * @param (Closure(SharedEnvironmentVariable): bool)|null $canReveal Resolve only the shared variables that pass this filter. + */ + public function get_real_environment_variables_with_server(?string $environment_variable = null, $resource = null, $server = null, bool $revealLockedSharedVariables = true, ?Closure $canReveal = null) { - return $this->get_real_environment_variables_internal($environment_variable, $resource, $server, $revealLockedSharedVariables); + return $this->get_real_environment_variables_internal($environment_variable, $resource, $server, $revealLockedSharedVariables, canReveal: $canReveal); } public function getResolvedValueWithServer($server = null) @@ -634,6 +653,69 @@ class EnvironmentVariable extends BaseModel return array_map(fn (array $match): array => ['type' => $match[1], 'key' => $match[2]], $matches); } + /** + * A user can only add a reference to a shared variable whose value they can read, because the + * resource gets the resolved value. References that the stored value already has are kept, so + * the other fields stay editable. Queue jobs and console commands have no user. + * + * @throws ValidationException + */ + private function authorizeAddedSharedVariableReferences(): void + { + if ($this->isDirty('value')) { + self::authorizeAddedSharedVariableReferencesIn($this->resourceable, $this->exists ? $this->getOriginal('value') : null, $this->value, 'value'); + } + } + + /** + * Rejects references to shared variables that are new in $value and that the logged-in user cannot read. + * Also used for compose files, where the parser resolves the same references. + * + * @throws ValidationException + */ + public static function authorizeAddedSharedVariableReferencesIn(?Model $resource, ?string $storedValue, ?string $value, string $field): void + { + $user = auth()->user(); + if (! $user instanceof User || ! str_contains((string) $value, '{{')) { + return; + } + + $storedReferences = self::sharedVariableReferences($storedValue); + foreach (self::sharedVariableReferences($value) as $reference) { + if (! in_array($reference['type'], SHARED_VARIABLE_TYPES, true) || in_array($reference, $storedReferences, true)) { + continue; + } + + $scope = self::sharedVariableScope($resource, $reference['type']); + if ($scope === null || Gate::forUser($user)->denies('viewSensitive', $scope)) { + throw ValidationException::withMessages([ + $field => "You cannot reference the shared variable {$reference['type']}.{$reference['key']}, because you cannot read its value.", + ]); + } + } + } + + /** + * An unsaved shared variable of the given type for the resource. The policy decides by the + * scope, so a reference to a key that does not exist yet is checked like an existing one. + */ + private static function sharedVariableScope(?Model $resource, string $type): ?SharedEnvironmentVariable + { + $teamId = $resource?->team()?->id; + if ($teamId === null) { + return null; + } + + $environment = data_get($resource, 'environment'); + + return new SharedEnvironmentVariable([ + 'type' => $type, + 'team_id' => $teamId, + 'project_id' => data_get($environment, 'project_id'), + 'environment_id' => data_get($environment, 'id'), + ]); + } + public function resolveReferencedValue(): ?string { $value = $this->value; @@ -654,8 +736,9 @@ class EnvironmentVariable extends BaseModel /** * @param Collection|null $sharedVariables + * @param (Closure(SharedEnvironmentVariable): bool)|null $canReveal */ - private function get_real_environment_variables_internal(?string $environment_variable = null, $resource = null, $serverOverride = null, bool $revealLockedSharedVariables = true, ?Collection $sharedVariables = null) + private function get_real_environment_variables_internal(?string $environment_variable = null, $resource = null, $serverOverride = null, bool $revealLockedSharedVariables = true, ?Collection $sharedVariables = null, ?Closure $canReveal = null) { if (is_null($environment_variable) || $environment_variable === '' || is_null($resource)) { return $environment_variable; @@ -699,7 +782,7 @@ class EnvironmentVariable extends BaseModel ->where('team_id', $resource->team()->id) ->where("{$type}_id", $id) ->first(); - if ($found && ($revealLockedSharedVariables || ! $found->is_shown_once)) { + if ($found && ($revealLockedSharedVariables || ! $found->is_shown_once) && ($canReveal === null || $canReveal($found))) { $environment_variable = str($environment_variable)->replace("{{{$sharedEnv}}}", $found->value); } } diff --git a/app/Models/Service.php b/app/Models/Service.php index c16d8974c2..24ba035349 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -105,6 +105,11 @@ class Service extends BaseModel $service->compose_parsing_version = self::$parserVersion; $service->save(); }); + static::saving(function ($service) { + if ($service->isDirty('docker_compose_raw')) { + EnvironmentVariable::authorizeAddedSharedVariableReferencesIn($service, $service->getOriginal('docker_compose_raw'), $service->docker_compose_raw, 'docker_compose_raw'); + } + }); } public function isConfigurationChanged(bool $save = false) diff --git a/app/Policies/ResourceCreatePolicy.php b/app/Policies/ResourceCreatePolicy.php index 7ad6d48bf6..e8dd00cf9d 100644 --- a/app/Policies/ResourceCreatePolicy.php +++ b/app/Policies/ResourceCreatePolicy.php @@ -6,6 +6,7 @@ use App\Enums\AccessLevel; use App\Models\Environment; use App\Models\User; use App\Services\Access\AccessResolver; +use Illuminate\Database\Eloquent\Model; class ResourceCreatePolicy { @@ -23,4 +24,16 @@ class ResourceCreatePolicy return $teamId !== null && app(AccessResolver::class)->allowsTeamWide($user, AccessLevel::Write, $teamId); } + + /** + * Determine whether the user can use a team credential or team-wide location that the resource does not use + * yet: private keys, git sources, integration tokens, and backup files of other resources. + * Needs write access on the whole team of the resource, or of the current team without a resource. + */ + public function useTeamCredentials(User $user, ?Model $resource = null): bool + { + $teamId = $resource !== null ? $resource->team()?->id : $user->currentTeam()?->id; + + return $teamId !== null && app(AccessResolver::class)->allowsTeamWide($user, AccessLevel::Write, (int) $teamId); + } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 075fd5d6e3..9e21ad25bd 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -5,6 +5,7 @@ namespace App\Providers; use App\Auth\Oidc\OidcDiscoveryService; use App\Auth\Oidc\OidcTokenValidator; use App\Auth\Oidc\Socialite\OidcProvider; +use App\Http\Middleware\EnsureTeamWideAccess; use App\Models\PersonalAccessToken; use App\Models\Server; use App\Rules\SafeExternalUrl; @@ -26,6 +27,7 @@ use Illuminate\Support\ServiceProvider; use Illuminate\Validation\Rules\Password; use Laravel\Sanctum\Sanctum; use Laravel\Socialite\Contracts\Factory as SocialiteFactory; +use Livewire\Livewire; use Stripe\StripeClient; class AppServiceProvider extends ServiceProvider @@ -52,6 +54,16 @@ class AppServiceProvider extends ServiceProvider $this->configureOidcSocialite(); $this->configureQueue(); $this->configureServerTransferImport(); + $this->configureLivewire(); + } + + /** + * Livewire updates skip route middleware. Run the team-wide access check again on each update, + * so a page that is already open stops working when an admin limits the member to some projects. + */ + private function configureLivewire(): void + { + Livewire::addPersistentMiddleware([EnsureTeamWideAccess::class]); } /** diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index c0a774daf2..b9d6a88245 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -152,6 +152,7 @@ class AuthServiceProvider extends ServiceProvider { // Register gates for resource creation policy Gate::define('createAnyResource', [ResourceCreatePolicy::class, 'createAny']); + Gate::define('useTeamCredentials', [ResourceCreatePolicy::class, 'useTeamCredentials']); // Register gate for terminal access Gate::define('canAccessTerminal', function ($user) { diff --git a/app/Services/Access/AccessResolver.php b/app/Services/Access/AccessResolver.php index 19fa8ecb4e..84ed397853 100644 --- a/app/Services/Access/AccessResolver.php +++ b/app/Services/Access/AccessResolver.php @@ -261,6 +261,7 @@ class AccessResolver /** * Layers of the authenticated user (and token) in their current team, for list queries. + * The token team comes first: the canvas API runs with the session, but its token is for the canvas team. * * @return list */ @@ -275,7 +276,7 @@ class AccessResolver return []; } - $teamId = data_get(session('currentTeam'), 'id') ?? data_get($user->currentAccessToken(), 'team_id'); + $teamId = data_get($user->currentAccessToken(), 'team_id') ?? data_get(session('currentTeam'), 'id'); if ($teamId === null || ! $this->isMember($user, (int) $teamId)) { return []; } diff --git a/app/Services/SecretManagers/ResourceSecretManager.php b/app/Services/SecretManagers/ResourceSecretManager.php index 8217b0d96b..91ea6102e9 100644 --- a/app/Services/SecretManagers/ResourceSecretManager.php +++ b/app/Services/SecretManagers/ResourceSecretManager.php @@ -5,9 +5,11 @@ namespace App\Services\SecretManagers; use App\Models\IntegrationToken; use App\Models\SecretManagerLink; use App\Support\ValidationPatterns; +use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\Gate; use Illuminate\Validation\ValidationException; /** @@ -18,13 +20,19 @@ use Illuminate\Validation\ValidationException; class ResourceSecretManager { /** - * Secret manager tokens of the resource's team that have the secrets capability. + * Secret manager tokens of the resource's team that have the secrets capability. Without write access on + * the whole team, only the linked token: such a user cannot link another token. * * @return Collection */ public function availableTokens(Model $resource): Collection { - return $this->teamTokens($resource)->get() + $tokens = $this->teamTokens($resource); + if (! Gate::allows('useTeamCredentials', $resource)) { + $tokens->whereIn('id', $resource->secretManagerLink()->select('integration_token_id')); + } + + return $tokens->get() ->filter(fn (IntegrationToken $token): bool => $token->hasCapability('secrets')) ->values(); } @@ -65,11 +73,15 @@ class ResourceSecretManager /** * Links the token as the source of the resource, or changes the token or settings of the existing link. + * Both need write access on the whole team: the token and its settings decide which secrets the resource reads. * * @param array $settings + * + * @throws AuthorizationException */ public function link(Model $resource, IntegrationToken $token, array $settings, string $auditSource): SecretManagerLink { + Gate::authorize('useTeamCredentials', $resource); $settings = array_filter($settings, fn ($value) => filled($value)); $link = $resource->secretManagerLink()->updateOrCreate([], [ 'integration_token_id' => $token->id, diff --git a/app/Traits/ListensToTeamChannel.php b/app/Traits/ListensToTeamChannel.php index 03242989b6..c3feef8488 100644 --- a/app/Traits/ListensToTeamChannel.php +++ b/app/Traits/ListensToTeamChannel.php @@ -2,6 +2,7 @@ namespace App\Traits; +use Illuminate\Contracts\Encryption\DecryptException; use Livewire\Attributes\Locked; /** @@ -28,6 +29,28 @@ trait ListensToTeamChannel $this->skipRender(); } + /** + * The data of a team channel event. Events about one resource send it encrypted in `payload`, + * because the team channel also reaches members without access to that resource. + * + * @param array $event + * @return array + */ + protected function teamChannelPayload(array $event): array + { + if (! array_key_exists('payload', $event)) { + return $event; + } + + try { + $payload = decrypt($event['payload']); + } catch (DecryptException) { + return []; + } + + return is_array($payload) ? $payload : []; + } + /** * @param array $handlers broadcast event name => handler method * @return array diff --git a/resources/views/livewire/project/shared/secret-manager-links.blade.php b/resources/views/livewire/project/shared/secret-manager-links.blade.php index 4d124a2fcb..b74e7e623b 100644 --- a/resources/views/livewire/project/shared/secret-manager-links.blade.php +++ b/resources/views/livewire/project/shared/secret-manager-links.blade.php @@ -45,9 +45,9 @@ @if ($selectedToken->dopplerTokenType() === 'service_account')
+ canGate="useTeamCredentials" :canResource="$resource" wire:blur="saveSettings" /> + canGate="useTeamCredentials" :canResource="$resource" wire:blur="saveSettings" />
@else

@@ -57,18 +57,18 @@ @elseif ($selectedToken?->provider === 'infisical')

+ canGate="useTeamCredentials" :canResource="$resource" wire:blur="saveSettings" /> + placeholder="prod" canGate="useTeamCredentials" :canResource="$resource" wire:blur="saveSettings" /> + canGate="useTeamCredentials" :canResource="$resource" wire:blur="saveSettings" />
@elseif ($selectedToken?->provider === 'vault')
+ canGate="useTeamCredentials" :canResource="$resource" wire:blur="saveSettings" /> + placeholder="my-app/production" canGate="useTeamCredentials" :canResource="$resource" wire:blur="saveSettings" />
@endif diff --git a/resources/views/livewire/source/gitea/create.blade.php b/resources/views/livewire/source/gitea/create.blade.php index a605c8dd9d..5e6659b23a 100644 --- a/resources/views/livewire/source/gitea/create.blade.php +++ b/resources/views/livewire/source/gitea/create.blade.php @@ -1,4 +1,4 @@ -@can('createAnyResource') +@can('create', \App\Models\GiteaApp::class)

Connect a Gitea OAuth2 application for private repositories, webhooks, and deployments. diff --git a/resources/views/livewire/source/github/create.blade.php b/resources/views/livewire/source/github/create.blade.php index 05f9ff5d4e..30cb34e385 100644 --- a/resources/views/livewire/source/github/create.blade.php +++ b/resources/views/livewire/source/github/create.blade.php @@ -1,4 +1,4 @@ -@can('createAnyResource') +@can('create', \App\Models\GithubApp::class)

Connect a GitHub App for private repositories, webhooks, and commit / pull request deployments. diff --git a/resources/views/livewire/source/gitlab/create.blade.php b/resources/views/livewire/source/gitlab/create.blade.php index 2a1482cc9c..745b8c6d35 100644 --- a/resources/views/livewire/source/gitlab/create.blade.php +++ b/resources/views/livewire/source/gitlab/create.blade.php @@ -1,4 +1,4 @@ -@can('createAnyResource') +@can('create', \App\Models\GitlabApp::class)

Connect a GitLab OAuth application for private repositories, webhooks, and deployments.