diff --git a/apps/platform/app/Filament/Resources/ProviderConnectionResource.php b/apps/platform/app/Filament/Resources/ProviderConnectionResource.php index a47b1de7..e18fcdaf 100644 --- a/apps/platform/app/Filament/Resources/ProviderConnectionResource.php +++ b/apps/platform/app/Filament/Resources/ProviderConnectionResource.php @@ -21,8 +21,8 @@ use App\Support\Badges\BadgeRenderer; use App\Support\Filament\TablePaginationProfiles; use App\Support\ManagedEnvironmentLinks; -use App\Support\Navigation\WorkspaceHubNavigation; use App\Support\Navigation\WorkspaceHubEnvironmentFilter; +use App\Support\Navigation\WorkspaceHubNavigation; use App\Support\OperationRunLinks; use App\Support\OperationRunType; use App\Support\OpsUx\OpsUxBrowserEvents; @@ -270,6 +270,12 @@ private static function resolveTenantExternalIdFromLivewireRequest(): ?string // Ignore and fall back to referer. } + $externalId = static::extractTenantExternalIdFromLivewireSnapshot(); + + if (is_string($externalId) && $externalId !== '') { + return $externalId; + } + $referer = request()->headers->get('referer'); if (! is_string($referer) || $referer === '') { @@ -279,6 +285,56 @@ private static function resolveTenantExternalIdFromLivewireRequest(): ?string return static::extractTenantExternalIdFromUrl($referer); } + private static function extractTenantExternalIdFromLivewireSnapshot(): ?string + { + $snapshotJson = request()->input('components.0.snapshot'); + + if (! is_string($snapshotJson) || $snapshotJson === '') { + return null; + } + + $snapshot = json_decode($snapshotJson, true); + + if (! is_array($snapshot)) { + return null; + } + + $componentName = data_get($snapshot, 'memo.name'); + + if (! is_string($componentName) || $componentName !== Pages\CreateProviderConnection::class) { + return null; + } + + $environmentId = data_get($snapshot, 'data.environmentId'); + + if (is_array($environmentId) || filter_var($environmentId, FILTER_VALIDATE_INT) === false) { + return null; + } + + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); + + if (! is_int($workspaceId)) { + return null; + } + + $tenant = ManagedEnvironment::query() + ->whereKey((int) $environmentId) + ->where('workspace_id', $workspaceId) + ->first(); + + if (! $tenant instanceof ManagedEnvironment) { + return null; + } + + $user = request()->user(); + + if ($user instanceof User && ! $user->canAccessTenant($tenant)) { + return null; + } + + return (string) $tenant->slug; + } + private static function extractTenantExternalIdFromUrl(string $url): ?string { $query = parse_url($url, PHP_URL_QUERY); @@ -341,14 +397,6 @@ private static function applyMembershipScope(Builder $query): Builder $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); $user = auth()->user(); - if (! is_int($workspaceId)) { - $tenant = static::resolveTenantContextForCurrentPanel(); - - if ($tenant instanceof ManagedEnvironment) { - $workspaceId = (int) $tenant->workspace_id; - } - } - if (! is_int($workspaceId) || ! $user instanceof User) { return $query->whereRaw('1 = 0'); } diff --git a/apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/CreateProviderConnection.php b/apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/CreateProviderConnection.php index 0805ef04..d82bf5c6 100644 --- a/apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/CreateProviderConnection.php +++ b/apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/CreateProviderConnection.php @@ -9,19 +9,34 @@ use App\Support\Providers\ProviderConnectionType; use App\Support\Providers\ProviderConsentStatus; use App\Support\Providers\ProviderReasonCodes; +use App\Support\Providers\ProviderVerificationStatus; use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor; use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer; -use App\Support\Providers\ProviderVerificationStatus; use Filament\Notifications\Notification; use Filament\Resources\Pages\CreateRecord; use Illuminate\Validation\ValidationException; +use Livewire\Attributes\Locked; class CreateProviderConnection extends CreateRecord { protected static string $resource = ProviderConnectionResource::class; + #[Locked] + public ?int $environmentId = null; + protected bool $shouldMakeDefault = false; + public function mount(): void + { + $environmentId = request()->query('environment_id'); + + if (! is_array($environmentId) && filter_var($environmentId, FILTER_VALIDATE_INT) !== false) { + $this->environmentId = (int) $environmentId; + } + + parent::mount(); + } + protected function mutateFormDataBeforeCreate(array $data): array { $tenant = $this->currentTenant(); diff --git a/apps/platform/app/Policies/ProviderConnectionPolicy.php b/apps/platform/app/Policies/ProviderConnectionPolicy.php index d06a4c8b..7bfbf9f7 100644 --- a/apps/platform/app/Policies/ProviderConnectionPolicy.php +++ b/apps/platform/app/Policies/ProviderConnectionPolicy.php @@ -9,10 +9,10 @@ use App\Services\Auth\ManagedEnvironmentAccessScopeResolver; use App\Support\Auth\Capabilities; use App\Support\Workspaces\WorkspaceContext; -use Filament\Facades\Filament; use Illuminate\Auth\Access\HandlesAuthorization; use Illuminate\Auth\Access\Response; use Illuminate\Support\Facades\Gate; +use Livewire\Livewire as LivewireFacade; class ProviderConnectionPolicy { @@ -210,14 +210,6 @@ private function currentWorkspace(User $user): ?Workspace { $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); - if (! is_int($workspaceId)) { - $filamentTenant = Filament::getTenant(); - - if ($filamentTenant instanceof ManagedEnvironment) { - $workspaceId = (int) $filamentTenant->workspace_id; - } - } - if (! is_int($workspaceId)) { return null; } @@ -237,33 +229,117 @@ private function currentWorkspace(User $user): ?Workspace private function resolveCreateTenant(Workspace $workspace): ?ManagedEnvironment { - $requestedEnvironmentId = request()->query('environment_id'); - - if (! is_numeric($requestedEnvironmentId)) { - $lastEnvironmentId = app(WorkspaceContext::class)->lastEnvironmentId(request()); - - if (is_int($lastEnvironmentId)) { - return ManagedEnvironment::query() - ->whereKey($lastEnvironmentId) - ->where('workspace_id', (int) $workspace->getKey()) - ->first(); - } - - $filamentTenant = Filament::getTenant(); - - if ($filamentTenant instanceof ManagedEnvironment && (int) $filamentTenant->workspace_id === (int) $workspace->getKey()) { - return $filamentTenant; - } + $requestedEnvironmentId = $this->requestedEnvironmentId(); + if ($requestedEnvironmentId === null) { return null; } return ManagedEnvironment::query() - ->whereKey((int) $requestedEnvironmentId) + ->whereKey($requestedEnvironmentId) ->where('workspace_id', (int) $workspace->getKey()) ->first(); } + private function requestedEnvironmentId(): ?int + { + $environmentId = request()->query('environment_id'); + + if (is_numeric($environmentId)) { + return (int) $environmentId; + } + + if (is_array($environmentId)) { + return null; + } + + try { + $resolved = $this->extractEnvironmentIdFromLivewireSnapshot(); + + if ($resolved !== null) { + return $resolved; + } + } catch (\Throwable) { + // Ignore and fall back to originalUrl() parsing. + } + + try { + $url = LivewireFacade::originalUrl(); + + $resolved = $this->extractEnvironmentIdFromUrl($url); + + if ($resolved !== null) { + return $resolved; + } + } catch (\Throwable) { + // Ignore and fall back to referer header. + } + + $referer = request()->headers->get('referer'); + + if (! is_string($referer) || $referer === '') { + return null; + } + + return $this->extractEnvironmentIdFromUrl($referer); + } + + private function extractEnvironmentIdFromLivewireSnapshot(): ?int + { + if (! request()->headers->has('x-livewire') && ! request()->headers->has('x-livewire-navigate')) { + return null; + } + + $snapshotJson = request()->input('components.0.snapshot'); + + if (! is_string($snapshotJson) || $snapshotJson === '') { + return null; + } + + $snapshot = json_decode($snapshotJson, true); + + if (! is_array($snapshot)) { + return null; + } + + $componentName = data_get($snapshot, 'memo.name'); + + if (! is_string($componentName) || $componentName !== \App\Filament\Resources\ProviderConnectionResource\Pages\CreateProviderConnection::class) { + return null; + } + + $environmentId = data_get($snapshot, 'data.environmentId'); + + if (is_array($environmentId) || filter_var($environmentId, FILTER_VALIDATE_INT) === false) { + return null; + } + + return (int) $environmentId; + } + + private function extractEnvironmentIdFromUrl(?string $url): ?int + { + if (! is_string($url) || $url === '') { + return null; + } + + $query = parse_url($url, PHP_URL_QUERY); + + if (! is_string($query) || $query === '') { + return null; + } + + parse_str($query, $params); + + $environmentId = $params['environment_id'] ?? null; + + if (is_array($environmentId) || filter_var($environmentId, FILTER_VALIDATE_INT) === false) { + return null; + } + + return (int) $environmentId; + } + private function tenantForConnection(ProviderConnection $connection): ?ManagedEnvironment { if ($connection->relationLoaded('tenant') && $connection->tenant instanceof ManagedEnvironment) { diff --git a/apps/platform/tests/Feature/Audit/ProviderConnectionIdentityAuditTest.php b/apps/platform/tests/Feature/Audit/ProviderConnectionIdentityAuditTest.php index c528f83f..678625d8 100644 --- a/apps/platform/tests/Feature/Audit/ProviderConnectionIdentityAuditTest.php +++ b/apps/platform/tests/Feature/Audit/ProviderConnectionIdentityAuditTest.php @@ -4,8 +4,8 @@ use App\Filament\Resources\ProviderConnectionResource\Pages\CreateProviderConnection; use App\Models\AuditLog; -use App\Models\ProviderConnection; use App\Models\ManagedEnvironment; +use App\Models\ProviderConnection; use App\Models\User; use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -76,6 +76,9 @@ Filament::setTenant($tenant, true); Livewire::actingAs($user) + ->withQueryParams([ + 'environment_id' => (int) $tenant->getKey(), + ]) ->test(CreateProviderConnection::class) ->fillForm([ 'display_name' => 'Audit target scope connection', diff --git a/apps/platform/tests/Feature/ProviderConnections/MvpProviderScopeTest.php b/apps/platform/tests/Feature/ProviderConnections/MvpProviderScopeTest.php index c72a585f..6ab3f9c5 100644 --- a/apps/platform/tests/Feature/ProviderConnections/MvpProviderScopeTest.php +++ b/apps/platform/tests/Feature/ProviderConnections/MvpProviderScopeTest.php @@ -15,7 +15,11 @@ $tenant->makeCurrent(); Filament::setTenant($tenant, true); - Livewire::test(CreateProviderConnection::class) + $component = Livewire::withQueryParams([ + 'environment_id' => (int) $tenant->getKey(), + ])->test(CreateProviderConnection::class); + + $component ->fillForm([ 'display_name' => 'MVP Scope Connection', 'entra_tenant_id' => (string) fake()->uuid(), diff --git a/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionNeutralitySpec238Test.php b/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionNeutralitySpec238Test.php index fe697eed..eb79743b 100644 --- a/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionNeutralitySpec238Test.php +++ b/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionNeutralitySpec238Test.php @@ -81,6 +81,9 @@ Filament::setTenant($tenant, true); Livewire::actingAs($user) + ->withQueryParams([ + 'environment_id' => (int) $tenant->getKey(), + ]) ->test(CreateProviderConnection::class) ->fillForm([ 'display_name' => 'Missing target scope', diff --git a/apps/platform/tests/Feature/ProviderConnections/ScopeHardeningAuthoritySourcesTest.php b/apps/platform/tests/Feature/ProviderConnections/ScopeHardeningAuthoritySourcesTest.php new file mode 100644 index 00000000..eb137bca --- /dev/null +++ b/apps/platform/tests/Feature/ProviderConnections/ScopeHardeningAuthoritySourcesTest.php @@ -0,0 +1,197 @@ +active()->create([ + 'external_id' => 'spec339-scopehardening-env-remembered', + ]); + + [$user, $environment] = createUserWithTenant(tenant: $environment, role: 'owner'); + + $this->actingAs($user); + session()->put(WorkspaceContext::SESSION_KEY, (int) $environment->workspace_id); + session()->put(WorkspaceContext::LAST_ENVIRONMENT_IDS_SESSION_KEY, [ + (string) $environment->workspace_id => (int) $environment->getKey(), + ]); + + /** @var ProviderConnectionPolicy $policy */ + $policy = app(ProviderConnectionPolicy::class); + + $result = $policy->create($user); + + expect($result)->toBeInstanceOf(Response::class); + expect($result->denied())->toBeTrue(); +}); + +it('ScopeHardening requires explicit environment_id for ProviderConnectionPolicy::create even if Filament tenant is set', function (): void { + $environment = ManagedEnvironment::factory()->active()->create([ + 'external_id' => 'spec339-scopehardening-env-filament', + ]); + + [$user, $environment] = createUserWithTenant(tenant: $environment, role: 'owner'); + + Filament::setTenant($environment, true); + + $this->actingAs($user); + session()->put(WorkspaceContext::SESSION_KEY, (int) $environment->workspace_id); + + /** @var ProviderConnectionPolicy $policy */ + $policy = app(ProviderConnectionPolicy::class); + + $result = $policy->create($user); + + expect($result)->toBeInstanceOf(Response::class); + expect($result->denied())->toBeTrue(); +}); + +it('ScopeHardening ignores legacy query aliases for ProviderConnectionPolicy::create authority', function (): void { + $environment = ManagedEnvironment::factory()->active()->create([ + 'external_id' => 'spec339-scopehardening-env-alias', + ]); + + [$user, $environment] = createUserWithTenant(tenant: $environment, role: 'owner'); + + $request = Request::create('/admin/provider-connections/create', 'GET', [ + 'managed_environment_id' => (int) $environment->getKey(), + ]); + + $this->app->instance('request', $request); + + $this->actingAs($user); + session()->put(WorkspaceContext::SESSION_KEY, (int) $environment->workspace_id); + session()->put(WorkspaceContext::LAST_ENVIRONMENT_IDS_SESSION_KEY, [ + (string) $environment->workspace_id => (int) $environment->getKey(), + ]); + + /** @var ProviderConnectionPolicy $policy */ + $policy = app(ProviderConnectionPolicy::class); + + $result = $policy->create($user); + + expect($result)->toBeInstanceOf(Response::class); + expect($result->denied())->toBeTrue(); +}); + +it('ScopeHardening denies ProviderConnectionPolicy::create for environment_id that belongs to another workspace', function (): void { + $environmentA = ManagedEnvironment::factory()->active()->create([ + 'external_id' => 'spec339-scopehardening-env-a', + ]); + [$user, $environmentA] = createUserWithTenant(tenant: $environmentA, role: 'owner'); + + $workspaceB = Workspace::factory()->create(); + $environmentB = ManagedEnvironment::factory()->active()->create([ + 'workspace_id' => (int) $workspaceB->getKey(), + 'external_id' => 'spec339-scopehardening-env-b', + ]); + + $request = Request::create('/admin/provider-connections/create', 'GET', [ + 'environment_id' => (int) $environmentB->getKey(), + ]); + + $this->app->instance('request', $request); + + $this->actingAs($user); + session()->put(WorkspaceContext::SESSION_KEY, (int) $environmentA->workspace_id); + + /** @var ProviderConnectionPolicy $policy */ + $policy = app(ProviderConnectionPolicy::class); + + $result = $policy->create($user); + + expect($result)->toBeInstanceOf(Response::class); + expect($result->denied())->toBeTrue(); +}); + +it('ScopeHardening returns 403 (not 404) for the create page when environment_id is missing (UI entry behavior)', function (): void { + $environment = ManagedEnvironment::factory()->active()->create([ + 'external_id' => 'spec339-scopehardening-create-page', + ]); + + [$user, $environment] = createUserWithTenant(tenant: $environment, role: 'owner'); + + $this->actingAs($user) + ->withSession([ + WorkspaceContext::SESSION_KEY => (int) $environment->workspace_id, + ]) + ->get('/admin/provider-connections/create') + ->assertForbidden(); +}); + +it('ScopeHardening returns 404 for the create page when environment_id belongs to another workspace (UI entry behavior)', function (): void { + $environmentA = ManagedEnvironment::factory()->active()->create([ + 'external_id' => 'spec339-scopehardening-create-page-a', + ]); + [$user, $environmentA] = createUserWithTenant(tenant: $environmentA, role: 'owner'); + + $workspaceB = Workspace::factory()->create(); + $environmentB = ManagedEnvironment::factory()->active()->create([ + 'workspace_id' => (int) $workspaceB->getKey(), + 'external_id' => 'spec339-scopehardening-create-page-b', + ]); + + $this->actingAs($user) + ->withSession([ + WorkspaceContext::SESSION_KEY => (int) $environmentA->workspace_id, + ]) + ->get('/admin/provider-connections/create?environment_id='.(int) $environmentB->getKey()) + ->assertNotFound(); +}); + +it('ScopeHardening returns 403 (not 404) for the create page when legacy alias is provided (UI entry behavior)', function (): void { + $environment = ManagedEnvironment::factory()->active()->create([ + 'external_id' => 'spec339-scopehardening-create-page-alias', + ]); + + [$user, $environment] = createUserWithTenant(tenant: $environment, role: 'owner'); + + $this->actingAs($user) + ->withSession([ + WorkspaceContext::SESSION_KEY => (int) $environment->workspace_id, + ]) + ->get('/admin/provider-connections/create?managed_environment_id='.(int) $environment->getKey()) + ->assertForbidden(); +}); + +it('ScopeHardening returns 403 (not 404) for the create page even if a remembered environment exists (UI entry behavior)', function (): void { + $environment = ManagedEnvironment::factory()->active()->create([ + 'external_id' => 'spec339-scopehardening-create-page-remembered', + ]); + + [$user, $environment] = createUserWithTenant(tenant: $environment, role: 'owner'); + + $this->actingAs($user) + ->withSession([ + WorkspaceContext::SESSION_KEY => (int) $environment->workspace_id, + WorkspaceContext::LAST_ENVIRONMENT_IDS_SESSION_KEY => [ + (string) $environment->workspace_id => (int) $environment->getKey(), + ], + ]) + ->get('/admin/provider-connections/create') + ->assertForbidden(); +}); + +it('ScopeHardening returns 403 (not 404) for the create page even if Filament tenant is set (UI entry behavior)', function (): void { + $environment = ManagedEnvironment::factory()->active()->create([ + 'external_id' => 'spec339-scopehardening-create-page-filament', + ]); + + [$user, $environment] = createUserWithTenant(tenant: $environment, role: 'owner'); + + Filament::setTenant($environment, true); + + $this->actingAs($user) + ->withSession([ + WorkspaceContext::SESSION_KEY => (int) $environment->workspace_id, + ]) + ->get('/admin/provider-connections/create') + ->assertForbidden(); +}); diff --git a/apps/platform/tests/Feature/ProviderConnections/ScopeHardeningPolicyWorkspaceAuthorityTest.php b/apps/platform/tests/Feature/ProviderConnections/ScopeHardeningPolicyWorkspaceAuthorityTest.php new file mode 100644 index 00000000..c91f5876 --- /dev/null +++ b/apps/platform/tests/Feature/ProviderConnections/ScopeHardeningPolicyWorkspaceAuthorityTest.php @@ -0,0 +1,32 @@ +active()->create([ + 'external_id' => 'spec339-scopehardening-policy-env', + ]); + + [$user, $environment] = createUserWithTenant(tenant: $environment, role: 'owner'); + + expect($user)->toBeInstanceOf(User::class); + + session()->forget(WorkspaceContext::SESSION_KEY); + + Filament::setTenant($environment, true); + + /** @var ProviderConnectionPolicy $policy */ + $policy = app(ProviderConnectionPolicy::class); + + $result = $policy->viewAny($user); + + expect($result)->toBeInstanceOf(Response::class); + expect($result->denied())->toBeTrue(); +}); diff --git a/apps/platform/tests/Feature/ProviderConnections/ScopeHardeningResourceWorkspaceAuthorityTest.php b/apps/platform/tests/Feature/ProviderConnections/ScopeHardeningResourceWorkspaceAuthorityTest.php new file mode 100644 index 00000000..e4b88163 --- /dev/null +++ b/apps/platform/tests/Feature/ProviderConnections/ScopeHardeningResourceWorkspaceAuthorityTest.php @@ -0,0 +1,35 @@ +active()->create([ + 'external_id' => 'spec339-scopehardening-resource-env', + ]); + + [$user, $environment] = createUserWithTenant(tenant: $environment, role: 'owner'); + + $connection = ProviderConnection::factory()->create([ + 'workspace_id' => (int) $environment->workspace_id, + 'managed_environment_id' => (int) $environment->getKey(), + 'display_name' => 'Spec339 ScopeHardening Connection', + 'provider' => 'microsoft', + ]); + + $this->actingAs($user); + + session()->forget(WorkspaceContext::SESSION_KEY); + + Filament::setTenant($environment, true); + + $component = Livewire::test(ListProviderConnections::class); + + expect($component->instance())->toBeNull(); +});