fix(access): hide shared variables and actions that a member cannot use

- Check read access on the project or environment for shared variables
  pages, the canvas, and the sidebar, not only navigation access
- Require team-wide read for the server shared variables canvas API
- Open the environment variable editor read-only when the value is hidden
- Show the integration tokens button only to users who can open it
- Show disabled confirmation buttons as disabled, not as danger buttons
This commit is contained in:
Andras Bacsai
2026-10-30 15:14:02 +01:00
parent 20466baae1
commit fb854a7cff
19 changed files with 98 additions and 30 deletions
@@ -37,7 +37,7 @@ class SharedVariableCanvasController extends Controller
if ($owner instanceof JsonResponse) {
return $owner;
}
$this->authorize('view', $owner);
$this->authorize('viewSharedVariables', $owner);
return response()->json($this->canvas->payload($scope, $owner, $this->canEdit($request, $scope, $owner)));
}
@@ -49,7 +49,7 @@ class SharedVariableCanvasController extends Controller
if ($owner instanceof JsonResponse) {
return $owner;
}
$this->authorize('view', $owner);
$this->authorize('viewSharedVariables', $owner);
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
@@ -108,7 +108,7 @@ class SharedVariableCanvasController extends Controller
if ($owner instanceof JsonResponse) {
return $owner;
}
$this->authorize('view', $owner);
$this->authorize('viewSharedVariables', $owner);
[$source, $target] = array_pad(explode('|', (string) $request->route('link_id'), 2), 2, '');
$resource = $this->canvas->resources($scope, $owner)->get($target);
@@ -164,9 +164,10 @@ class Show extends Component
*/
public function loadValues(): void
{
$this->authorize('viewSensitive', $this->env);
$this->authorize('view', $this->env);
if ($this->valuesLoaded) {
// Users without viewSensitive open the editor with the value hidden; nothing to decrypt.
if ($this->valuesLoaded || $this->valuesHiddenForUser()) {
return;
}
+1 -1
View File
@@ -59,7 +59,7 @@ class Canvas extends Component
default => 'team',
};
$owner = $this->owner();
$this->authorize('view', $owner);
$this->authorize('viewSharedVariables', $owner);
$this->endpoint = $this->scope === 'team'
? '/shared-variables/team/canvas'
@@ -2,6 +2,7 @@
namespace App\Livewire\SharedVariables\Environment;
use App\Models\Environment;
use App\Models\Project;
use Illuminate\Support\Collection;
use Livewire\Component;
@@ -12,7 +13,12 @@ class Index extends Component
public function mount()
{
$this->projects = Project::ownedByCurrentTeamCached();
$this->projects = Project::ownedByCurrentTeamCached()
->each(fn (Project $project) => $project->setRelation('environments', $project->environments
->filter(fn (Environment $environment) => auth()->user()->can('viewSharedVariables', $environment))
->values()))
->filter(fn (Project $project) => $project->environments->isNotEmpty())
->values();
}
public function render()
@@ -62,6 +62,7 @@ class Show extends Component
$this->project = Project::ownedByCurrentTeam()->where('uuid', $projectUuid)->firstOrFail();
$this->environment = $this->project->environments()->where('uuid', $environmentUuid)->firstOrFail();
$this->authorize('viewSharedVariables', $this->environment);
if ($this->prefersCanvasView('environment_variables')) {
return $this->redirectRoute('shared-variables.environment.canvas', ['project_uuid' => $this->project->uuid, 'environment_uuid' => $this->environment->uuid], navigate: true);
}
@@ -70,7 +71,7 @@ class Show extends Component
public function switch()
{
$this->authorize('view', $this->environment);
$this->authorize('viewSharedVariables', $this->environment);
$this->view = $this->view === 'normal' ? 'dev' : 'normal';
$this->getDevView();
}
@@ -12,7 +12,9 @@ class Index extends Component
public function mount()
{
$this->projects = Project::ownedByCurrentTeamCached();
$this->projects = Project::ownedByCurrentTeamCached()
->filter(fn (Project $project) => auth()->user()->can('viewSharedVariables', $project))
->values();
}
public function render()
@@ -55,6 +55,7 @@ class Show extends Component
if (! $project) {
return redirect()->route('dashboard');
}
$this->authorize('viewSharedVariables', $project);
$this->project = $project;
if ($this->prefersCanvasView('project_variables')) {
return $this->redirectRoute('shared-variables.project.canvas', ['project_uuid' => $project->uuid], navigate: true);
@@ -65,7 +66,7 @@ class Show extends Component
public function switch()
{
try {
$this->authorize('view', $this->project);
$this->authorize('viewSharedVariables', $this->project);
$this->view = $this->view === 'normal' ? 'dev' : 'normal';
$this->getDevView();
} catch (\Throwable $e) {
+9
View File
@@ -26,6 +26,15 @@ class EnvironmentPolicy
return app(AccessResolver::class)->canOpen($user, $environment);
}
/**
* Determine whether the user can read the shared variables of the environment.
* Opening an environment for navigation is not enough; this needs read access on the environment.
*/
public function viewSharedVariables(User $user, Environment $environment): bool
{
return $user->canAccess(AccessLevel::Read, $environment);
}
/**
* Determine whether the user can create environments, in the project when one is given.
*/
+9
View File
@@ -25,6 +25,15 @@ class ProjectPolicy
return app(AccessResolver::class)->canOpen($user, $project);
}
/**
* Determine whether the user can read the shared variables of the project.
* Opening a project for navigation is not enough; this needs read access on the project.
*/
public function viewSharedVariables(User $user, Project $project): bool
{
return $user->canAccess(AccessLevel::Read, $project);
}
/**
* Determine whether the user can create projects in the current team.
*/
+10
View File
@@ -2,8 +2,10 @@
namespace App\Policies;
use App\Enums\AccessLevel;
use App\Models\Server;
use App\Models\User;
use App\Services\Access\AccessResolver;
class ServerPolicy
{
@@ -23,6 +25,14 @@ class ServerPolicy
return $user->teams->contains('id', $server->team_id);
}
/**
* Determine whether the user can read the shared variables of the server (team-wide read).
*/
public function viewSharedVariables(User $user, Server $server): bool
{
return app(AccessResolver::class)->allowsTeamWide($user, AccessLevel::Read, $server->team_id);
}
/**
* Determine whether the user can create models.
*/
+10
View File
@@ -2,8 +2,10 @@
namespace App\Policies;
use App\Enums\AccessLevel;
use App\Models\Team;
use App\Models\User;
use App\Services\Access\AccessResolver;
class TeamPolicy
{
@@ -23,6 +25,14 @@ class TeamPolicy
return $user->teams->contains('id', $team->id);
}
/**
* Determine whether the user can read the team shared variables (team-wide read).
*/
public function viewSharedVariables(User $user, Team $team): bool
{
return app(AccessResolver::class)->allowsTeamWide($user, AccessLevel::Read, $team->id);
}
/**
* Determine whether the user can create models.
*/
+1 -1
View File
@@ -515,7 +515,7 @@ option {
}
.button.button-error:disabled {
@apply text-red-800 dark:text-red-300 bg-red-50 dark:bg-red-900/30 border-red-300 dark:border-red-800;
@apply text-neutral-300 dark:text-fg-faint bg-transparent dark:bg-transparent border-neutral-200 dark:border-white/[0.06];
}
.button-error:not(:disabled) {
@@ -173,11 +173,11 @@
@else
@if ($disabled)
@if ($buttonFullWidth)
<x-forms.button class="w-full" isError disabled :authDisabled="$authDisabled" :tooltip="$disabledTooltip" wire:target>
<x-forms.button class="w-full" :isError="$isErrorButton" disabled :authDisabled="$authDisabled" :tooltip="$disabledTooltip" wire:target>
{{ $resolvedButtonTitle }}
</x-forms.button>
@else
<x-forms.button isError disabled :authDisabled="$authDisabled" :tooltip="$disabledTooltip" wire:target>
<x-forms.button :isError="$isErrorButton" disabled :authDisabled="$authDisabled" :tooltip="$disabledTooltip" wire:target>
{{ $resolvedButtonTitle }}
</x-forms.button>
@endif
+12 -4
View File
@@ -109,6 +109,10 @@
@php
// Members limited to some projects only see project pages; team-level pages return 403 for them.
$teamWide = app(App\Services\Access\AccessResolver::class)->coversWholeTeam(auth()->user(), currentTeam()->id);
// Project and environment variables need read access there; opening a parent of a granted resource is not enough.
$readableProjects = $teamWide ? null : App\Models\Project::ownedByCurrentTeamCached();
$canReadProjectVariables = $teamWide || $readableProjects->contains(fn ($project) => auth()->user()->can('viewSharedVariables', $project));
$canReadEnvironmentVariables = $teamWide || $readableProjects->contains(fn ($project) => $project->environments->contains(fn ($environment) => auth()->user()->can('viewSharedVariables', $environment)));
// An entry with children opens a submenu in place of the main menu (back button + its pages).
$navSections = collect([
'Workspace' => [
@@ -131,8 +135,8 @@
['label' => 'Shared Variables', 'icon' => 'variables', 'children' => [
['label' => 'Overview', 'icon' => 'dashboard', 'href' => route('shared-variables.index'), 'active' => request()->is('shared-variables'), 'visible' => $teamWide],
['label' => 'Team', 'icon' => 'teams', 'href' => route('shared-variables.team.index'), 'active' => request()->is('shared-variables/team*'), 'visible' => $teamWide],
['label' => 'Projects', 'icon' => 'projects', 'href' => route('shared-variables.project.index'), 'active' => request()->is('shared-variables/projects', 'shared-variables/project/*')],
['label' => 'Environments', 'icon' => 'layers', 'href' => route('shared-variables.environment.index'), 'active' => request()->is('shared-variables/environments*')],
['label' => 'Projects', 'icon' => 'projects', 'href' => route('shared-variables.project.index'), 'active' => request()->is('shared-variables/projects', 'shared-variables/project/*'), 'visible' => $canReadProjectVariables],
['label' => 'Environments', 'icon' => 'layers', 'href' => route('shared-variables.environment.index'), 'active' => request()->is('shared-variables/environments*'), 'visible' => $canReadEnvironmentVariables],
['label' => 'Servers', 'icon' => 'servers', 'href' => route('shared-variables.server.index'), 'active' => request()->is('shared-variables/servers', 'shared-variables/server/*'), 'visible' => $teamWide],
]],
],
@@ -178,9 +182,13 @@
}
$children = array_values(array_filter($item['children'], fn (array $child): bool => (bool) ($child['visible'] ?? true)));
return [...$item, 'children' => $children, 'href' => $children[0]['href'], 'active' => collect($children)->contains('active', true)];
return [...$item, 'children' => $children, 'href' => $children[0]['href'] ?? null, 'active' => collect($children)->contains('active', true)];
})
->all());
// A parent whose children are all hidden has nothing to open.
->reject(fn (array $item): bool => isset($item['children']) && $item['children'] === [])
->values()
->all())
->reject(fn (array $items): bool => $items === []);
$activeSubmenu = $navSections->flatten(1)->first(fn (array $item): bool => isset($item['children']) && $item['active']);
// Resource menus by depth, registered by registerMainNavContext() (e.g. from
// components/main-nav-menu.blade.php). Each one teleports itself into #main-nav-context-{depth}
@@ -6,15 +6,20 @@
<x-application.settings-section title="Secret manager" :description="$secretManagerDescription">
@if (! $link && $availableTokens->isEmpty())
<x-empty title="No secret manager tokens"
description="Add a Doppler, Infisical, or HashiCorp Vault token first."
icon-name="keys" size="sm">
<x-slot:actions>
<a class="button" href="{{ route('security.integration-tokens') }}" {{ wireNavigate() }}>
Integration tokens
</a>
</x-slot:actions>
</x-empty>
@can('viewAny', \App\Models\IntegrationToken::class)
<x-empty title="No secret manager tokens"
description="Add a Doppler, Infisical, or HashiCorp Vault token first."
icon-name="keys" size="sm">
<x-slot:actions>
<a class="button" href="{{ route('security.integration-tokens') }}" {{ wireNavigate() }}>
Integration tokens
</a>
</x-slot:actions>
</x-empty>
@else
<x-empty title="No secret manager source" description="This resource does not use a secret manager."
icon-name="keys" size="sm" />
@endcan
@else
@can('update', $resource)
<div class="application-settings-form flex w-full flex-col gap-4">
@@ -130,6 +135,9 @@
{{ $link->integrationToken->name }} &middot; {{ $link->sourceSummary() }}
</span>
</div>
@else
<x-empty title="No secret manager source" description="This resource does not use a secret manager."
icon-name="keys" size="sm" />
@endif
@endcan
@endif
@@ -11,7 +11,7 @@
}
}">
@if ($projects->isEmpty())
<x-empty title="No environments yet" description="Create a project environment before adding environment-wide variables." icon-name="layers" />
<x-empty title="No environments" description="Environments that you can read show here. Create an environment to add environment-wide variables." icon-name="layers" />
@else
<x-shared-variables.view-controls label="environments" storage-key="shared-variables-environments-view" />
@@ -11,7 +11,7 @@
}
}">
@if ($projects->isEmpty())
<x-empty title="No projects yet" description="Create a project before adding project-wide variables."
<x-empty title="No projects" description="Projects that you can read show here. Create a project to add project-wide variables."
icon-name="projects" />
@else
<x-shared-variables.view-controls label="projects" storage-key="shared-variables-projects-view" />
+3 -1
View File
@@ -215,7 +215,9 @@ it('shows secret values in the variable editor only with read-sensitive access',
if ($expectedValue === null) {
expect($component->get('isValueHidden'))->toBeTrue()
->and($component->get('value'))->toBeNull();
$component->call('loadValues')->assertForbidden();
$component->call('loadValues')->assertOk();
expect($component->get('value'))->toBeNull()
->and($component->get('valuesLoaded'))->toBeFalse();
return;
}
@@ -220,8 +220,9 @@ test('member cannot load application environment variable values', function () {
'type' => 'application',
])
->call('loadValues')
->assertForbidden()
->assertOk()
->assertSet('valuesLoaded', false)
->assertSet('isValueHidden', true)
->assertSet('value', null);
});