mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
fix(access): enforce member grants on more actions
- Require write access on the target environment to clone or move a resource - Check read access on shared variables before a reference is added or resolved - Limit team credentials, git sources, and imports of other backups to team-wide writers - Require the delete level for backup, volume, storage, and task deletes - Require create access for previews, new projects, and git sources - Use the token team for the access scope, and check team-wide access on Livewire updates - Require instance admin to toggle the API and MCP server - Hide server pickers and broadcast details from members without access
This commit is contained in:
@@ -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.');
|
||||
|
||||
@@ -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,
|
||||
])];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
])];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
@@ -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.';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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.';
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.';
|
||||
|
||||
@@ -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.';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 === '') {
|
||||
|
||||
@@ -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<int, SharedEnvironmentVariable>|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<int, SharedEnvironmentVariable>|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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<AccessGrants>
|
||||
*/
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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<int, IntegrationToken>
|
||||
*/
|
||||
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<string, mixed> $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,
|
||||
|
||||
@@ -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<string, mixed> $event
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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<string, string> $handlers broadcast event name => handler method
|
||||
* @return array<string, string>
|
||||
|
||||
@@ -45,9 +45,9 @@
|
||||
@if ($selectedToken->dopplerTokenType() === 'service_account')
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<x-forms.input required id="settings.project" label="Project (required)"
|
||||
wire:blur="saveSettings" />
|
||||
canGate="useTeamCredentials" :canResource="$resource" wire:blur="saveSettings" />
|
||||
<x-forms.input required id="settings.config" label="Config (required)" placeholder="prd"
|
||||
wire:blur="saveSettings" />
|
||||
canGate="useTeamCredentials" :canResource="$resource" wire:blur="saveSettings" />
|
||||
</div>
|
||||
@else
|
||||
<p class="text-[12px] text-neutral-500 dark:text-fg-dim">
|
||||
@@ -57,18 +57,18 @@
|
||||
@elseif ($selectedToken?->provider === 'infisical')
|
||||
<div class="grid gap-4 lg:grid-cols-3">
|
||||
<x-forms.input required id="settings.project_id" label="Project ID"
|
||||
wire:blur="saveSettings" />
|
||||
canGate="useTeamCredentials" :canResource="$resource" wire:blur="saveSettings" />
|
||||
<x-forms.input required id="settings.environment" label="Environment slug"
|
||||
placeholder="prod" wire:blur="saveSettings" />
|
||||
placeholder="prod" canGate="useTeamCredentials" :canResource="$resource" wire:blur="saveSettings" />
|
||||
<x-forms.input id="settings.secret_path" label="Secret path" placeholder="/"
|
||||
wire:blur="saveSettings" />
|
||||
canGate="useTeamCredentials" :canResource="$resource" wire:blur="saveSettings" />
|
||||
</div>
|
||||
@elseif ($selectedToken?->provider === 'vault')
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<x-forms.input required id="settings.mount" label="KV v2 mount" placeholder="secret"
|
||||
wire:blur="saveSettings" />
|
||||
canGate="useTeamCredentials" :canResource="$resource" wire:blur="saveSettings" />
|
||||
<x-forms.input required id="settings.path" label="Secret path"
|
||||
placeholder="my-app/production" wire:blur="saveSettings" />
|
||||
placeholder="my-app/production" canGate="useTeamCredentials" :canResource="$resource" wire:blur="saveSettings" />
|
||||
</div>
|
||||
@endif
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@can('createAnyResource')
|
||||
@can('create', \App\Models\GiteaApp::class)
|
||||
<form wire:submit="createGiteaApp" class="flex w-full flex-col gap-4">
|
||||
<p class="text-[12px] leading-5 text-neutral-500 dark:text-fg-dim">
|
||||
Connect a Gitea OAuth2 application for private repositories, webhooks, and deployments.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@can('createAnyResource')
|
||||
@can('create', \App\Models\GithubApp::class)
|
||||
<form wire:submit="createGitHubApp" class="flex w-full flex-col gap-4">
|
||||
<p class="text-[12px] leading-5 text-neutral-500 dark:text-fg-dim">
|
||||
Connect a GitHub App for private repositories, webhooks, and commit / pull request deployments.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@can('createAnyResource')
|
||||
@can('create', \App\Models\GitlabApp::class)
|
||||
<form wire:submit="createGitLabApp" class="flex w-full flex-col gap-4">
|
||||
<p class="text-[12px] leading-5 text-neutral-500 dark:text-fg-dim">
|
||||
Connect a GitLab OAuth application for private repositories, webhooks, and deployments.
|
||||
|
||||
Reference in New Issue
Block a user