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:
Andras Bacsai
2026-10-30 16:11:10 +01:00
parent f733ea4c1f
commit 6f1905b7b0
54 changed files with 413 additions and 111 deletions
@@ -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.');
+14
View File
@@ -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');
+3 -3
View File
@@ -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) {
+1 -1
View File
@@ -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.');
+1 -1
View File
@@ -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)
+14 -3
View File
@@ -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;
+6
View File
@@ -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.');
}
+1 -1
View File
@@ -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();
}
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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.';
+2
View File
@@ -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);
}
+2 -2
View File
@@ -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();
}
+2 -1
View File
@@ -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) {
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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)
+3
View File
@@ -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 === '') {
+92 -9
View File
@@ -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);
}
}
+5
View File
@@ -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)
+13
View File
@@ -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);
}
}
+12
View File
@@ -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]);
}
/**
+1
View File
@@ -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) {
+2 -1
View File
@@ -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,
+23
View File
@@ -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.