feat: canonicalize admin scope links and queries (341) (#413)

## Summary
- remove remaining legacy scope query hint parsing from shared workspace and environment scoping seams so hubs only narrow via explicit `environment_id`
- align canonical link generation across workspace hubs, provider connections, audit log, alerts, and decision register flows
- add focused Spec 341 regression coverage for canonical link/query behavior and legacy alias rejection
- include the Spec 341 artifacts and move the review screenshots into `specs/341-canonical-link-query-cleanup/artifacts/screenshots/`
- ignore local `.playwright-mcp` browser tool output so it does not pollute future commits or pull requests

## Validation
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Navigation --filter=Spec341`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Navigation/Spec341CanonicalLinkQueryCleanupTest.php tests/Feature/Navigation/WorkspaceHubEnvironmentFilterContractTest.php tests/Feature/ProviderConnections/ProviderConnectionsWorkspaceHubContractTest.php`
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- `git diff --check`

## Notes
- Livewire v4 compliance unchanged
- Filament provider registration remains in `apps/platform/bootstrap/providers.php`
- no globally searchable resource behavior was changed in this slice
- no destructive action behavior was changed
- no new Filament assets; deploy `filament:assets` posture is unchanged

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #413
This commit is contained in:
ahmido 2026-05-31 22:46:39 +00:00
parent a3b21c48d8
commit e324bd7bd6
20 changed files with 669 additions and 136 deletions

1
.gitignore vendored
View File

@ -56,6 +56,7 @@ Thumbs.db
/references
/tests/Browser/Screenshots
/apps/platform/tests/Browser/Screenshots
/.playwright-mcp
*.tmp
*.swp
/apps/platform/.env

View File

@ -247,14 +247,6 @@ protected static function resolveScopedTenant(ManagedEnvironment|string|null $te
->first();
}
$queryTenant = request()->query('tenant');
if (is_string($queryTenant) && $queryTenant !== '') {
return ManagedEnvironment::query()
->where('slug', $queryTenant)
->first();
}
return null;
}

View File

@ -332,6 +332,11 @@ public function table(Table $table): Table
->visible(fn (): bool => $this->registerState === 'recently_closed')
->wrap(),
])
->filters([
Tables\Filters\SelectFilter::make('managed_environment_id')
->label('Environment')
->options(fn (): array => $this->tenantFilterOptions()),
])
->emptyStateHeading($this->emptyStateHeading())
->emptyStateDescription($this->emptyStateDescription())
->emptyStateActions($this->emptyStateActions());
@ -476,6 +481,21 @@ private function visibleDecisionTenants(): array
return $this->visibleDecisionTenants = static::resolveVisibleDecisionTenantsFor($user, $workspace, $tenants);
}
/**
* @return array<string, string>
*/
private function tenantFilterOptions(): array
{
$options = [];
foreach ($this->visibleDecisionTenants() as $tenant) {
$label = (string) ($tenant->name ?: $tenant->slug ?: ('Environment '.(int) $tenant->getKey()));
$options[(string) $tenant->getKey()] = $label;
}
return $options;
}
private function applyRequestedTenantPrefilter(): void
{
$workspace = $this->workspace();
@ -495,6 +515,8 @@ private function applyRequestedTenantPrefilter(): void
foreach ($this->visibleDecisionTenants() as $tenant) {
if ((int) $tenant->getKey() === $environmentId) {
$this->tenantId = $environmentId;
$this->tableFilters['managed_environment_id']['value'] = (string) $environmentId;
$this->tableDeferredFilters['managed_environment_id']['value'] = (string) $environmentId;
return;
}

View File

@ -289,7 +289,7 @@ public function table(Table $table): Table
->searchable()
->toggleable(),
TextColumn::make('tenant.name')
->label('ManagedEnvironment')
->label('Environment')
->formatStateUsing(fn (?string $state): string => $state ?: 'Workspace')
->toggleable(),
TextColumn::make('recorded_at')
@ -299,7 +299,7 @@ public function table(Table $table): Table
])
->filters([
SelectFilter::make('managed_environment_id')
->label('ManagedEnvironment')
->label('Environment')
->options(fn (): array => $this->tenantFilterOptions())
->default(fn (): ?string => $this->defaultTenantFilter())
->searchable(),

View File

@ -185,7 +185,7 @@ public static function infolist(Schema $schema): Schema
->formatStateUsing(fn (?string $state): string => ucfirst((string) $state))
->placeholder('—'),
TextEntry::make('tenant.name')
->label('ManagedEnvironment'),
->label('Environment'),
TextEntry::make('rule.name')
->label('Rule')
->placeholder('—'),
@ -246,7 +246,7 @@ public static function table(Table $table): Table
->since()
->sortable(),
TextColumn::make('tenant.name')
->label('ManagedEnvironment')
->label('Environment')
->searchable(),
TextColumn::make('event_type')
->label('Event')
@ -274,7 +274,7 @@ public static function table(Table $table): Table
])
->filters([
SelectFilter::make('managed_environment_id')
->label('ManagedEnvironment')
->label('Environment')
->options(function (): array {
$user = auth()->user();

View File

@ -8,6 +8,7 @@
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
use Filament\Schemas\Components\EmbeddedTable;
@ -28,6 +29,7 @@ public function mount(): void
parent::mount();
$this->resetWorkspaceHubEnvironmentFilterStateForCleanEntry(request());
$this->applyRequestedEnvironmentFilter();
}
private function tableHasRecords(): bool
@ -243,6 +245,34 @@ private function resolveTenantExternalIdForCreateAction(): ?string
return null;
}
private function applyRequestedEnvironmentFilter(): void
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if (! is_int($workspaceId)) {
return;
}
$environment = ProviderConnectionResource::resolveRequestedEnvironment();
if (! $environment instanceof ManagedEnvironment) {
return;
}
if ((int) $environment->workspace_id !== $workspaceId) {
abort(404);
}
$slug = (string) $environment->slug;
if ($slug === '') {
return;
}
$this->tableFilters['tenant']['value'] = $slug;
$this->tableDeferredFilters['tenant']['value'] = $slug;
}
/**
* @return array{label: string, clear_url: string}|null
*/

View File

@ -214,10 +214,6 @@ private function requestHasExplicitTenantContext(Request $request): bool
return false;
}
if (filled($request->query('tenant')) || filled($request->query('managed_environment_id'))) {
return true;
}
$route = $request->route();
return $route?->hasParameter('tenant') && filled($route->parameter('tenant'));

View File

@ -6,7 +6,6 @@
use App\Models\User;
use App\Support\Navigation\AdminSurfaceScope;
use App\Support\Navigation\NavigationScope;
use App\Support\Navigation\WorkspaceHubRegistry;
use App\Support\Navigation\WorkspaceSidebarNavigation;
use App\Support\OperateHub\OperateHubShell;
use App\Support\Workspaces\WorkspaceContext;
@ -87,10 +86,6 @@ public function handle(Request $request, Closure $next): Response
! $resolvedContext->hasTenant()
&& $this->adminPathRequiresTenantSelection($path)
) {
if ($this->requestHasExplicitTenantHint($request)) {
abort(404);
}
$workspace = $workspaceContext->currentWorkspace($request);
if ($workspace !== null) {
@ -178,15 +173,6 @@ private function isCanonicalWorkspaceRecordViewerPath(string $path): bool
return AdminSurfaceScope::fromPath($path) === AdminSurfaceScope::CanonicalWorkspaceRecordViewer;
}
private function requestHasExplicitTenantHint(Request $request): bool
{
if (WorkspaceHubRegistry::isWorkspaceHubPath('/'.ltrim((string) $request->path(), '/'))) {
return false;
}
return filled($request->query('tenant')) || filled($request->query('managed_environment_id'));
}
private function adminPathRequiresTenantSelection(string $path): bool
{
if (! str_starts_with($path, '/admin/')) {

View File

@ -5,6 +5,7 @@
namespace App\Support\Navigation;
use App\Models\ManagedEnvironment;
use App\Support\OperateHub\OperateHubShell;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
@ -50,6 +51,12 @@ public static function environmentContext(): ?ManagedEnvironment
return null;
}
$resolved = app(OperateHubShell::class)->resolvedContext(request());
if ($resolved->tenant instanceof ManagedEnvironment) {
return $resolved->tenant;
}
$tenant = Filament::getTenant();
if ($tenant instanceof ManagedEnvironment) {

View File

@ -11,7 +11,6 @@
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;
@ -185,35 +184,6 @@ private function buildResolvedContext(?Request $request = null): ResolvedShellCo
);
}
$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) {
@ -324,30 +294,6 @@ private function resolveWorkspaceForPageCategory(
};
}
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();
@ -412,55 +358,6 @@ private function resolveRefererTenantCandidate(?Request $request, AdminSurfaceSc
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) {

View File

@ -110,7 +110,7 @@
$query->where('slug', $identifier);
if (ctype_digit($identifier)) {
$query->orWhereKey((int) $identifier);
$query->orWhere('id', (int) $identifier);
}
})
->first();
@ -132,7 +132,7 @@
$query->where('slug', $identifier);
if (ctype_digit($identifier)) {
$query->orWhereKey((int) $identifier);
$query->orWhere('id', (int) $identifier);
}
})
->first();

View File

@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\EnvironmentRequiredPermissions;
use App\Models\ManagedEnvironment;
use App\Support\ManagedEnvironmentLinks;
use App\Support\Navigation\WorkspaceHubNavigation;
use App\Support\OperateHub\OperateHubShell;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Http\Request;
it('Spec341 OperateHubShell ignores legacy tenant query hints', function (): void {
$environment = ManagedEnvironment::factory()->active()->create([
'name' => 'Spec341 Environment A',
'external_id' => 'spec341-environment-a',
]);
[$user, $environment] = createUserWithTenant(tenant: $environment, role: 'owner', workspaceRole: 'owner');
$workspace = $environment->workspace()->firstOrFail();
$this->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
session()->forget(WorkspaceContext::LAST_ENVIRONMENT_IDS_SESSION_KEY);
$shell = app(OperateHubShell::class);
$queryCases = [
'tenant' => ['tenant' => (string) $environment->getRouteKey()],
'managed_environment_id' => ['managed_environment_id' => (int) $environment->getKey()],
];
foreach ($queryCases as $query) {
$request = Request::create('/admin/onboarding', 'GET', $query);
$request->setUserResolver(fn () => $user);
$resolved = $shell->resolvedContext($request);
expect($resolved->hasTenant())->toBeFalse()
->and($resolved->tenantSource)->not->toBe('query_hint');
}
});
it('Spec341 EnvironmentRequiredPermissions does not resolve tenant from legacy query keys', function (): void {
$environment = ManagedEnvironment::factory()->active()->create([
'name' => 'Spec341 Permissions Environment',
'external_id' => 'spec341-permissions-environment',
]);
$originalRequest = app('request');
try {
$request = Request::create('/livewire/update', 'GET', [
'tenant' => (string) $environment->getRouteKey(),
]);
app()->instance('request', $request);
$method = new ReflectionMethod(EnvironmentRequiredPermissions::class, 'resolveScopedTenant');
$method->setAccessible(true);
/** @var ManagedEnvironment|null $resolvedTenant */
$resolvedTenant = $method->invoke(null, null);
expect($resolvedTenant)->toBeNull();
} finally {
app()->instance('request', $originalRequest);
}
});
it('Spec341 environment-bound routes remain route-owned even when legacy tenant query hints are present', function (): void {
$environmentA = ManagedEnvironment::factory()->active()->create([
'name' => 'Spec341 Baseline Environment A',
'external_id' => 'spec341-baseline-environment-a',
]);
[$user, $environmentA] = createUserWithTenant(tenant: $environmentA, role: 'owner', workspaceRole: 'owner');
$environmentB = ManagedEnvironment::factory()->active()->create([
'workspace_id' => (int) $environmentA->workspace_id,
'name' => 'Spec341 Baseline Environment B',
'external_id' => 'spec341-baseline-environment-b',
]);
createUserWithTenant(tenant: $environmentB, user: $user, role: 'owner', workspaceRole: 'owner');
$this->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $environmentA->workspace_id);
baselineCompareLandingLivewire($environmentB, ['tenant' => (string) $environmentA->getRouteKey()], $user)
->assertSet('scopedEnvironmentId', (int) $environmentB->getKey());
});
it('Spec341 WorkspaceHubNavigation carries route-owned environment context into workspace hub URLs', function (): void {
$environment = ManagedEnvironment::factory()->active()->create([
'name' => 'Spec341 Hub Navigation Environment',
'external_id' => 'spec341-hub-navigation-environment',
]);
[$user, $environment] = createUserWithTenant(tenant: $environment, role: 'owner', workspaceRole: 'owner');
$workspace = $environment->workspace()->firstOrFail();
$this->actingAs($user);
setAdminPanelContext();
Filament::setTenant(null, true);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
session()->forget(WorkspaceContext::LAST_ENVIRONMENT_IDS_SESSION_KEY);
$originalRequest = app('request');
try {
$workspaceKey = ManagedEnvironmentLinks::workspaceRouteKey($workspace);
$environmentKey = ManagedEnvironmentLinks::environmentRouteKey($environment);
$request = Request::create('/livewire/update', 'POST');
$request->headers->set('x-livewire', '1');
$request->headers->set('referer', url("/admin/workspaces/{$workspaceKey}/environments/{$environmentKey}/required-permissions"));
$request->setUserResolver(fn () => $user);
app()->instance('request', $request);
$url = WorkspaceHubNavigation::environmentFilteredUrl(url('/admin/provider-connections'));
$query = [];
parse_str((string) parse_url($url, PHP_URL_QUERY), $query);
expect((int) ($query['environment_id'] ?? 0))->toBe((int) $environment->getKey());
} finally {
app()->instance('request', $originalRequest);
}
});

View File

@ -110,6 +110,12 @@
->assertSee($environmentA->name)
->assertDontSee('Spec315 Decision B');
Livewire::withQueryParams(['environment_id' => (int) $environmentA->getKey()])
->actingAs($user)
->test(DecisionRegister::class)
->assertSet('tableFilters.managed_environment_id.value', (string) $environmentA->getKey())
->assertSet('tableDeferredFilters.managed_environment_id.value', (string) $environmentA->getKey());
$this->get(route('admin.evidence.overview', ['environment_id' => (int) $environmentA->getKey()]))
->assertOk()
->assertSee('Environment filter:')

View File

@ -116,3 +116,26 @@
expect(data_get(session()->get($filtersSessionKey, []), 'tenant.value'))->toBeNull();
});
it('Spec341 provider connections translates environment_id query into an active table filter', function (): void {
$environment = ManagedEnvironment::factory()->active()->create([
'external_id' => 'spec341-provider-connections-env',
]);
[$user, $environment] = createUserWithTenant(tenant: $environment, role: 'owner');
ProviderConnection::factory()->create([
'workspace_id' => (int) $environment->workspace_id,
'managed_environment_id' => (int) $environment->getKey(),
'display_name' => 'Spec341 Provider Filtered',
]);
$this->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $environment->workspace_id);
Livewire::actingAs($user)
->withQueryParams(['environment_id' => (int) $environment->getKey()])
->test(ListProviderConnections::class)
->assertSet('tableFilters.tenant.value', (string) $environment->slug)
->assertSet('tableDeferredFilters.tenant.value', (string) $environment->slug);
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

View File

@ -0,0 +1,48 @@
# Specification Quality Checklist: Spec 341 - Canonical Link / Query Cleanup
**Purpose**: Validate Spec 341 preparation completeness before implementation.
**Created**: 2026-05-31
**Feature**: `specs/341-canonical-link-query-cleanup/spec.md`
## Candidate Selection Gate
- [x] CHK001 The selected candidate matches `canonical-link-query-cleanup` in `docs/product/spec-candidates.md`.
- [x] CHK002 Related specs were checked for completed-spec signals and are treated as context only (no rewrites).
- [x] CHK003 The package does not overwrite an existing `specs/341-*` directory.
- [x] CHK004 Close alternatives (`environment-resource-context-follow-through`, `product-truth-docs-drift-cleanup`) are deferred instead of hidden scope.
- [x] CHK005 The spec is scoped as link/query contract hygiene (no feature expansion).
## Content Quality
- [x] CHK006 The spec has a concrete problem statement, user value, and explicit non-goals.
- [x] CHK007 The spec includes acceptance criteria, out-of-scope boundaries, assumptions, risks, and open questions.
- [x] CHK008 The spec avoids new persistence, new abstractions, or frameworking without proportionality justification.
- [x] CHK009 No placeholder markers (`[FEATURE]`, `[DATE]`, `NEEDS CLARIFICATION`) remain in `spec.md`, `plan.md`, or `tasks.md`.
- [x] CHK010 The specs scope is small enough for a bounded implementation loop.
## Constitution And Scope
- [x] CHK011 The Spec Candidate Check is filled and scored above the approval threshold.
- [x] CHK012 Pre-production posture is respected: no compatibility redirects or legacy alias preservation “just in case”.
- [x] CHK013 Workspace/environment isolation and deny-as-not-found semantics are explicitly preserved.
- [x] CHK014 UI/Productization Coverage is completed as “no new surface; contract hardening only”.
## Plan Quality
- [x] CHK015 `plan.md` is repo-aware and lists the expected touched seams.
- [x] CHK016 The plan sequences work as inventory → failing tests → behavior change → regression guards → validation.
- [x] CHK017 Test governance is explicit: Feature lane first; browser only if justified.
- [x] CHK018 Deployment impact is explicitly none (no migrations/env/queues/assets).
## Task Quality
- [x] CHK019 `tasks.md` exists and is dependency-ordered into small phases.
- [x] CHK020 Tasks reference concrete repo files and do not invent new architecture.
- [x] CHK021 Tasks include explicit validation commands and formatting checks.
- [x] CHK022 Tasks explicitly forbid schema/persistence/framework scope expansion.
## Spec Readiness Gate
- [x] CHK023 `spec.md`, `plan.md`, and `tasks.md` exist.
- [x] CHK024 No open question blocks safe implementation (inventory-driven follow-ups are allowed).
- [x] CHK025 Result: ready for implementation loop.

View File

@ -0,0 +1,111 @@
# Implementation Plan: Spec 341 - Canonical Link / Query Cleanup
**Branch**: `341-canonical-link-query-cleanup` | **Date**: 2026-05-31 | **Spec**: `specs/341-canonical-link-query-cleanup/spec.md`
**Input**: Candidate `canonical-link-query-cleanup` from `docs/product/spec-candidates.md` + repo inspection of legacy scope query parsing seams.
## Summary
Inventory and remove remaining legacy “scope hint” query parsing (`tenant`, `managed_environment_id`, etc.) in shared request-scoping seams and align canonical link generation so that:
- workspace hubs are workspace-wide unless explicitly filtered via `environment_id`;
- environment-bound pages are route-owned and do not accept legacy query aliases as authority; and
- “clear filter” behavior returns to clean canonical URLs.
This is contract hardening; it must not introduce new abstractions, persistence, or UI frameworks.
## Technical Context
- **Language/Version**: PHP 8.4.15, Laravel 12.52.x
- **Primary Dependencies**: Filament v5, Livewire v4, Pest v4
- **Storage**: PostgreSQL (no schema changes planned)
- **Testing**: Pest Feature tests (navigation + scope contracts)
- **Validation Lanes**: fast-feedback (Feature); browser only if later proven necessary for visible UX behaviors that cannot be asserted reliably in Feature tests
- **Target Platform**: `apps/platform` (Sail locally; Dokploy/container posture unchanged)
- **Constraints**:
- no compatibility redirects or “legacy alias preservation”
- no new packages, migrations, queues, scheduler, storage changes
- deny-as-not-found semantics remain authoritative
## UI / Surface Guardrail Plan
- **Guardrail scope**: existing reachable surfaces; navigation + scope contract hardening
- **Affected routes/pages/actions/states** (inventory-driven):
- workspace hubs that accept environment narrowing via `environment_id` and expose “clear filter” behavior
- environment-bound pages under `/admin/workspaces/{workspace}/environments/{environment}/...` that must remain route-owned
- shared request-scoping seams that must not treat legacy query keys as authority
- **State layers in scope**: URL/query + route parameters + session workspace context
- **Handling mode**: review-mandatory (scope/authority semantics)
- **Required tests or manual smoke**: Feature contract tests for clean vs filtered hub URLs and legacy alias rejection/ignore behavior; browser smoke only if required later
- **UI/Productization coverage decision**: existing pages changed / navigation semantics changed; no new reachable surface added; do not update `docs/ui-ux-enterprise-audit/*` unless implementation materially changes navigation entries or routes (not expected)
## Shared Pattern & System Fit
- **Cross-cutting feature marker**: yes
- **Systems touched** (expected):
- `apps/platform/app/Support/Navigation/WorkspaceHubNavigation.php`
- `apps/platform/app/Support/Navigation/WorkspaceHubEnvironmentFilter.php`
- `apps/platform/app/Filament/Concerns/UsesAdminEnvironmentFilterQueryParameter.php`
- `apps/platform/app/Http/Middleware/EnsureWorkspaceSelected.php`
- `apps/platform/app/Support/Middleware/EnsureEnvironmentContextSelected.php`
- `apps/platform/app/Support/OperateHub/OperateHubShell.php`
- `apps/platform/tests/Feature/Navigation/*`
- **Shared abstractions reused**: existing hub filter contract helpers, `WorkspaceContext`, deny-as-not-found semantics, existing guard-test patterns
- **New abstraction introduced?**: none
- **Spread control**: inventory-driven; limit changes to URL/query scope semantics and canonical link generation only
## OperationRun UX Impact
N/A — no `OperationRun` start/completion/link UX changes.
## Implementation Approach
### Phase 1 — Repo truth inventory + failing tests first
- inventory every remaining legacy scope query parsing seam (query keys only, not DB naming)
- add/adjust Feature tests that fail on todays behavior and encode the intended contract
### Phase 2 — Remove legacy query scope hint parsing in shared seams
- remove or strictly ignore legacy scope hints in:
- `EnsureWorkspaceSelected`
- `EnsureEnvironmentContextSelected`
- `OperateHubShell`
- `EnvironmentRequiredPermissions` (query-hint handling only)
- ensure out-of-scope requests preserve deny-as-not-found semantics
### Phase 3 — Align canonical link generation
- ensure link generation:
- never emits legacy scope query keys
- uses `environment_id` only for workspace hub narrowing
- does not treat `environment_id` as a replacement for route-owned environment pages
### Phase 4 — Regression guards
- add/extend guard tests that block:
- parsing legacy scope keys as authority
- generating navigation URLs containing forbidden scope query keys
### Phase 5 — Validation + close-out
- run the narrowest Feature tests
- run `pint` on dirty files and `git diff --check`
## Test Governance Check
- **Test purpose / classification**: Feature
- **Affected validation lanes**: fast-feedback (Feature)
- **Narrowest proving command(s)**:
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Navigation --filter=Spec341`
- **Fixture / helper cost risks**: none expected; reuse existing workspace/environment factories + navigation harness helpers
- **Escalation path**: `document-in-feature` if a bounded exception is proven necessary; otherwise treat legacy alias preservation as a blocker
## Deployment / Ops Impact
No migrations, env vars, queues, scheduler, storage, or asset pipeline changes are planned.
## Constitution Check (subset)
- Proportionality: no new persistence/abstractions/taxonomy introduced
- Compatibility posture: legacy alias preservation is out of scope
- Scope & isolation: route-owned environment remains authoritative; hub narrowing is `environment_id` only; deny-as-not-found semantics preserved

View File

@ -0,0 +1,191 @@
# Feature Specification: Spec 341 - Canonical Link / Query Cleanup
**Feature Branch**: `341-canonical-link-query-cleanup`
**Created**: 2026-05-31
**Status**: Draft
**Input**: Candidate `canonical-link-query-cleanup` from `docs/product/spec-candidates.md` + repo inspection of remaining legacy scope query hints in middleware/shell after Specs 338340 and Spec 339.
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
- **Problem**: The workspace/environment scope contract is now repo-real (Specs 314322, 338339), but some remaining URL/query seams still interpret legacy scope query keys (`tenant`, `managed_environment_id`, etc.) as “environment hints”. That reintroduces hidden scope, weakens trust for credential-adjacent and environment-bound surfaces, and makes deep links less predictable.
- **Today's failure**: Repo inspection shows remaining legacy query parsing in shared request-scoping seams (e.g. environment/tenant selection middleware and `OperateHubShell`) that can treat `?tenant=` or `?managed_environment_id=` as scope hints outside workspace hubs. This undermines the “explicit or route-owned” scope rule and increases the risk of accidental scope drift resurfacing.
- **User-visible improvement**: Operators can share and reload admin links confidently: workspace hubs are workspace-wide by default and only narrow via explicit `environment_id`; environment-bound pages are route-owned and do not accept legacy query aliases as authority; “clear filter” links return to clean canonical URLs; and guard tests block reintroducing legacy query scope behavior.
- **Smallest enterprise-capable version**: Inventory remaining legacy scope query parsing, remove it (or make it strictly ignore-only with safe fallback), align canonical link generation across hubs/shell/middleware seams, and add targeted tests to prevent regressions.
- **Explicit non-goals**:
- No new UI shell/sidebar/topbar redesign.
- No new routes or panels.
- No new persisted entities, enums, or abstraction frameworks.
- No RBAC model or capability registry changes.
- No provider/OAuth redesign (Spec 281 family).
- No Graph integration changes.
- **Permanent complexity imported**: A small, explicit inventory + a small set of guard/contract tests around canonical URL/query semantics. No new runtime taxonomy or UI framework.
- **Why now**: Specs 338340 and Spec 339 hardened scope/authority. The next risk is “scope drift through links and query parsing” reappearing as features continue. This cleanup locks the contract so future work cannot accidentally revive legacy hints.
- **Why not local**: Fixing a single pages link generator is insufficient because the drift sits in shared request-scoping seams. The smallest correct slice must cover the shared parsing/link seams and prove behavior with tests.
- **Approval class**: Core Enterprise (scope/authorization safety hardening).
- **Red flags triggered**: Cross-surface navigation semantics + shared middleware/shell seams. **Defense**: bounded to removing legacy query hint parsing and aligning canonical URL generation; no new frameworking/persistence; tests make the change reviewable.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 1 | Komplexität: 1 | Produktnähe: 1 | Wiederverwendung: 2 | **Gesamt: 9/12**
- **Decision**: approve.
## Summary
Canonicalize admin navigation and request scope semantics so that:
- Workspace hubs remain workspace-wide unless explicitly filtered by `environment_id`.
- Environment-bound pages derive environment context only from route parameters (not legacy query aliases).
- Legacy scope query keys do not grant authority, do not silently narrow results, and do not reintroduce “hidden environment context”.
- Shared seams (`EnsureWorkspaceSelected`, `EnsureEnvironmentContextSelected`, `OperateHubShell`) do not parse legacy query scope hints for authority.
This is a hardening / cleanup slice: it removes legacy scope query hint parsing and aligns link/query semantics with the existing scope contract.
## Completed-Spec Guardrail Result
Related specs are context only and must not be rewritten:
- Workspace hub contracts and legacy alias guards: Specs 314322, 317, 338.
- Credential-adjacent authority hardening: Spec 339.
- Post-scope browser verification gate: Spec 340.
No `specs/341-*` package existed before this prep package was created.
## Spec Scope Fields *(mandatory)*
- **Scope**: canonical-view (navigation + link/query contract hygiene).
- **Primary routes / surfaces affected** (contract-level; exact list is inventory-driven):
- Workspace hubs that accept environment narrowing via `environment_id` (e.g. Governance Inbox, Decision Register, Review Register, Evidence Overview, Audit Log, Alerts, Provider Connections index, Customer Review Workspace).
- Environment-bound pages under `/admin/workspaces/{workspace}/environments/{environment}/...` that must not accept legacy query scope hints.
- Shared request-scoping seams that currently inspect legacy query keys:
- `apps/platform/app/Http/Middleware/EnsureWorkspaceSelected.php`
- `apps/platform/app/Support/Middleware/EnsureEnvironmentContextSelected.php`
- `apps/platform/app/Support/OperateHub/OperateHubShell.php`
- `apps/platform/app/Filament/Pages/EnvironmentRequiredPermissions.php` (query-hint cleanup only)
- **Data Ownership**: No schema changes. This slice changes URL/query handling and link generation only.
- **RBAC**:
- No new capabilities.
- Existing 404 vs 403 semantics remain authoritative.
- `environment_id` is a filter hint only; it must be validated against current workspace context + actor entitlement.
For canonical-view surfaces:
- **Default filter behavior when tenant-context is active**: Workspace hub clean URLs are workspace-wide and must not inherit remembered environment context, Filament tenant fallback, session table filters, or legacy query keys. If narrowed, the page must show an explicit `environment_id` filter state.
- **Explicit entitlement checks preventing cross-tenant leakage**: Any requested `environment_id` must resolve to an environment in the current workspace that the actor can access; foreign/out-of-scope IDs must fail as not found and must not leak existence.
## UI Surface Impact *(mandatory — UI-COV-001)*
Does this spec add, remove, rename, or materially change any reachable UI surface?
- [ ] No UI surface impact
- [x] Existing page changed
- [ ] New page/route added
- [x] Navigation changed
- [ ] Filament panel/provider surface changed
- [ ] New modal/drawer/wizard/action added
- [ ] New table/form/state added
- [ ] Customer-facing surface changed
- [ ] Dangerous action changed
- [ ] Status/evidence/review presentation changed
- [x] Workspace/environment context presentation changed
## UI/Productization Coverage *(UI-COV-001)*
- **Route/page/surface**: Cross-surface link + query semantics for workspace hubs and environment-bound pages (no new UI surface).
- **Current page archetype**: scope-contract / navigation semantics (global-context-shell + workspace hub filter contract).
- **Design depth**: Domain Pattern Surface (scope/authority hardening) — minimal visible UX change expected.
- **Repo-truth level**: repo-verified (existing hub filter contracts + shared middleware/shell code).
- **Existing pattern reused**: Spec 314/315/316 hub contracts + Spec 317 legacy alias cleanup + Spec 338 contract tests + existing navigation helpers.
- **New pattern required**: none (tighten and converge; do not invent a new link-normalization framework).
- **Screenshot required**: no (unless implementation introduces visible copy changes; then add one targeted screenshot in the implementation PR only).
- **Page audit required**: no (no new archetype; contract hardening only).
- **Customer-safe review required**: no (scope contract applies broadly; not a customer-safe productization slice).
- **Dangerous-action review required**: no (no action behavior change; link/query semantics only).
- **Coverage files to update (in implementation PR)**:
- [ ] `docs/ui-ux-enterprise-audit/route-inventory.md` (only if routes/nav entries change materially; not expected)
- [ ] `docs/ui-ux-enterprise-audit/design-coverage-matrix.md` (no new surface; expected `no`)
- [x] `N/A - no new reachable UI surface added; contract/URL semantics only`
## Cross-Cutting / Shared Pattern Reuse *(mandatory)*
- **Cross-cutting feature?**: yes.
- **Interaction class(es)**: navigation deep links, scope presentation, URL/query semantics, “clear filter” behavior.
- **Systems touched (expected)**:
- `apps/platform/app/Support/Navigation/WorkspaceHubNavigation.php`
- `apps/platform/app/Support/Navigation/WorkspaceHubEnvironmentFilter.php`
- `apps/platform/app/Filament/Concerns/UsesAdminEnvironmentFilterQueryParameter.php`
- `apps/platform/app/Http/Middleware/EnsureWorkspaceSelected.php`
- `apps/platform/app/Support/Middleware/EnsureEnvironmentContextSelected.php`
- `apps/platform/app/Support/OperateHub/OperateHubShell.php`
- `apps/platform/tests/Feature/Navigation/*`
- **Existing pattern(s) to extend**: Explicit `environment_id` filter only for workspace hubs; route-owned environment for environment-bound pages; deny-as-not-found for out-of-scope.
- **Allowed deviation and why**: none. This slice removes legacy behavior; it does not add a second link/query interpretation path.
- **Consistency impact**: One canonical environment filter key (`environment_id`) for hubs; no legacy query alias authority anywhere; all “clear filter” links return to clean canonical URLs.
- **Review focus**: “legacy query scope hints are not authority” + tests that make regressions obvious.
## OperationRun UX Impact
N/A — no `OperationRun` start, link, or lifecycle semantics are changed in this slice.
## Provider Boundary / Platform Core Check
- **Shared provider/platform boundary touched?**: no.
- **N/A**: This is platform scope/navigation hygiene. It must not interpret provider “tenant” identifiers as platform authority.
## Proportionality Review
N/A — no new persisted entities, abstraction frameworks, enums/status families, or taxonomy systems are introduced.
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
- **Test purpose / classification**: Feature (scope/link/query contract + regression guard).
- **Validation lane(s)**: fast-feedback (Feature). Browser smoke is optional and only justified if implementation changes visible navigation/URL behavior that cannot be proven reliably in Feature tests alone.
- **Planned validation commands**:
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Navigation --filter=Canonical`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Workspaces --filter=Scope`
- `cd apps/platform && ./vendor/bin/sail pint --dirty`
## Acceptance Criteria
- **AC1**: Workspace hubs accept `environment_id` as the only environment-narrowing query key; legacy aliases (`tenant`, `tenant_id`, `managed_environment_id`, `environment`, `tenant_scope`, `tableFilters`) never establish filter authority.
- **AC2**: Environment-bound routes derive environment context only from route params and do not parse legacy query aliases as scope hints.
- **AC3**: “Clear filter” returns to the clean canonical hub URL and clears any persisted hub filter state as per existing contracts.
- **AC4**: Out-of-scope `environment_id` is rejected with deny-as-not-found semantics and does not leak environment existence.
- **AC5**: Targeted Feature tests fail loudly on regression and pass after implementation; no new heavy-governance or browser family is introduced without an explicit spec decision.
## User Scenarios & Testing *(mandatory)*
### User Story 1 — Share a canonical workspace hub link (Priority: P1)
As a workspace operator, I can share a link to a workspace hub that is either workspace-wide (clean URL) or explicitly narrowed to one environment using `environment_id`, and the page shows scope/filter state correctly after reload/back/forward.
**Independent Test**: A Feature test renders each critical hub with clean URL and with `?environment_id=<id>` and asserts filter state, clear-filter behavior, and no legacy query keys.
### User Story 2 — Legacy query aliases never become authority (Priority: P1)
As a security reviewer, I can trust that legacy query keys cannot establish environment context or widen results on environment-bound pages or shared scoping middleware.
**Independent Test**: Feature tests verify that `?tenant=` and `?managed_environment_id=` are ignored or rejected consistently and never treated as an environment hint for authority.
### User Story 3 — Guard tests prevent reintroduction (Priority: P2)
As a developer, I have regression guards that fail CI if new code introduces legacy query parsing or generates links containing legacy scope query keys.
**Independent Test**: A guard test scans/validates generated navigation URLs for forbidden query keys.
## Out of Scope
- Any feature work that changes business logic, capabilities, policies, or data model.
- Any broad UI redesign, new surfaces, or new navigation architecture.
- Any provider/OAuth or credential workflow changes.
- Any compatibility redirects or preserving legacy scope query keys “just in case”.
## Risks
- Some internal/shared surfaces may still rely on legacy query hints for environment selection. Implementation must replace that behavior with explicit route-owned context or explicit `environment_id` on hubs, and prove it with tests.
- Over-scoping this cleanup can become a refactor. Tasks must stay inventory-driven and bounded to canonical scope/query semantics only.
## Assumptions
- The workspace/environment scope contract tests and hub filter contracts remain authoritative.
- This is pre-production: removing legacy scope query keys is allowed and preferred over compatibility layers.
## Open Questions
None blocking. Any discovered “needed but unclear” legacy behavior must be recorded as a follow-up candidate rather than silently preserved.

View File

@ -0,0 +1,93 @@
# Tasks: Spec 341 - Canonical Link / Query Cleanup
- Input: `specs/341-canonical-link-query-cleanup/spec.md`, `specs/341-canonical-link-query-cleanup/plan.md`
- Preparation status: implementation-ready.
**Tests**: Required. This spec hardens scope/URL semantics and must be guarded by deterministic Feature tests.
## Test Governance Checklist
- [x] Lane assignment is explicit and narrow: Feature (navigation/scope contract).
- [x] No new default-heavy helpers/factories/seeds are introduced.
- [x] Contract tests are written before refactors to keep review safe.
- [x] Any exception resolves as `document-in-feature`, `follow-up-spec`, or `reject-or-split`.
## Phase 1: Preparation And Repo Truth (blocks runtime changes)
**Purpose**: Identify every remaining legacy scope query parsing seam and the canonical link helpers to reuse.
- [x] T001 Re-read `specs/341-canonical-link-query-cleanup/spec.md`, `specs/341-canonical-link-query-cleanup/plan.md`, and this `tasks.md`.
- [x] T002 Confirm branch and working tree intent and record baseline commit (`git status --short --branch`, `git log -1 --oneline`).
- [x] T003 Inventory legacy scope query parsing in runtime code (focus: request query keys, not DB column names):
- `apps/platform/app/Http/Middleware/EnsureWorkspaceSelected.php` (`query('tenant')`, `query('managed_environment_id')`)
- `apps/platform/app/Support/Middleware/EnsureEnvironmentContextSelected.php` (legacy query hints)
- `apps/platform/app/Support/OperateHub/OperateHubShell.php` (legacy query tenant hints)
- `apps/platform/app/Filament/Pages/EnvironmentRequiredPermissions.php` (legacy query hint)
- Search guard: `rg -n \"query\\('tenant'\\)|query\\('managed_environment_id'\\)\" apps/platform/app`
- [x] T004 Inventory existing navigation contract tests and decide where Spec 341 regression coverage belongs:
- `apps/platform/tests/Feature/Navigation/Spec322LegacyQueryAliasGuardTest.php`
- `apps/platform/tests/Feature/Navigation/WorkspaceHubEnvironmentFilterContractTest.php`
- `apps/platform/tests/Feature/Navigation/WorkspaceHubClearFilterContractTest.php`
- `apps/platform/tests/Feature/Workspaces/WorkspaceHubContextContractTest.php`
## Phase 2: Add failing contract tests first
**Purpose**: Make the cleanup reviewable and prevent accidental reintroduction of legacy scope hints.
- [x] T005 Add a Spec 341 Feature test proving legacy scope query keys do not establish authority in shared seams:
- Requests with `?tenant=` or `?managed_environment_id=` do not establish environment context and do not widen access.
- Implement in: `apps/platform/tests/Feature/Navigation/Spec341CanonicalLinkQueryCleanupTest.php`
- [x] T006 [P] Add a test proving workspace hub narrowing is `environment_id`-only:
- Every in-scope hub URL accepts `environment_id`;
- legacy aliases (`tenant`, `tenant_id`, `managed_environment_id`, `environment`, `tenant_scope`, `tableFilters`) are rejected/ignored.
- Extend/verify: `apps/platform/tests/Feature/Navigation/WorkspaceHubEnvironmentFilterContractTest.php`
- [x] T007 [P] Add a test proving environment-bound pages remain route-owned:
- No environment-bound route derives environment scope from legacy query keys.
- Target at least one representative environment-bound route (e.g. Baseline Compare) plus one middleware-driven entry.
## Phase 3: Remove legacy query scope hint parsing (Spec 341 contract)
**Purpose**: Remove hidden environment authority sources and converge on explicit `environment_id` (hubs) or route-owned environment context (environment-bound pages).
- [x] T008 Update `apps/platform/app/Http/Middleware/EnsureWorkspaceSelected.php`:
- remove `?tenant=` / `?managed_environment_id=` handling as “explicit tenant context” signals;
- keep workspace selection and deny-as-not-found semantics correct.
- [x] T009 Update `apps/platform/app/Support/Middleware/EnsureEnvironmentContextSelected.php`:
- remove legacy scope query hint parsing;
- ensure workspace hubs remain exempt from tenant/environment selection requirements.
- [x] T010 Update `apps/platform/app/Support/OperateHub/OperateHubShell.php`:
- remove `resolveQueryTenantHint()` and related “explicit query tenant hint” behavior (or convert to ignore-only with no authority);
- ensure all environment-bound context comes from route parameters + validated workspace context.
- [x] T011 Update `apps/platform/app/Filament/Pages/EnvironmentRequiredPermissions.php`:
- remove legacy query hint parsing; route-owned environment only.
## Phase 4: Align canonical link generation
**Purpose**: Ensure the code never generates legacy scope query keys and that “clear filter” links return to clean canonical URLs.
- [x] T012 Remove/replace any link generation that emits legacy scope query keys (focus: URL query keys, not Graph tenant context):
- Use `rg -n \"\\?tenant=|\\btenant=\\\"|query\\('tenant'\\)\" apps/platform` to find offenders.
- [x] T013 Confirm `apps/platform/app/Support/Navigation/WorkspaceHubNavigation.php` and `WorkspaceHubEnvironmentFilter.php` remain the single source for hub filter link building and parsing (`environment_id` only).
- [x] T014 Confirm `apps/platform/app/Filament/Concerns/UsesAdminEnvironmentFilterQueryParameter.php` continues to strip legacy scope keys and that no new legacy keys are added elsewhere.
## Phase 5: Regression guards
**Purpose**: Prevent future drift back to legacy scope query keys.
- [x] T015 Add a guard test that fails if generated navigation URLs contain forbidden scope query keys (e.g. `tenant`, `tenant_id`, `managed_environment_id`, `environment`, `tenant_scope`, `tableFilters`).
- [x] T016 If a bounded exception is proven necessary, document it explicitly in the spec/PR and add a dedicated test; otherwise treat legacy scope alias preservation as a blocker.
## Phase 6: Validation
- [x] T017 Run narrow tests first:
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Navigation --filter=Spec341`
- [x] T018 Run formatting and patch checks:
- `cd apps/platform && ./vendor/bin/sail pint --dirty`
- `git diff --check`
## Explicit Non-Goals
- [x] NT001 Do not add migrations, new tables, or new persisted truth.
- [x] NT002 Do not introduce a new link-normalization abstraction framework.
- [x] NT003 Do not add compatibility redirects for legacy query keys.
- [x] NT004 Do not change provider/OAuth behavior or credential flows (Spec 281 family).