## Summary - enforce the canonical workspace/environment scope contract for workspace hubs and environment-owned surfaces - replace first-party Operations deep links that leaked Filament `tableFilters[...]` internals with stable product-level query behavior - add the sidebar scope indicator and split environment-page navigation into explicit `Workspace-wide` and `Workspace admin` groups - remove redundant tenantless `All environments` scope badges from workspace-wide pages while preserving explicit environment filter affordances - include the Spec 338 artifacts, guard tests, and browser smoke coverage for the new contract ## Validation - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Navigation/Spec338EnvironmentSidebarSeparationTest.php tests/Feature/Navigation/Spec338OperationRunLinksQueryContractTest.php tests/Feature/Navigation/Spec338SidebarScopeIndicatorTest.php tests/Feature/Filament/PanelNavigationSegregationTest.php` - `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec338ScopeContractSmokeTest.php --compact` ## Notes - Livewire v4 compliance unchanged - Filament provider registration remains in `bootstrap/providers.php` - no destructive action behavior changed - no migrations, env var changes, or new Filament asset registration Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #409
539 lines
18 KiB
PHP
539 lines
18 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support\OperateHub;
|
|
|
|
use App\Models\ManagedEnvironment;
|
|
use App\Models\User;
|
|
use App\Models\Workspace;
|
|
use App\Services\Auth\CapabilityResolver;
|
|
use App\Services\Tenants\TenantOperabilityService;
|
|
use App\Support\ManagedEnvironmentLinks;
|
|
use App\Support\Navigation\AdminSurfaceScope;
|
|
use App\Support\Navigation\WorkspaceHubRegistry;
|
|
use App\Support\Tenants\TenantOperabilityQuestion;
|
|
use App\Support\Workspaces\WorkspaceContext;
|
|
use Filament\Actions\Action;
|
|
use Filament\Facades\Filament;
|
|
use Illuminate\Http\Request;
|
|
|
|
final class OperateHubShell
|
|
{
|
|
private const string REQUEST_ATTRIBUTE = 'tenantpilot.resolved_shell_context';
|
|
|
|
public function __construct(
|
|
private WorkspaceContext $workspaceContext,
|
|
private CapabilityResolver $capabilityResolver,
|
|
private TenantOperabilityService $tenantOperabilityService,
|
|
) {}
|
|
|
|
public function scopeLabel(?Request $request = null): string
|
|
{
|
|
$activeEnvironment = $this->activeEntitledTenant($request);
|
|
|
|
if ($activeEnvironment instanceof ManagedEnvironment) {
|
|
return __('localization.shell.environment_scope').': '.$activeEnvironment->name;
|
|
}
|
|
|
|
return __('localization.shell.all_environments');
|
|
}
|
|
|
|
/**
|
|
* @return array{label: string, url: string}|null
|
|
*/
|
|
public function returnAffordance(?Request $request = null): ?array
|
|
{
|
|
$activeEnvironment = $this->activeEntitledTenant($request);
|
|
|
|
if ($activeEnvironment instanceof ManagedEnvironment) {
|
|
return [
|
|
'label' => 'Back to '.$activeEnvironment->name,
|
|
'url' => ManagedEnvironmentLinks::viewUrl($activeEnvironment),
|
|
];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @return array<Action>
|
|
*/
|
|
public function headerActions(
|
|
string $scopeActionName = 'operate_hub_scope',
|
|
string $returnActionName = 'operate_hub_return',
|
|
?Request $request = null,
|
|
): array {
|
|
$actions = [];
|
|
$activeEnvironment = $this->activeEntitledTenant($request);
|
|
|
|
if ($activeEnvironment instanceof ManagedEnvironment) {
|
|
$actions[] = Action::make($scopeActionName)
|
|
->label($this->scopeLabel($request))
|
|
->color('gray')
|
|
->disabled();
|
|
}
|
|
|
|
$returnAffordance = $this->returnAffordance($request);
|
|
|
|
if (is_array($returnAffordance)) {
|
|
$actions[] = Action::make($returnActionName)
|
|
->label($returnAffordance['label'])
|
|
->icon('heroicon-o-arrow-left')
|
|
->color('gray')
|
|
->url($returnAffordance['url']);
|
|
}
|
|
|
|
return $actions;
|
|
}
|
|
|
|
public function activeEntitledTenant(?Request $request = null): ?ManagedEnvironment
|
|
{
|
|
return $this->resolvedContext($request)->tenant;
|
|
}
|
|
|
|
public function tenantOwnedPanelContext(?Request $request = null): ?ManagedEnvironment
|
|
{
|
|
return $this->activeEntitledTenant($request);
|
|
}
|
|
|
|
public function resolvedContext(?Request $request = null): ResolvedShellContext
|
|
{
|
|
$request ??= request();
|
|
|
|
if ($request instanceof Request) {
|
|
$cached = $request->attributes->get(self::REQUEST_ATTRIBUTE);
|
|
|
|
if ($cached instanceof ResolvedShellContext) {
|
|
return $cached;
|
|
}
|
|
}
|
|
|
|
$resolved = $this->buildResolvedContext($request);
|
|
|
|
if ($request instanceof Request) {
|
|
$request->attributes->set(self::REQUEST_ATTRIBUTE, $resolved);
|
|
}
|
|
|
|
return $resolved;
|
|
}
|
|
|
|
private function buildResolvedContext(?Request $request = null): ResolvedShellContext
|
|
{
|
|
$pageCategory = $this->pageCategory($request);
|
|
$routeTenantCandidate = $this->resolveRouteTenantCandidate($request, $pageCategory);
|
|
$sessionWorkspace = $this->workspaceContext->currentWorkspace($request);
|
|
$workspace = $this->resolveWorkspaceForPageCategory($routeTenantCandidate, $pageCategory, $request);
|
|
|
|
$workspaceSource = match (true) {
|
|
$workspace instanceof Workspace && $sessionWorkspace instanceof Workspace && $workspace->is($sessionWorkspace) => 'session_workspace',
|
|
$workspace instanceof Workspace && $routeTenantCandidate instanceof ManagedEnvironment && (int) $routeTenantCandidate->workspace_id === (int) $workspace->getKey() => 'route',
|
|
default => 'none',
|
|
};
|
|
|
|
if (! $workspace instanceof Workspace) {
|
|
return new ResolvedShellContext(
|
|
workspace: null,
|
|
tenant: null,
|
|
pageCategory: $pageCategory,
|
|
state: 'missing_workspace',
|
|
displayMode: 'recovery',
|
|
recoveryAction: $pageCategory === AdminSurfaceScope::WorkspaceChooserException ? 'none' : 'redirect_choose_workspace',
|
|
recoveryDestination: '/admin/choose-workspace',
|
|
recoveryReason: 'missing_workspace',
|
|
);
|
|
}
|
|
|
|
$routeTenant = $this->resolveValidatedRouteTenant($routeTenantCandidate, $workspace, $request, $pageCategory);
|
|
|
|
if ($routeTenant['tenant'] instanceof ManagedEnvironment) {
|
|
return new ResolvedShellContext(
|
|
workspace: $workspace,
|
|
tenant: $routeTenant['tenant'],
|
|
pageCategory: $pageCategory,
|
|
state: 'tenant_scoped',
|
|
displayMode: 'tenant_scoped',
|
|
workspaceSource: $workspaceSource,
|
|
tenantSource: 'route',
|
|
);
|
|
}
|
|
|
|
$recoveryReason = $routeTenant['reason'];
|
|
|
|
if ($pageCategory === AdminSurfaceScope::EnvironmentBound && $recoveryReason !== null) {
|
|
return new ResolvedShellContext(
|
|
workspace: $workspace,
|
|
tenant: null,
|
|
pageCategory: $pageCategory,
|
|
state: 'invalid_tenant',
|
|
displayMode: 'recovery',
|
|
workspaceSource: $workspaceSource,
|
|
recoveryAction: 'abort_not_found',
|
|
recoveryReason: $recoveryReason,
|
|
);
|
|
}
|
|
|
|
if ($pageCategory->forcesEnvironmentlessShellContext()) {
|
|
return new ResolvedShellContext(
|
|
workspace: $workspace,
|
|
tenant: null,
|
|
pageCategory: $pageCategory,
|
|
state: 'tenantless_workspace',
|
|
displayMode: 'tenantless',
|
|
workspaceSource: $workspaceSource,
|
|
recoveryReason: $recoveryReason,
|
|
);
|
|
}
|
|
|
|
$queryHintTenant = $this->resolveValidatedQueryHintTenant($request, $workspace, $pageCategory);
|
|
|
|
if ($queryHintTenant['tenant'] instanceof ManagedEnvironment) {
|
|
return new ResolvedShellContext(
|
|
workspace: $workspace,
|
|
tenant: $queryHintTenant['tenant'],
|
|
pageCategory: $pageCategory,
|
|
state: 'tenant_scoped',
|
|
displayMode: 'tenant_scoped',
|
|
workspaceSource: $workspaceSource,
|
|
tenantSource: 'query_hint',
|
|
);
|
|
}
|
|
|
|
$recoveryReason ??= $queryHintTenant['reason'];
|
|
|
|
if ($this->requiresStrictQueryTenantHintResolution($request)) {
|
|
return new ResolvedShellContext(
|
|
workspace: $workspace,
|
|
tenant: null,
|
|
pageCategory: $pageCategory,
|
|
state: 'invalid_tenant',
|
|
displayMode: 'recovery',
|
|
workspaceSource: $workspaceSource,
|
|
recoveryAction: 'abort_not_found',
|
|
recoveryReason: $recoveryReason ?? 'missing_tenant',
|
|
);
|
|
}
|
|
|
|
$tenant = $this->resolveValidatedFilamentTenant($request, $pageCategory, $workspace);
|
|
|
|
if ($tenant instanceof ManagedEnvironment) {
|
|
return new ResolvedShellContext(
|
|
workspace: $workspace,
|
|
tenant: $tenant,
|
|
pageCategory: $pageCategory,
|
|
state: 'tenant_scoped',
|
|
displayMode: 'tenant_scoped',
|
|
workspaceSource: $workspaceSource,
|
|
tenantSource: 'filament_tenant',
|
|
);
|
|
}
|
|
|
|
if ($pageCategory->allowsRememberedEnvironmentRestore()) {
|
|
$rememberedEnvironment = $this->workspaceContext->rememberedEnvironment($request);
|
|
|
|
if ($rememberedEnvironment instanceof ManagedEnvironment) {
|
|
return new ResolvedShellContext(
|
|
workspace: $workspace,
|
|
tenant: $rememberedEnvironment,
|
|
pageCategory: $pageCategory,
|
|
state: 'tenant_scoped',
|
|
displayMode: 'tenant_scoped',
|
|
workspaceSource: $workspaceSource,
|
|
tenantSource: 'remembered',
|
|
);
|
|
}
|
|
}
|
|
|
|
if ($pageCategory->requiresExplicitEnvironment()) {
|
|
return new ResolvedShellContext(
|
|
workspace: $workspace,
|
|
tenant: null,
|
|
pageCategory: $pageCategory,
|
|
state: 'missing_tenant',
|
|
displayMode: 'recovery',
|
|
workspaceSource: $workspaceSource,
|
|
recoveryAction: 'abort_not_found',
|
|
recoveryDestination: null,
|
|
recoveryReason: $recoveryReason ?? 'missing_tenant',
|
|
);
|
|
}
|
|
|
|
return new ResolvedShellContext(
|
|
workspace: $workspace,
|
|
tenant: null,
|
|
pageCategory: $pageCategory,
|
|
state: 'tenantless_workspace',
|
|
displayMode: 'tenantless',
|
|
workspaceSource: $workspaceSource,
|
|
recoveryReason: $recoveryReason,
|
|
);
|
|
}
|
|
|
|
private function resolveValidatedFilamentTenant(
|
|
?Request $request = null,
|
|
?AdminSurfaceScope $pageCategory = null,
|
|
?Workspace $workspace = null,
|
|
): ?ManagedEnvironment {
|
|
$tenant = Filament::getTenant();
|
|
|
|
if (! $tenant instanceof ManagedEnvironment) {
|
|
return null;
|
|
}
|
|
|
|
$pageCategory ??= $this->pageCategory($request);
|
|
$workspace ??= $this->resolveWorkspaceForPageCategory($tenant, $pageCategory, $request);
|
|
|
|
if ($workspace instanceof Workspace && $this->tenantValidationReason($tenant, $workspace, $request, $pageCategory) === null) {
|
|
return $tenant;
|
|
}
|
|
|
|
Filament::setTenant(null, true);
|
|
|
|
return null;
|
|
}
|
|
|
|
private function resolveValidatedRouteTenant(
|
|
?ManagedEnvironment $tenant,
|
|
Workspace $workspace,
|
|
?Request $request = null,
|
|
?AdminSurfaceScope $pageCategory = null,
|
|
): array {
|
|
$pageCategory ??= $this->pageCategory($request);
|
|
|
|
if (! $tenant instanceof ManagedEnvironment) {
|
|
return ['tenant' => null, 'reason' => null];
|
|
}
|
|
|
|
$reason = $this->tenantValidationReason($tenant, $workspace, $request, $pageCategory);
|
|
|
|
if ($reason !== null) {
|
|
return ['tenant' => null, 'reason' => $reason];
|
|
}
|
|
|
|
return ['tenant' => $tenant, 'reason' => null];
|
|
}
|
|
|
|
private function resolveWorkspaceForPageCategory(
|
|
?ManagedEnvironment $tenant,
|
|
AdminSurfaceScope $pageCategory,
|
|
?Request $request = null,
|
|
): ?Workspace {
|
|
return match ($pageCategory) {
|
|
AdminSurfaceScope::EnvironmentBound => $this->workspaceContext->currentWorkspaceOrEnvironmentWorkspace($tenant, $request),
|
|
default => $this->workspaceContext->currentWorkspaceOrEnvironmentWorkspace($tenant, $request),
|
|
};
|
|
}
|
|
|
|
private function resolveValidatedQueryHintTenant(
|
|
?Request $request,
|
|
Workspace $workspace,
|
|
AdminSurfaceScope $pageCategory,
|
|
): array {
|
|
if (! $pageCategory->allowsQueryEnvironmentHints()) {
|
|
return ['tenant' => null, 'reason' => null];
|
|
}
|
|
|
|
$queryTenant = $this->resolveQueryTenantHint($request);
|
|
|
|
if (! $queryTenant instanceof ManagedEnvironment) {
|
|
return ['tenant' => null, 'reason' => null];
|
|
}
|
|
|
|
$reason = $this->tenantValidationReason($queryTenant, $workspace, $request, $pageCategory);
|
|
|
|
if ($reason !== null) {
|
|
return ['tenant' => null, 'reason' => $reason];
|
|
}
|
|
|
|
return ['tenant' => $queryTenant, 'reason' => null];
|
|
}
|
|
|
|
private function resolveRouteTenantCandidate(?Request $request = null, ?AdminSurfaceScope $pageCategory = null): ?ManagedEnvironment
|
|
{
|
|
$route = $request?->route();
|
|
$pageCategory ??= $this->pageCategory($request);
|
|
|
|
if ($route?->hasParameter('environment')) {
|
|
return $this->resolveTenantIdentifier($route->parameter('environment'));
|
|
}
|
|
|
|
if ($route?->hasParameter('tenant')) {
|
|
return $this->resolveTenantIdentifier($route->parameter('tenant'));
|
|
}
|
|
|
|
$refererEnvironment = $this->resolveRefererTenantCandidate($request, $pageCategory);
|
|
|
|
if ($refererEnvironment instanceof ManagedEnvironment) {
|
|
return $refererEnvironment;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function resolveRefererTenantCandidate(?Request $request, AdminSurfaceScope $pageCategory): ?ManagedEnvironment
|
|
{
|
|
if (! $request instanceof Request) {
|
|
return null;
|
|
}
|
|
|
|
$path = '/'.ltrim((string) $request->path(), '/');
|
|
|
|
$isLivewireUpdateRequest = preg_match('#^/(?:livewire(?:-[^/]+)?/update|livewire-unit-test-endpoint)(?:/|$)#', $path) === 1
|
|
|| $request->headers->has('x-livewire');
|
|
|
|
if (! $isLivewireUpdateRequest) {
|
|
return null;
|
|
}
|
|
|
|
if ($pageCategory !== AdminSurfaceScope::EnvironmentBound) {
|
|
return null;
|
|
}
|
|
|
|
$refererPath = parse_url((string) $request->headers->get('referer', ''), PHP_URL_PATH);
|
|
|
|
if (! is_string($refererPath) || $refererPath === '') {
|
|
return null;
|
|
}
|
|
|
|
$normalizedRefererPath = '/'.ltrim($refererPath, '/');
|
|
|
|
$matches = [];
|
|
|
|
if (preg_match('#^/admin/workspaces/[^/]+/environments/([^/]+)(?:/|$)#', $normalizedRefererPath, $matches) !== 1) {
|
|
return null;
|
|
}
|
|
|
|
$environmentIdentifier = $matches[1] ?? null;
|
|
|
|
if (! is_string($environmentIdentifier) || $environmentIdentifier === '') {
|
|
return null;
|
|
}
|
|
|
|
return $this->resolveTenantIdentifier($environmentIdentifier);
|
|
}
|
|
|
|
private function resolveQueryTenantHint(?Request $request = null): ?ManagedEnvironment
|
|
{
|
|
if ($request instanceof Request && WorkspaceHubRegistry::isWorkspaceHubPath('/'.ltrim((string) $request->path(), '/'))) {
|
|
return null;
|
|
}
|
|
|
|
$queryTenant = $request?->query('tenant');
|
|
|
|
if (filled($queryTenant)) {
|
|
return $this->resolveTenantIdentifier($queryTenant);
|
|
}
|
|
|
|
$queryTenantId = $request?->query('managed_environment_id');
|
|
|
|
if (filled($queryTenantId)) {
|
|
return $this->resolveTenantIdentifier($queryTenantId);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function hasExplicitQueryTenantHint(?Request $request = null): bool
|
|
{
|
|
if ($request instanceof Request && WorkspaceHubRegistry::isWorkspaceHubPath('/'.ltrim((string) $request->path(), '/'))) {
|
|
return false;
|
|
}
|
|
|
|
return filled($request?->query('tenant')) || filled($request?->query('managed_environment_id'));
|
|
}
|
|
|
|
private function requiresStrictQueryTenantHintResolution(?Request $request = null): bool
|
|
{
|
|
if (! $this->hasExplicitQueryTenantHint($request)) {
|
|
return false;
|
|
}
|
|
|
|
$path = '/'.ltrim((string) $request?->path(), '/');
|
|
|
|
if (! str_starts_with($path, '/admin/')) {
|
|
return false;
|
|
}
|
|
|
|
if (str_starts_with($path, '/admin/finding-exceptions/queue')) {
|
|
return false;
|
|
}
|
|
|
|
return preg_match('#^/admin/(inventory|policies|policy-versions|backup-sets|backup-schedules|findings|finding-exceptions)(/|$)#', $path) === 1;
|
|
}
|
|
|
|
private function resolveTenantIdentifier(mixed $tenantIdentifier): ?ManagedEnvironment
|
|
{
|
|
if ($tenantIdentifier instanceof ManagedEnvironment) {
|
|
return $tenantIdentifier;
|
|
}
|
|
|
|
$tenantIdentifier = trim((string) $tenantIdentifier);
|
|
|
|
if ($tenantIdentifier === '') {
|
|
return null;
|
|
}
|
|
|
|
$tenantKeyColumn = (new ManagedEnvironment)->getQualifiedKeyName();
|
|
|
|
return ManagedEnvironment::query()
|
|
->withTrashed()
|
|
->where(static function ($query) use ($tenantIdentifier, $tenantKeyColumn): void {
|
|
$query->where('slug', $tenantIdentifier);
|
|
|
|
if (ctype_digit($tenantIdentifier)) {
|
|
$query->orWhere($tenantKeyColumn, (int) $tenantIdentifier);
|
|
}
|
|
})
|
|
->first();
|
|
}
|
|
|
|
private function tenantValidationReason(
|
|
ManagedEnvironment $tenant,
|
|
Workspace $workspace,
|
|
?Request $request = null,
|
|
?AdminSurfaceScope $pageCategory = null,
|
|
): ?string {
|
|
$pageCategory ??= $this->pageCategory($request);
|
|
|
|
if ((int) $tenant->workspace_id !== (int) $workspace->getKey()) {
|
|
return 'mismatched_workspace';
|
|
}
|
|
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
return 'not_member';
|
|
}
|
|
|
|
if (! $this->capabilityResolver->isMember($user, $tenant)) {
|
|
return 'not_member';
|
|
}
|
|
|
|
$question = $pageCategory === AdminSurfaceScope::EnvironmentBound
|
|
? TenantOperabilityQuestion::EnvironmentBoundViewability
|
|
: TenantOperabilityQuestion::AdministrativeDiscoverability;
|
|
|
|
$allowed = $this->tenantOperabilityService->outcomeFor(
|
|
tenant: $tenant,
|
|
question: $question,
|
|
actor: $user,
|
|
workspaceId: (int) $workspace->getKey(),
|
|
lane: $pageCategory->lane(),
|
|
selectedTenant: Filament::getTenant() instanceof ManagedEnvironment ? Filament::getTenant() : null,
|
|
)->allowed;
|
|
|
|
if ($allowed) {
|
|
return null;
|
|
}
|
|
|
|
return $pageCategory === AdminSurfaceScope::EnvironmentBound
|
|
? 'inaccessible'
|
|
: 'not_operable';
|
|
}
|
|
|
|
private function pageCategory(?Request $request = null): AdminSurfaceScope
|
|
{
|
|
return AdminSurfaceScope::fromRequest($request);
|
|
}
|
|
}
|