feat: retire legacy tenant route surfaces #352

Merged
ahmido merged 1 commits from 297-managed-environment-canonical-route-cutover into platform-dev 2026-05-12 23:35:10 +00:00
101 changed files with 2119 additions and 453 deletions

View File

@ -4,7 +4,6 @@
namespace App\Console\Commands;
use App\Filament\Pages\TenantDashboard;
use App\Filament\Resources\BackupSetResource;
use App\Models\BackupItem;
use App\Models\BackupSet;
@ -15,6 +14,7 @@
use App\Models\UserTenantPreference;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\ManagedEnvironmentLinks;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Schema;
@ -174,7 +174,7 @@ public function handle(): int
['User password', $password],
['ManagedEnvironment', (string) $tenant->name],
['ManagedEnvironment external id', (string) $tenant->external_id],
['Dashboard URL', TenantDashboard::getUrl(tenant: $tenant)],
['Dashboard URL', ManagedEnvironmentLinks::viewUrl($tenant)],
['Fixture login URL', route('admin.local.backup-health-browser-fixture-login', absolute: false)],
['Blocked route', BackupSetResource::getUrl(panel: 'admin', tenant: $tenant)],
['Locally denied capability', 'tenant.view'],

View File

@ -8,6 +8,7 @@
use App\Models\User;
use App\Models\UserTenantPreference;
use App\Services\Tenants\TenantOperabilityService;
use App\Support\ManagedEnvironmentLinks;
use App\Support\Tenants\TenantInteractionLane;
use App\Support\Tenants\TenantLifecyclePresentation;
use App\Support\Tenants\TenantOperabilityQuestion;
@ -129,7 +130,7 @@ public function selectTenant(int $tenantId): void
abort(404);
}
$this->redirect(TenantDashboard::getUrl(tenant: $tenant));
$this->redirect(ManagedEnvironmentLinks::viewUrl($tenant));
}
public function tenantLifecyclePresentation(ManagedEnvironment $tenant): TenantLifecyclePresentation

View File

@ -4,7 +4,6 @@
namespace App\Filament\Pages;
use App\Filament\Resources\TenantResource;
use App\Models\InventoryItem;
use App\Models\ManagedEnvironment;
use App\Models\User;
@ -14,6 +13,7 @@
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\PortfolioCompare\CrossTenantPromotionExecutionService;
use App\Support\Auth\Capabilities;
use App\Support\ManagedEnvironmentLinks;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\OperationalControls\OperationalControlBlockedException;
use App\Support\OperationRunLinks;
@ -183,7 +183,7 @@ protected function getHeaderActions(): array
->label('Open source tenant')
->icon('heroicon-o-arrow-top-right-on-square')
->color('gray')
->url(TenantResource::getUrl('view', ['record' => $sourceTenant], panel: 'admin'));
->url(ManagedEnvironmentLinks::viewUrl($sourceTenant));
}
$targetTenant = $this->selectedTargetTenant();
@ -193,7 +193,7 @@ protected function getHeaderActions(): array
->label('Open target tenant')
->icon('heroicon-o-arrow-top-right-on-square')
->color('gray')
->url(TenantResource::getUrl('view', ['record' => $targetTenant], panel: 'admin'));
->url(ManagedEnvironmentLinks::viewUrl($targetTenant));
}
$preflightAction = Action::make('generatePromotionPreflight')
@ -446,7 +446,7 @@ public function sourceTenantUrl(): ?string
return null;
}
return TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin');
return ManagedEnvironmentLinks::viewUrl($tenant);
}
public function targetTenantUrl(): ?string
@ -457,7 +457,7 @@ public function targetTenantUrl(): ?string
return null;
}
return TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin');
return ManagedEnvironmentLinks::viewUrl($tenant);
}
/**

View File

@ -10,6 +10,7 @@
use App\Models\ManagedEnvironment;
use App\Models\Workspace;
use App\Support\Filament\CanonicalAdminTenantFilterState;
use App\Support\ManagedEnvironmentLinks;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\OperateHub\OperateHubShell;
use App\Support\OperationRunLinks;
@ -255,7 +256,7 @@ protected function getHeaderActions(): array
->label('Back to '.$activeTenant->name)
->icon('heroicon-o-arrow-left')
->color('gray')
->url(\App\Filament\Pages\TenantDashboard::getUrl(tenant: $activeTenant));
->url(ManagedEnvironmentLinks::viewUrl($activeTenant));
}
if ($activeTenant instanceof ManagedEnvironment) {

View File

@ -15,6 +15,7 @@
use App\Services\Baselines\BaselineEvidenceCaptureResumeService;
use App\Services\Tenants\TenantOperabilityService;
use App\Support\Auth\Capabilities;
use App\Support\ManagedEnvironmentLinks;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\Navigation\RelatedNavigationResolver;
use App\Support\OperateHub\OperateHubShell;
@ -126,7 +127,7 @@ protected function getHeaderActions(): array
$actions[] = Action::make('operate_hub_back_to_tenant_run_detail')
->label('← Back to '.$activeTenant->name)
->color('gray')
->url(\App\Filament\Pages\TenantDashboard::getUrl(tenant: $activeTenant));
->url(ManagedEnvironmentLinks::viewUrl($activeTenant));
} else {
$actions[] = Action::make('operate_hub_back_to_operations')
->label('Back to Operations')

View File

@ -11,10 +11,10 @@
use App\Models\SupportRequest;
use App\Models\ManagedEnvironment;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\ManagedEnvironmentLinks;
use App\Support\ProductTelemetry\ProductTelemetryRecorder;
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
use App\Support\Rbac\UiEnforcement;
@ -101,22 +101,9 @@ public static function getUrl(array $parameters = [], bool $isAbsolute = true, ?
return url('/admin');
}
$workspace = $parameters['workspace'] ?? null;
if (! $workspace instanceof Workspace) {
$workspace = $resolvedTenant->workspace()->first();
}
if (! $workspace instanceof Workspace) {
return url('/admin');
}
$url = url('/admin/workspaces/'.($workspace->slug ?? $workspace->getKey()).'/environments/'.$resolvedTenant->getRouteKey());
$query = array_diff_key($parameters, array_flip(['tenant', 'environment', 'workspace']));
return $query === []
? $url
: $url.'?'.http_build_query($query);
return ManagedEnvironmentLinks::viewUrl($resolvedTenant, $query);
}
/**

View File

@ -4,8 +4,6 @@
namespace App\Filament\Pages;
use App\Filament\Resources\ProviderConnectionResource;
use App\Filament\Resources\TenantResource;
use App\Models\ManagedEnvironment;
use App\Models\User;
use App\Models\WorkspaceMembership;
@ -13,6 +11,7 @@
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\ManagedEnvironmentLinks;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
@ -207,7 +206,7 @@ public function reRunVerificationUrl(): string
$tenant = $this->trustedScopedTenant();
if ($tenant instanceof ManagedEnvironment) {
return TenantResource::getUrl('view', ['record' => $tenant]);
return ManagedEnvironmentLinks::viewUrl($tenant);
}
return route('admin.onboarding');
@ -221,7 +220,7 @@ public function manageProviderConnectionUrl(): ?string
return null;
}
return ProviderConnectionResource::getUrl('index', ['tenant' => $tenant], panel: 'admin');
return ManagedEnvironmentLinks::providerConnectionsUrl($tenant);
}
protected static function resolveScopedTenant(ManagedEnvironment|string|null $tenant = null): ?ManagedEnvironment

View File

@ -7,7 +7,6 @@
use BackedEnum;
use App\Exceptions\Onboarding\OnboardingDraftConflictException;
use App\Exceptions\Onboarding\OnboardingDraftImmutableException;
use App\Filament\Pages\TenantDashboard;
use App\Filament\Resources\TenantResource;
use App\Filament\Support\VerificationReportChangeIndicator;
use App\Filament\Support\VerificationReportViewer;
@ -43,6 +42,7 @@
use App\Support\Badges\BadgeDomain;
use App\Support\Links\RequiredPermissionsLinks;
use App\Support\Livewire\TrustedState\TrustedStateResolver;
use App\Support\ManagedEnvironmentLinks;
use App\Support\Onboarding\OnboardingCheckpoint;
use App\Support\Onboarding\OnboardingDraftStage;
use App\Support\Onboarding\OnboardingLifecycleState;
@ -189,7 +189,7 @@ protected function getHeaderActions(): array
$actions[] = Action::make('view_linked_tenant')
->label($this->linkedTenantActionLabel())
->color('gray')
->url($tenant instanceof ManagedEnvironment ? TenantResource::getUrl('view', ['record' => $tenant]) : null);
->url($tenant instanceof ManagedEnvironment ? ManagedEnvironmentLinks::viewUrl($tenant) : null);
}
if ($this->canResumeDraft($draft)) {
@ -5240,7 +5240,7 @@ public function completeOnboarding(): void
resourceId: (string) $tenant->getKey(),
);
$this->redirect(TenantDashboard::getUrl(tenant: $tenant));
$this->redirect(ManagedEnvironmentLinks::viewUrl($tenant));
}
private function verificationRun(): ?OperationRun

View File

@ -5,7 +5,6 @@
namespace App\Filament\Pages\Workspaces;
use App\Filament\Pages\ChooseTenant;
use App\Filament\Resources\TenantResource;
use App\Models\ManagedEnvironment;
use App\Models\User;
use App\Models\Workspace;
@ -13,6 +12,7 @@
use App\Services\Tenants\TenantOperabilityService;
use App\Support\Tenants\TenantInteractionLane;
use App\Support\Tenants\TenantOperabilityQuestion;
use App\Support\ManagedEnvironmentLinks;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Pages\Page;
use Illuminate\Database\Eloquent\Collection;
@ -118,7 +118,7 @@ public function openTenant(int $tenantId): void
}
$this->redirect(
\App\Filament\Pages\TenantDashboard::getUrl(tenant: $tenant)
ManagedEnvironmentLinks::viewUrl($tenant)
);
}
}

View File

@ -20,6 +20,7 @@
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\ManagedEnvironmentLinks;
use App\Support\OperationRunLinks;
use App\Support\OperationRunType;
use App\Support\OpsUx\OperationUxPresenter;
@ -284,17 +285,7 @@ private static function extractTenantExternalIdFromUrl(string $url): ?string
}
}
$path = parse_url($url, PHP_URL_PATH);
if (! is_string($path) || $path === '') {
$path = $url;
}
if (preg_match('~/(?:admin)/(?:tenants|t)/([0-9a-fA-F-]{36})(?:/|$)~', $path, $matches) !== 1) {
return null;
}
return (string) $matches[1];
return null;
}
private static function resolveTenantByExternalId(?string $externalId): ?ManagedEnvironment
@ -828,7 +819,7 @@ public static function table(Table $table): Table
return null;
}
return TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin');
return ManagedEnvironmentLinks::viewUrl($tenant);
}),
Tables\Columns\TextColumn::make('display_name')->label('Name')->searchable()->sortable(),
Tables\Columns\TextColumn::make('provider')

View File

@ -7,7 +7,6 @@
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Concerns\WorkspaceScopedTenantRoutes;
use App\Filament\Pages\TenantDashboard;
use App\Filament\Resources\StoredReportResource\Pages;
use App\Models\StoredReport;
use App\Models\ManagedEnvironment;
@ -16,6 +15,7 @@
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\ManagedEnvironmentLinks;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
@ -374,7 +374,7 @@ public static function table(Table $table): Table
->emptyStateIcon('heroicon-o-document-chart-bar')
->emptyStateActions([
Actions\Action::make('open_tenant_overview')
->label('Open tenant overview')
->label('Open environment overview')
->icon('heroicon-o-home')
->url(fn (): string => static::tenantOverviewUrl()),
]);
@ -715,6 +715,6 @@ private static function tenantOverviewUrl(): string
return '#';
}
return TenantDashboard::getUrl(tenant: $tenant);
return ManagedEnvironmentLinks::viewUrl($tenant);
}
}

View File

@ -3,7 +3,6 @@
namespace App\Filament\Resources;
use App\Filament\Pages\CrossTenantComparePage;
use App\Filament\Pages\TenantDashboard;
use App\Filament\Resources\TenantResource\Pages;
use App\Filament\Resources\TenantResource\RelationManagers;
use App\Http\Controllers\RbacDelegatedAuthController;
@ -43,6 +42,7 @@
use App\Support\Badges\TagBadgeDomain;
use App\Support\Badges\TagBadgeRenderer;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\ManagedEnvironmentLinks;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
@ -102,6 +102,8 @@ class TenantResource extends Resource
protected static bool $isScopedToTenant = false;
protected static bool $isGloballySearchable = false;
protected static ?string $recordTitleAttribute = 'name';
protected static ?string $recordRouteKeyName = 'slug';
@ -226,7 +228,7 @@ public static function makeMembershipsAction(): Actions\Action
Actions\Action::make('memberships')
->label('Manage access scope')
->icon('heroicon-o-users')
->url(fn (ManagedEnvironment $record): string => static::getUrl('memberships', ['record' => $record], panel: 'admin')),
->url(fn (ManagedEnvironment $record): string => ManagedEnvironmentLinks::accessScopesUrl($record)),
)
->requireCapability(Capabilities::TENANT_MEMBERSHIP_VIEW)
->tooltip('You do not have permission to view environment access scopes.')
@ -411,11 +413,11 @@ public static function makeRemoveTenantFromWorkspaceAction(?string $permissionTo
{
$builder = UiEnforcement::forAction(
Actions\Action::make('remove_from_workspace')
->label('Remove tenant')
->label('Remove environment')
->color('danger')
->icon('heroicon-o-no-symbol')
->requiresConfirmation()
->modalHeading('Remove tenant from workspace')
->modalHeading('Remove environment from workspace')
->modalDescription('The tenant remains available for audit, operation history, evidence, and administrative inspection, but it is no longer selectable as active tenant context.')
->form([
Forms\Components\Textarea::make('removal_reason')
@ -464,11 +466,11 @@ public static function makeRestoreTenantToWorkspaceAction(?string $permissionToo
{
$builder = UiEnforcement::forAction(
Actions\Action::make('restore_to_workspace')
->label('Restore tenant')
->label('Restore environment')
->color('success')
->icon('heroicon-o-arrow-uturn-left')
->requiresConfirmation()
->modalHeading('Restore tenant to workspace')
->modalHeading('Restore environment to workspace')
->modalDescription('Restoring the tenant makes it eligible for normal workspace tenant selection and new tenant operations again, subject to its lifecycle and RBAC.')
->form([
Forms\Components\Textarea::make('restore_reason')
@ -767,14 +769,7 @@ public static function getEloquentQuery(): Builder
public static function getGlobalSearchEloquentQuery(): Builder
{
if (app(WorkspaceContext::class)->currentWorkspaceId(request()) === null) {
return static::getEloquentQuery()->whereRaw('1 = 0');
}
return static::tenantOperability()->applyAdministrativeDiscoverabilityScope(
static::getEloquentQuery(),
(new ManagedEnvironment)->getTable(),
);
return static::getEloquentQuery()->whereRaw('1 = 0');
}
public static function table(Table $table): Table
@ -1355,15 +1350,12 @@ public static function tenantDashboardOpenUrl(ManagedEnvironment $record, array
$arrivalState = static::portfolioArrivalStateForTenant($record, $triageState);
if ($arrivalState === null) {
return TenantDashboard::getUrl(tenant: $record);
return ManagedEnvironmentLinks::viewUrl($record);
}
return TenantDashboard::getUrl(
parameters: [
PortfolioArrivalContextToken::QUERY_PARAMETER => PortfolioArrivalContextToken::encode($arrivalState),
],
tenant: $record,
);
return ManagedEnvironmentLinks::viewUrl($record, [
PortfolioArrivalContextToken::QUERY_PARAMETER => PortfolioArrivalContextToken::encode($arrivalState),
]);
}
/**
@ -2805,7 +2797,7 @@ public static function tenantViewContextEntries(ManagedEnvironment $tenant): arr
label: 'Provider connections',
value: 'Open provider connections',
secondaryValue: 'Inspect consent, credentials, and health for this tenant.',
targetUrl: ProviderConnectionResource::getUrl('index', ['managed_environment_id' => $tenant->external_id], panel: 'admin'),
targetUrl: ManagedEnvironmentLinks::providerConnectionsUrl($tenant),
targetKind: 'canonical_page',
priority: 20,
actionLabel: 'Open',
@ -2859,7 +2851,7 @@ public static function tenantEditContextEntries(ManagedEnvironment $tenant): arr
RelatedContextEntry::available(
key: 'tenant_view',
label: 'ManagedEnvironment detail',
value: 'Open tenant detail',
value: 'Open environment detail',
secondaryValue: 'Review verification, RBAC, and lifecycle context without leaving the tenant resource.',
targetUrl: static::getUrl('view', ['record' => $tenant]),
targetKind: 'direct_record',
@ -3146,9 +3138,64 @@ public static function getRelations(): array
*/
public static function getUrl(?string $name = null, array $parameters = [], bool $isAbsolute = true, ?string $panel = null, ?Model $tenant = null, bool $shouldGuessMissingParameters = false): string
{
$panel ??= 'admin';
$environment = static::resolveUrlEnvironment(
$parameters['record']
?? $parameters['tenant']
?? $parameters['environment']
?? $tenant
?? null,
);
return parent::getUrl($name, $parameters, $isAbsolute, $panel, $tenant, $shouldGuessMissingParameters);
$query = $parameters;
unset($query['record'], $query['tenant'], $query['environment'], $query['workspace']);
if ($name === 'memberships' && $environment instanceof ManagedEnvironment) {
return ManagedEnvironmentLinks::accessScopesUrl($environment, $query);
}
if (($name === 'view' || $name === 'edit') && $environment instanceof ManagedEnvironment) {
return ManagedEnvironmentLinks::viewUrl($environment, $query);
}
if ($name === null || $name === 'index') {
$scope = $environment ?? ($parameters['workspace'] ?? null);
return ManagedEnvironmentLinks::indexUrl($scope instanceof Workspace || $scope instanceof ManagedEnvironment ? $scope : null, $query);
}
if ($environment instanceof ManagedEnvironment) {
return ManagedEnvironmentLinks::viewUrl($environment, $query);
}
return ManagedEnvironmentLinks::indexUrl(query: $query);
}
private static function resolveUrlEnvironment(mixed $environment): ?ManagedEnvironment
{
if ($environment instanceof ManagedEnvironment) {
return $environment;
}
if ($environment instanceof Model) {
return null;
}
$identifier = trim((string) $environment);
if ($identifier === '') {
return null;
}
return ManagedEnvironment::query()
->withTrashed()
->where(static function (Builder $query) use ($identifier): void {
$query->where('slug', $identifier);
if (ctype_digit($identifier)) {
$query->orWhere((new ManagedEnvironment)->getQualifiedKeyName(), (int) $identifier);
}
})
->first();
}
public static function rbacAction(): Actions\Action
@ -3275,9 +3322,7 @@ public static function rbacAction(): Actions\Action
->label('Open RBAC login')
->url(route('admin.rbac.start', [
'tenant' => $record->graphTenantId(),
'return' => route('filament.admin.resources.tenants.view', [
'record' => $record,
]),
'return' => ManagedEnvironmentLinks::viewUrl($record),
])),
])
->warning()
@ -3378,7 +3423,7 @@ public static function adminConsentUrl(ManagedEnvironment $tenant): ?string
*/
private static function providerConnectionState(ManagedEnvironment $tenant): array
{
$ctaUrl = ProviderConnectionResource::getUrl('index', ['managed_environment_id' => (string) $tenant->external_id], panel: 'admin');
$ctaUrl = ManagedEnvironmentLinks::providerConnectionsUrl($tenant);
$defaultConnection = ProviderConnection::query()
->where('managed_environment_id', (int) $tenant->getKey())

View File

@ -2,13 +2,19 @@
namespace App\Filament\Resources\TenantResource\Pages;
use App\Filament\Resources\TenantResource;
use App\Models\ManagedEnvironment;
use App\Support\ManagedEnvironmentLinks;
use Filament\Actions\Action;
class ManageTenantMemberships extends ViewTenant
{
protected static ?string $title = 'Manage environment access scope';
public function mount(int|string|ManagedEnvironment $tenant): void
{
parent::mount($tenant instanceof ManagedEnvironment ? (string) $tenant->getRouteKey() : $tenant);
}
public function getSubheading(): ?string
{
return 'Workspace membership defines the role. Explicit environment scopes only narrow which workspace members can see this environment.';
@ -31,7 +37,7 @@ protected function getHeaderActions(): array
Action::make('back_to_overview')
->label('Back to environment overview')
->color('gray')
->url(TenantResource::getUrl('view', ['record' => $this->getRecord()->getRouteKey()], panel: 'admin')),
->url(fn (): string => ManagedEnvironmentLinks::viewUrl($this->getRecord())),
);
return $actions;

View File

@ -3,6 +3,7 @@
namespace App\Http\Controllers;
use App\Models\ManagedEnvironment;
use App\Support\ManagedEnvironmentLinks;
use Carbon\CarbonImmutable;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@ -33,7 +34,7 @@ public function start(Request $request): RedirectResponse
$request->session()->put('rbac_tenant', $tenant->getKey());
$returnTo = $this->sanitizeReturnPath(
$request->string('return')->toString()
?: route('filament.admin.resources.tenants.view', $tenant)
?: ManagedEnvironmentLinks::viewUrl($tenant)
);
$request->session()->put('rbac_return', $returnTo);
@ -91,7 +92,7 @@ public function callback(Request $request): RedirectResponse
Cache::put($this->cacheKey($tenant, auth()->id(), $request->session()->getId()), $accessToken, $ttl);
$destination = $this->sanitizeReturnPath($returnTo) ?: route('filament.admin.resources.tenants.view', $tenant);
$destination = $this->sanitizeReturnPath($returnTo) ?: ManagedEnvironmentLinks::viewUrl($tenant);
return redirect()->to($destination);
}

View File

@ -4,13 +4,13 @@
namespace App\Http\Controllers;
use App\Filament\Pages\TenantDashboard;
use App\Models\ManagedEnvironment;
use App\Models\User;
use App\Models\UserTenantPreference;
use App\Services\Tenants\TenantOperabilityService;
use App\Support\Tenants\TenantInteractionLane;
use App\Support\Tenants\TenantOperabilityQuestion;
use App\Support\ManagedEnvironmentLinks;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@ -67,7 +67,7 @@ public function __invoke(Request $request): RedirectResponse
abort(404);
}
return redirect()->to(TenantDashboard::getUrl(tenant: $tenant));
return redirect()->to(ManagedEnvironmentLinks::viewUrl($tenant));
}
private function persistLastTenant(User $user, ManagedEnvironment $tenant): void

View File

@ -28,7 +28,6 @@
use App\Filament\Resources\InventoryItemResource;
use App\Filament\Resources\PolicyResource;
use App\Filament\Resources\ProviderConnectionResource;
use App\Filament\Resources\TenantResource;
use App\Filament\Resources\TenantReviewResource;
use App\Filament\Resources\Workspaces\WorkspaceResource;
use App\Models\User;
@ -174,7 +173,6 @@ public function panel(Panel $panel): Panel
: '')
)
->resources([
TenantResource::class,
PolicyResource::class,
ProviderConnectionResource::class,
InventoryItemResource::class,

View File

@ -1,142 +0,0 @@
<?php
namespace App\Providers\Filament;
use App\Filament\Pages\Auth\Login;
use App\Filament\Pages\TenantDashboard;
use App\Filament\Resources\TenantReviewResource;
use App\Models\ManagedEnvironment;
use App\Support\Filament\PanelThemeAsset;
use App\Support\Middleware\DenyNonMemberTenantAccess;
use App\Support\OperationRunLinks;
use Filament\Facades\Filament;
use Filament\FontProviders\LocalFontProvider;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession;
use Filament\Http\Middleware\DisableBladeIconComponents;
use Filament\Http\Middleware\DispatchServingFilamentEvent;
use Filament\Navigation\NavigationItem;
use Filament\Panel;
use Filament\PanelProvider;
use Filament\Support\Colors\Color;
use Filament\View\PanelsRenderHook;
use Filament\Widgets\AccountWidget;
use Filament\Widgets\FilamentInfoWidget;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\View\Middleware\ShareErrorsFromSession;
class TenantPanelProvider extends PanelProvider
{
public function panel(Panel $panel): Panel
{
$panel = $panel
->id('tenant')
->path('admin/t')
->login(Login::class)
->brandName('TenantPilot')
->brandLogo(fn () => view('filament.admin.logo'))
->brandLogoHeight('2rem')
->favicon(asset('favicon.ico'))
->font(null, provider: LocalFontProvider::class, preload: [])
->tenant(ManagedEnvironment::class, slugAttribute: 'slug')
->tenantRoutePrefix(null)
->tenantMenu(fn (): bool => filled(Filament::getTenant()))
->searchableTenantMenu()
->colors([
'primary' => Color::Indigo,
])
->navigationItems([
NavigationItem::make(fn (): string => __('localization.navigation.operations'))
->url(fn (): string => OperationRunLinks::index())
->icon('heroicon-o-queue-list')
->group(fn (): string => __('localization.navigation.monitoring'))
->sort(10),
NavigationItem::make(fn (): string => __('localization.navigation.alerts'))
->url(fn (): string => url('/admin/alerts'))
->icon('heroicon-o-bell-alert')
->group(fn (): string => __('localization.navigation.monitoring'))
->sort(20),
NavigationItem::make(fn (): string => __('localization.navigation.audit_log'))
->url(fn (): string => route('admin.monitoring.audit-log'))
->icon('heroicon-o-clipboard-document-list')
->group(fn (): string => __('localization.navigation.monitoring'))
->sort(30),
])
->renderHook(
PanelsRenderHook::HEAD_END,
fn () => view('filament.partials.livewire-intercept-shim')->render()
)
->renderHook(
PanelsRenderHook::TOPBAR_START,
fn () => view('filament.partials.context-bar')->render()
)
->renderHook(
PanelsRenderHook::CONTENT_START,
fn (): string => $this->shouldRenderBulkOperationProgressWidget()
? view('livewire.bulk-operation-progress-wrapper')->render()
: ''
)
->discoverClusters(in: app_path('Filament/Clusters'), for: 'App\Filament\Clusters')
->resources([
TenantReviewResource::class,
])
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
->pages([
TenantDashboard::class,
])
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\Filament\Widgets')
->widgets([
AccountWidget::class,
FilamentInfoWidget::class,
])
->databaseNotifications()
->databaseNotificationsPolling(null)
->middleware([
EncryptCookies::class,
AddQueuedCookiesToResponse::class,
StartSession::class,
AuthenticateSession::class,
ShareErrorsFromSession::class,
VerifyCsrfToken::class,
SubstituteBindings::class,
'ensure-correct-guard:web',
'ensure-workspace-selected',
'ensure-filament-tenant-selected',
DenyNonMemberTenantAccess::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
])
->middleware(['apply-resolved-locale:tenant'], isPersistent: true)
->authMiddleware([
Authenticate::class,
]);
$theme = PanelThemeAsset::resolve('resources/css/filament/admin/theme.css');
if (is_string($theme)) {
$panel->theme($theme);
}
return $panel;
}
private function shouldRenderBulkOperationProgressWidget(): bool
{
if (! (bool) config('tenantpilot.bulk_operations.progress_widget_enabled', true)) {
return false;
}
$segments = request()->segments();
return ! (
count($segments) === 3
&& ($segments[0] ?? null) === 'admin'
&& ($segments[1] ?? null) === 't'
);
}
}

View File

@ -0,0 +1,183 @@
<?php
namespace App\Support;
use App\Filament\Resources\ProviderConnectionResource;
use App\Models\ManagedEnvironment;
use App\Models\ProviderConnection;
use App\Models\Workspace;
use App\Support\Links\RequiredPermissionsLinks;
use App\Support\Workspaces\WorkspaceContext;
final class ManagedEnvironmentLinks
{
/**
* @param array<string, mixed> $query
*/
public static function indexUrl(Workspace|ManagedEnvironment|null $scope = null, array $query = []): string
{
$workspace = self::workspaceFromScope($scope);
if (! $workspace instanceof Workspace) {
return url('/admin');
}
return self::withQuery(route('admin.workspace.managed-tenants.index', [
'workspace' => self::workspaceRouteKey($workspace),
]), $query);
}
/**
* @param array<string, mixed> $query
*/
public static function viewUrl(ManagedEnvironment $environment, array $query = []): string
{
$workspace = self::workspaceFromScope($environment);
if (! $workspace instanceof Workspace) {
return url('/admin');
}
return self::withQuery(route('admin.workspace.environments.show', [
'workspace' => self::workspaceRouteKey($workspace),
'tenant' => self::environmentRouteKey($environment),
]), $query);
}
/**
* @param array<string, mixed> $filters
*/
public static function requiredPermissionsUrl(ManagedEnvironment $environment, array $filters = []): string
{
return RequiredPermissionsLinks::requiredPermissions($environment, $filters);
}
/**
* @param array<string, mixed> $query
*/
public static function diagnosticsUrl(ManagedEnvironment $environment, array $query = []): string
{
return self::environmentChildUrl('admin.workspace.environments.diagnostics', $environment, $query);
}
/**
* @param array<string, mixed> $query
*/
public static function accessScopesUrl(ManagedEnvironment $environment, array $query = []): string
{
return self::environmentChildUrl('admin.workspace.environments.access-scopes', $environment, $query);
}
/**
* @param array<string, mixed> $query
*/
public static function providerConnectionsUrl(?ManagedEnvironment $environment = null, array $query = []): string
{
if ($environment instanceof ManagedEnvironment && ! array_key_exists('managed_environment_id', $query)) {
$query['managed_environment_id'] = (string) $environment->external_id;
}
return ProviderConnectionResource::getUrl('index', $query, panel: 'admin');
}
/**
* @param array<string, mixed> $query
*/
public static function providerConnectionUrl(
ProviderConnection|int $connection,
string $page = 'view',
?ManagedEnvironment $environment = null,
array $query = [],
): string {
if ($environment instanceof ManagedEnvironment && ! array_key_exists('managed_environment_id', $query)) {
$query['managed_environment_id'] = (string) $environment->external_id;
}
$query['record'] = $connection;
return ProviderConnectionResource::getUrl($page, $query, panel: 'admin');
}
/**
* @param array<string, mixed> $query
*/
public static function operationsUrl(Workspace|ManagedEnvironment|null $scope = null, array $query = []): string
{
if ($scope instanceof ManagedEnvironment && ! array_key_exists('managed_environment_id', $query)) {
$query['managed_environment_id'] = (int) $scope->getKey();
}
$workspace = self::workspaceFromScope($scope);
if (! $workspace instanceof Workspace) {
return url('/admin');
}
return self::withQuery(route('admin.operations.index', [
'workspace' => self::workspaceRouteKey($workspace),
]), $query);
}
public static function workspaceRouteKey(Workspace $workspace): string
{
$slug = $workspace->getAttribute('slug');
return is_string($slug) && $slug !== ''
? $slug
: (string) $workspace->getKey();
}
public static function environmentRouteKey(ManagedEnvironment $environment): string
{
return (string) $environment->getRouteKey();
}
/**
* @param array<string, mixed> $query
*/
private static function environmentChildUrl(string $routeName, ManagedEnvironment $environment, array $query = []): string
{
$workspace = self::workspaceFromScope($environment);
if (! $workspace instanceof Workspace) {
return url('/admin');
}
return self::withQuery(route($routeName, [
'workspace' => self::workspaceRouteKey($workspace),
'tenant' => self::environmentRouteKey($environment),
]), $query);
}
private static function workspaceFromScope(Workspace|ManagedEnvironment|null $scope = null): ?Workspace
{
if ($scope instanceof Workspace) {
return $scope;
}
if ($scope instanceof ManagedEnvironment) {
return $scope->workspace()->first();
}
return app(WorkspaceContext::class)->currentWorkspace(request());
}
/**
* @param array<string, mixed> $query
*/
private static function withQuery(string $url, array $query): string
{
$query = array_filter(
$query,
static fn (mixed $value): bool => $value !== null && $value !== '',
);
if ($query === []) {
return $url;
}
$queryString = http_build_query($query);
return $queryString !== '' ? "{$url}?{$queryString}" : $url;
}
}

View File

@ -242,7 +242,7 @@ private function configureNavigationForRequest(\Filament\Panel $panel): void
private function isWorkspaceScopedPageWithTenant(string $path): bool
{
return preg_match('#^/admin/workspaces/[^/]+/environments/[^/]+/required-permissions$#', $path) === 1;
return preg_match('#^/admin/workspaces/[^/]+/environments/[^/]+/(required-permissions|diagnostics|access-scopes)$#', $path) === 1;
}
private function isLivewireUpdatePath(string $path): bool

View File

@ -4,12 +4,12 @@
namespace App\Support\OperateHub;
use App\Filament\Pages\TenantDashboard;
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\Tenants\TenantOperabilityQuestion;
use App\Support\Tenants\TenantPageCategory;
use App\Support\Workspaces\WorkspaceContext;
@ -48,7 +48,7 @@ public function returnAffordance(?Request $request = null): ?array
if ($activeTenant instanceof ManagedEnvironment) {
return [
'label' => 'Back to '.$activeTenant->name,
'url' => TenantDashboard::getUrl(tenant: $activeTenant),
'url' => ManagedEnvironmentLinks::viewUrl($activeTenant),
];
}
@ -346,15 +346,7 @@ private function resolveRouteTenantCandidate(?Request $request = null, ?TenantPa
return $this->resolveTenantIdentifier($route->parameter('tenant'));
}
if (
$pageCategory !== TenantPageCategory::TenantBound
|| ! $route?->hasParameter('record')
|| ! str_starts_with((string) ($route->getName() ?? ''), 'filament.admin.resources.tenants.')
) {
return null;
}
return $this->resolveTenantIdentifier($route->parameter('record'));
return null;
}
private function resolveQueryTenantHint(?Request $request = null): ?ManagedEnvironment

View File

@ -10,7 +10,6 @@
use App\Filament\Resources\EvidenceSnapshotResource;
use App\Filament\Resources\InventoryItemResource;
use App\Filament\Resources\PolicyResource;
use App\Filament\Resources\ProviderConnectionResource;
use App\Filament\Resources\RestoreRunResource;
use App\Filament\Resources\ReviewPackResource;
use App\Filament\Resources\TenantReviewResource;
@ -166,9 +165,9 @@ public static function related(OperationRun $run, ?ManagedEnvironment $tenant):
$providerConnectionId = $context['provider_connection_id'] ?? null;
$canonicalType = $run->canonicalOperationType();
if (is_numeric($providerConnectionId) && class_exists(ProviderConnectionResource::class)) {
$links['Provider Connections'] = ProviderConnectionResource::getUrl('index', ['tenant' => $tenant], panel: 'admin');
$links['Provider Connection'] = ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant, 'record' => (int) $providerConnectionId], panel: 'admin');
if (is_numeric($providerConnectionId)) {
$links['Provider Connections'] = ManagedEnvironmentLinks::providerConnectionsUrl($tenant);
$links['Provider Connection'] = ManagedEnvironmentLinks::providerConnectionUrl((int) $providerConnectionId, 'edit', $tenant);
}
if ($canonicalType === 'inventory.sync') {

View File

@ -14,6 +14,7 @@
use App\Support\Auth\Capabilities;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use App\Support\BackupHealth\TenantBackupHealthResolver;
use App\Support\ManagedEnvironmentLinks;
use App\Support\Rbac\UiTooltips;
use App\Support\RestoreSafety\RestoreResultAttention;
use App\Support\RestoreSafety\RestoreSafetyResolver;
@ -292,7 +293,7 @@ private function nextStepTarget(
concernReason: $concernReason,
recoveryEvidence: $recoveryEvidence,
),
default => $this->disabledTarget(kind: 'tenant_dashboard', label: 'Open tenant dashboard'),
default => $this->disabledTarget(kind: 'tenant_dashboard', label: 'Open environment'),
};
}
@ -460,7 +461,7 @@ private function returnTarget(string $sourceSurface, ?array $returnFilters): ?ar
return [
'kind' => 'tenant_registry',
'label' => 'Return to tenant triage',
'url' => TenantResource::getUrl('index', $filters, panel: 'admin'),
'url' => ManagedEnvironmentLinks::indexUrl(query: $filters),
'filters' => $filters,
];
}

View File

@ -4,10 +4,10 @@
namespace App\Support\Providers;
use App\Filament\Resources\ProviderConnectionResource;
use App\Models\ProviderConnection;
use App\Models\ManagedEnvironment;
use App\Support\Links\RequiredPermissionsLinks;
use App\Support\ManagedEnvironmentLinks;
use App\Support\ReasonTranslation\Contracts\TranslatesReasonCode;
use App\Support\ReasonTranslation\NextStepOption;
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
@ -356,14 +356,14 @@ private function nextStepsFor(
NextStepOption::link(
label: $connection instanceof ProviderConnection ? 'Review migration classification' : 'Manage Provider Connections',
destination: $connection instanceof ProviderConnection
? ProviderConnectionResource::getUrl('view', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
? ManagedEnvironmentLinks::providerConnectionUrl($connection, 'view', $tenant)
: ManagedEnvironmentLinks::providerConnectionsUrl($tenant),
),
NextStepOption::link(
label: 'Review effective app details',
destination: $connection instanceof ProviderConnection
? ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
? ManagedEnvironmentLinks::providerConnectionUrl($connection, 'edit', $tenant)
: ManagedEnvironmentLinks::providerConnectionsUrl($tenant),
),
],
ProviderReasonCodes::DedicatedCredentialMissing,
@ -371,8 +371,8 @@ private function nextStepsFor(
NextStepOption::link(
label: $connection instanceof ProviderConnection ? 'Manage dedicated connection' : 'Manage Provider Connections',
destination: $connection instanceof ProviderConnection
? ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
? ManagedEnvironmentLinks::providerConnectionUrl($connection, 'edit', $tenant)
: ManagedEnvironmentLinks::providerConnectionsUrl($tenant),
),
],
ProviderReasonCodes::ProviderCredentialMissing,
@ -390,8 +390,8 @@ private function nextStepsFor(
? ($connection->connection_type === ProviderConnectionType::Dedicated ? 'Manage dedicated connection' : 'Review platform connection')
: 'Manage Provider Connections',
destination: $connection instanceof ProviderConnection
? ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
? ManagedEnvironmentLinks::providerConnectionUrl($connection, 'edit', $tenant)
: ManagedEnvironmentLinks::providerConnectionsUrl($tenant),
),
],
ProviderReasonCodes::ProviderPermissionMissing,
@ -408,7 +408,7 @@ private function nextStepsFor(
ProviderReasonCodes::IntuneRbacStale => [
NextStepOption::link(
label: 'Review provider connections',
destination: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
destination: ManagedEnvironmentLinks::providerConnectionsUrl($tenant),
),
NextStepOption::instruction('Refresh the tenant RBAC health check before retrying.', scope: 'tenant'),
],
@ -418,14 +418,14 @@ private function nextStepsFor(
NextStepOption::link(
label: 'Review provider connection',
destination: $connection instanceof ProviderConnection
? ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
? ManagedEnvironmentLinks::providerConnectionUrl($connection, 'edit', $tenant)
: ManagedEnvironmentLinks::providerConnectionsUrl($tenant),
),
],
default => [
NextStepOption::link(
label: 'Manage Provider Connections',
destination: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
destination: ManagedEnvironmentLinks::providerConnectionsUrl($tenant),
),
],
};

View File

@ -4,7 +4,6 @@
namespace App\Support\SupportDiagnostics;
use App\Filament\Pages\TenantDashboard;
use App\Filament\Resources\FindingResource;
use App\Filament\Resources\ProviderConnectionResource;
use App\Filament\Resources\ReviewPackResource;
@ -20,6 +19,7 @@
use App\Models\User;
use App\Models\Workspace;
use App\Support\Ai\AiDataClassification;
use App\Support\ManagedEnvironmentLinks;
use App\Support\Navigation\RelatedNavigationResolver;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\GovernanceRunDiagnosticSummaryBuilder;
@ -723,7 +723,7 @@ private function tenantReviewSection(?TenantReview $review, ?ManagedEnvironment
availability: 'missing',
summary: 'No tenant review was found for this support context.',
references: [
$this->missingReference('tenant_review', 'ManagedEnvironment review not yet observed', 'Open tenant review'),
$this->missingReference('tenant_review', 'ManagedEnvironment review not yet observed', 'Open environment review'),
],
);
}
@ -739,7 +739,7 @@ private function tenantReviewSection(?TenantReview $review, ?ManagedEnvironment
type: 'tenant_review',
record: $review,
label: 'ManagedEnvironment review #'.$review->getKey(),
actionLabel: 'Open tenant review',
actionLabel: 'Open environment review',
url: $tenant instanceof ManagedEnvironment
? TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant)
: null,
@ -904,8 +904,8 @@ private function tenantReference(ManagedEnvironment $tenant): array
'type' => 'tenant',
'record_id' => (string) $tenant->getKey(),
'label' => $tenant->name,
'action_label' => 'Open tenant',
'url' => TenantDashboard::getUrl(tenant: $tenant),
'action_label' => 'Open environment',
'url' => ManagedEnvironmentLinks::viewUrl($tenant),
'availability' => 'available',
'freshness_note' => null,
'access_reason' => null,

View File

@ -10,6 +10,7 @@
use App\Filament\System\Pages\Directory\Workspaces;
use App\Models\ManagedEnvironment;
use App\Models\Workspace;
use App\Support\ManagedEnvironmentLinks;
final class SystemDirectoryLinks
{
@ -46,9 +47,13 @@ public static function adminWorkspace(Workspace|int $workspace): string
public static function adminTenant(ManagedEnvironment|string|int $tenant): string
{
$tenantRouteKey = self::tenantRouteKey($tenant);
$environment = $tenant instanceof ManagedEnvironment
? $tenant
: ManagedEnvironment::query()->forTenant($tenant)->first();
return route('filament.admin.resources.tenants.view', ['record' => $tenantRouteKey]);
return $environment instanceof ManagedEnvironment
? ManagedEnvironmentLinks::viewUrl($environment)
: url('/admin');
}
private static function tenantRouteKey(ManagedEnvironment|string|int $tenant): string

View File

@ -102,7 +102,7 @@ private static function isAllowed(string $pathWithQuery): bool
return false;
}
if (preg_match('#^https?://#i', $pathWithQuery) === 1) {
if (preg_match('#^[a-z][a-z0-9+.-]*://#i', $pathWithQuery) === 1) {
return false;
}
@ -121,6 +121,6 @@ private static function isAllowed(string $pathWithQuery): bool
return false;
}
return true;
return preg_match('#^/admin/(?:t|tenants)(?:/|$)#', $path) !== 1;
}
}

View File

@ -8,9 +8,7 @@
use App\Filament\Pages\ChooseTenant;
use App\Filament\Pages\Findings\FindingsHygieneReport;
use App\Filament\Pages\Findings\MyFindingsInbox;
use App\Filament\Pages\TenantDashboard;
use App\Filament\Resources\FindingResource;
use App\Filament\Resources\TenantResource;
use App\Models\AlertDelivery;
use App\Models\Finding;
use App\Models\FindingException;
@ -33,6 +31,7 @@
use App\Support\Baselines\TenantGovernanceAggregate;
use App\Support\Baselines\TenantGovernanceAggregateResolver;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\ManagedEnvironmentLinks;
use App\Support\OperationCatalog;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
@ -557,7 +556,7 @@ private function attentionItems(
),
badge: 'Governance',
badgeColor: 'danger',
destination: $this->tenantDashboardTarget($tenant, $user, 'Open tenant dashboard'),
destination: $this->tenantDashboardTarget($tenant, $user, 'Open environment'),
supportingMessage: 'Open the tenant dashboard to review the full invalid-governance family without narrowing the findings set.',
)];
}
@ -1546,7 +1545,7 @@ private function filteredTenantRegistryTarget(array $filters, ?string $label = n
{
return $this->destination(
kind: 'choose_tenant',
url: $this->appendQuery(TenantResource::getUrl(panel: 'admin'), $filters),
url: ManagedEnvironmentLinks::indexUrl(query: $filters),
label: $label ?? __('localization.shell.choose_environment'),
filters: $filters,
);
@ -1570,7 +1569,7 @@ private function switchWorkspaceTarget(string $label = 'Switch workspace'): arra
private function tenantDashboardTarget(
ManagedEnvironment $tenant,
User $user,
string $label = 'Open tenant dashboard',
string $label = 'Open environment',
?array $arrivalState = null,
): array
{
@ -1585,7 +1584,7 @@ private function tenantDashboardTarget(
return $this->destination(
kind: 'tenant_dashboard',
url: $this->appendArrivalToken(
TenantDashboard::getUrl(tenant: $tenant),
ManagedEnvironmentLinks::viewUrl($tenant),
$arrivalState,
),
label: $label,
@ -1658,7 +1657,7 @@ private function findingsTarget(ManagedEnvironment $tenant, User $user, array $f
}
if ($this->canTenantView($user, $tenant)) {
return $this->tenantDashboardTarget($tenant, $user, 'Open tenant dashboard');
return $this->tenantDashboardTarget($tenant, $user, 'Open environment');
}
return $this->disabledDestination(

View File

@ -5,11 +5,11 @@
namespace App\Support\Workspaces;
use App\Filament\Pages\ChooseWorkspace;
use App\Filament\Pages\TenantDashboard;
use App\Models\ManagedEnvironment;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Tenants\TenantOperabilityService;
use App\Support\ManagedEnvironmentLinks;
/**
* Resolves the explicit post-selection destination after a workspace is set.
@ -32,8 +32,12 @@ public function __construct(
*/
public function resolve(Workspace $workspace, User $user, ?string $intendedUrl = null): string
{
if (is_string($intendedUrl) && $this->intendedUrlMatchesWorkspace($intendedUrl, $workspace, $user)) {
return $intendedUrl;
if (is_string($intendedUrl)) {
$resolvedIntendedUrl = $this->resolveSafeIntendedUrl($intendedUrl, $workspace, $user);
if (is_string($resolvedIntendedUrl)) {
return $resolvedIntendedUrl;
}
}
$selectableTenants = $this->tenantOperabilityService->filterSelectable($user->accessibleManagedEnvironmentsQuery((int) $workspace->getKey())
@ -50,7 +54,7 @@ public function resolve(Workspace $workspace, User $user, ?string $intendedUrl =
$tenant = $selectableTenants->first();
if ($tenant !== null) {
return TenantDashboard::getUrl(tenant: $tenant);
return ManagedEnvironmentLinks::viewUrl($tenant);
}
}
@ -73,27 +77,46 @@ public function resolveFromId(int $workspaceId, User $user): string
return $this->resolve($workspace, $user);
}
private function intendedUrlMatchesWorkspace(string $intendedUrl, Workspace $workspace, User $user): bool
private function resolveSafeIntendedUrl(string $intendedUrl, Workspace $workspace, User $user): ?string
{
$scheme = parse_url($intendedUrl, PHP_URL_SCHEME);
$host = parse_url($intendedUrl, PHP_URL_HOST);
if (str_starts_with($intendedUrl, '//')) {
return null;
}
if ((is_string($scheme) || is_string($host)) && ! $this->isSameOriginUrl($intendedUrl)) {
return null;
}
$path = '/'.ltrim((string) (parse_url($intendedUrl, PHP_URL_PATH) ?? ''), '/');
if (! str_starts_with($path, '/admin')) {
return false;
return null;
}
if (preg_match('#^/admin/(?:t|tenants)/([^/]+)(?:/|$)#', $path, $matches) === 1) {
return $this->tenantIdentifierMatchesWorkspace($matches[1], $workspace, $user);
if ($this->isRetiredTenantPath($path)) {
return null;
}
parse_str((string) (parse_url($intendedUrl, PHP_URL_QUERY) ?? ''), $query);
if ($path === '/admin/operations') {
return ManagedEnvironmentLinks::operationsUrl($workspace, $query);
}
if (str_starts_with($path, '/admin/operations/')) {
return null;
}
$tenantIdentifier = $query['tenant'] ?? $query['managed_environment_id'] ?? null;
if ($tenantIdentifier !== null && ! $this->tenantIdentifierMatchesWorkspace((string) $tenantIdentifier, $workspace, $user)) {
return false;
return null;
}
return true;
return $intendedUrl;
}
private function tenantIdentifierMatchesWorkspace(string $identifier, Workspace $workspace, User $user): bool
@ -116,6 +139,34 @@ private function tenantIdentifierMatchesWorkspace(string $identifier, Workspace
private function environmentChooserUrl(Workspace $workspace): string
{
return url('/admin/workspaces/'.($workspace->slug ?? $workspace->getKey()).'/environments');
return ManagedEnvironmentLinks::indexUrl($workspace);
}
private function isRetiredTenantPath(string $path): bool
{
return preg_match('#^/admin/(?:t|tenants)(?:/|$)#', $path) === 1;
}
private function isSameOriginUrl(string $url): bool
{
$urlHost = parse_url($url, PHP_URL_HOST);
if (! is_string($urlHost) || $urlHost === '') {
return true;
}
$appUrl = url('/');
$appHost = parse_url($appUrl, PHP_URL_HOST);
if (! is_string($appHost) || ! hash_equals($appHost, $urlHost)) {
return false;
}
$urlScheme = parse_url($url, PHP_URL_SCHEME);
$appScheme = parse_url($appUrl, PHP_URL_SCHEME);
return ! is_string($urlScheme)
|| ! is_string($appScheme)
|| hash_equals($appScheme, $urlScheme);
}
}

View File

@ -40,7 +40,7 @@
$isOnboarding = in_array($tenant->status, [\App\Models\ManagedEnvironment::STATUS_DRAFT, \App\Models\ManagedEnvironment::STATUS_ONBOARDING], true);
$backUrl = $isOnboarding
? route('admin.onboarding')
: route('filament.admin.resources.tenants.view', ['tenant' => $tenant->external_id, 'record' => $tenant]);
: \App\Support\ManagedEnvironmentLinks::viewUrl($tenant);
$backLabel = $isOnboarding ? 'Zurück zum Onboarding' : 'Zurück zur ManagedEnvironment-Detailseite';
@endphp

View File

@ -41,7 +41,7 @@
? route('admin.home')
: ChooseWorkspace::getUrl(panel: 'admin');
$tenantTriggerLabel = $workspace ? $tenantLabel : __('localization.shell.choose_workspace');
$localePlane = Filament::getCurrentPanel()?->getId() === 'tenant' ? 'tenant' : 'admin';
$localePlane = 'admin';
@endphp
<div class="inline-flex items-center gap-0 rounded-lg border border-gray-200 bg-white text-sm dark:border-white/10 dark:bg-white/5">

View File

@ -1,6 +1,5 @@
<?php
use App\Filament\Pages\TenantDashboard;
use App\Filament\Pages\WorkspaceOverview;
use App\Http\Controllers\AdminConsentCallbackController;
use App\Http\Controllers\Auth\EntraController;
@ -20,6 +19,7 @@
use App\Models\Workspace;
use App\Services\Onboarding\OnboardingDraftResolver;
use App\Support\Auth\WorkspaceRole;
use App\Support\ManagedEnvironmentLinks;
use App\Services\Tenants\TenantOperabilityService;
use App\Support\Tenants\TenantOperabilityQuestion;
use App\Support\Tenants\TenantPageCategory;
@ -141,7 +141,7 @@
$resolveSmokeRedirect = static function (?string $redirect, ?ManagedEnvironment $tenant = null): string {
$fallback = $tenant instanceof ManagedEnvironment && ! $tenant->trashed()
? TenantDashboard::getUrl(tenant: $tenant)
? ManagedEnvironmentLinks::viewUrl($tenant)
: '/admin';
$redirect = trim((string) $redirect);
@ -511,6 +511,32 @@
->get('/admin/workspaces/{workspace}/environments/{tenant:slug}', \App\Filament\Pages\TenantDashboard::class)
->name('admin.workspace.environments.show');
Route::middleware([
'web',
'panel:admin',
'ensure-correct-guard:web',
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
FilamentAuthenticate::class,
'ensure-workspace-member',
'ensure-filament-tenant-selected',
])
->get('/admin/workspaces/{workspace}/environments/{tenant:slug}/diagnostics', \App\Filament\Pages\TenantDiagnostics::class)
->name('admin.workspace.environments.diagnostics');
Route::middleware([
'web',
'panel:admin',
'ensure-correct-guard:web',
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
FilamentAuthenticate::class,
'ensure-workspace-member',
'ensure-filament-tenant-selected',
])
->get('/admin/workspaces/{workspace}/environments/{tenant:slug}/access-scopes', \App\Filament\Resources\TenantResource\Pages\ManageTenantMemberships::class)
->name('admin.workspace.environments.access-scopes');
Route::middleware(['signed'])
->get('/admin/review-packs/{reviewPack}/download', ReviewPackDownloadController::class)
->name('admin.review-packs.download');

View File

@ -169,7 +169,7 @@ function spec192ApprovedFindingException(ManagedEnvironment $tenant, User $reque
->assertNoJavaScriptErrors()
->assertScript("document.querySelectorAll('[data-supporting-group-kind]').length === 0", true)
->assertSee('Resume onboarding')
->assertSee('Open tenant detail');
->assertSee('Open environment detail');
});
it('smokes the explicit workflow-heavy tenant detail exception without javascript errors', function (): void {

View File

@ -3,10 +3,10 @@
declare(strict_types=1);
use App\Filament\Resources\ProviderConnectionResource;
use App\Filament\Resources\TenantResource;
use App\Models\ManagedEnvironment;
use App\Models\ProviderConnection;
use App\Models\TenantOnboardingSession;
use App\Support\ManagedEnvironmentLinks;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
@ -60,7 +60,7 @@
'record' => $connection,
'managed_environment_id' => $tenant->external_id,
], panel: 'admin'), PHP_URL_PATH);
$tenantViewPath = (string) parse_url(TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin'), PHP_URL_PATH);
$tenantViewPath = (string) parse_url(ManagedEnvironmentLinks::viewUrl($tenant), PHP_URL_PATH);
visit(ProviderConnectionResource::getUrl('view', [
'record' => $connection,
@ -82,13 +82,11 @@
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
visit(TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin'))
visit(ManagedEnvironmentLinks::viewUrl($tenant))
->waitForText('Provider connection')
->assertScript("window.location.pathname === '{$tenantViewPath}'", true)
->assertSee('Spec 281 Browser Connection')
->assertSee('Target scope')
->assertSee('Spec 281 Browser Environment')
->assertSee('Open Provider Connections')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
});

View File

@ -5,6 +5,7 @@
use App\Models\OperationRun;
use App\Models\ManagedEnvironment;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\ManagedEnvironmentLinks;
use App\Support\OperationRunLinks;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
@ -39,7 +40,7 @@ public function test_trusts_canonical_run_links_opened_from_a_tenant_surface_aft
canonicalRouteName: 'admin.operations.view',
tenantId: (int) $runTenant->getKey(),
backLinkLabel: 'Back to tenant',
backLinkUrl: route('filament.admin.resources.tenants.view', ['record' => $runTenant]),
backLinkUrl: ManagedEnvironmentLinks::viewUrl($runTenant),
);
setAdminPanelContext($otherTenant);
@ -49,7 +50,7 @@ public function test_trusts_canonical_run_links_opened_from_a_tenant_surface_aft
->get(OperationRunLinks::view($run, $runTenant, $context))
->assertOk()
->assertSee('Back to tenant')
->assertSee(route('filament.admin.resources.tenants.view', ['record' => $runTenant]), false)
->assertSee(ManagedEnvironmentLinks::viewUrl($runTenant), false)
->assertSee('Current environment context differs from this operation');
}

View File

@ -5,6 +5,7 @@
use App\Models\TenantOnboardingSession;
use App\Models\Workspace;
use App\Models\OperationRun;
use App\Support\ManagedEnvironmentLinks;
use App\Support\Providers\ProviderReasonCodes;
use Illuminate\Foundation\Testing\RefreshDatabase;
@ -25,7 +26,7 @@
$response->assertSeeText('Verification state:');
$response->assertSeeText('Needs verification');
$response->assertSee(
route('filament.admin.resources.tenants.view', ['tenant' => $tenant->external_id, 'record' => $tenant]),
ManagedEnvironmentLinks::viewUrl($tenant),
false,
);

View File

@ -247,7 +247,7 @@ function tenantDashboardButtonClassesForXPath(string $content, string $xpathExpr
->and($actions[2]['actionUrl'])->toBe(FindingExceptionResource::getUrl('index', panel: 'admin', tenant: $tenant));
$this->actingAs($user);
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
$content = $this->get(TenantDashboard::getUrl(panel: 'admin', tenant: $tenant))
->assertSuccessful()

View File

@ -125,7 +125,7 @@ function tenantDashboardProductizationHeaderMoreActionNames(Testable $component)
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
$this->get(TenantDashboard::getUrl(panel: 'admin', tenant: $tenant))
->assertSuccessful();
@ -142,7 +142,7 @@ function tenantDashboardProductizationHeaderMoreActionNames(Testable $component)
'status' => Finding::STATUS_NEW,
]);
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
$component = Livewire::actingAs($user)
->test(TenantDashboard::class)
@ -181,7 +181,7 @@ function tenantDashboardProductizationHeaderMoreActionNames(Testable $component)
workspaceOverviewSeedRestoreHistory($tenant, $backupSet);
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
$component = Livewire::actingAs($user)
->test(TenantDashboard::class)
@ -209,7 +209,7 @@ function tenantDashboardProductizationHeaderMoreActionNames(Testable $component)
],
]);
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
$summary = app(TenantDashboardSummaryBuilder::class)
->build($tenant, $user)
@ -259,7 +259,7 @@ function tenantDashboardProductizationHeaderMoreActionNames(Testable $component)
->and(collect($summary['governanceStatus'])->firstWhere('key', 'backup_posture')['actionUrl'] ?? null)->toBeNull();
$this->actingAs($user);
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
$content = $this->get(TenantDashboard::getUrl(panel: 'admin', tenant: $tenant))
->assertSuccessful()
@ -311,7 +311,7 @@ function tenantDashboardProductizationHeaderMoreActionNames(Testable $component)
->and($governanceStatus['provider_permissions']['actionUrl'] ?? null)->not->toBeNull();
$this->actingAs($user);
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
$content = $this->get(TenantDashboard::getUrl(panel: 'admin', tenant: $tenant))
->assertSuccessful()

View File

@ -262,7 +262,7 @@ function mockTenantDashboardReadinessPermissions(array $overview = []): void
composeTenantReviewForTest($tenant, $user, $snapshot);
$this->actingAs($user);
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
$content = $this->get(TenantDashboard::getUrl(panel: 'admin', tenant: $tenant))
->assertSuccessful()

View File

@ -72,7 +72,7 @@ function mockTenantDashboardSummaryPermissions(array $overview = []): void
]);
$this->actingAs($user);
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
$response = $this->get(TenantDashboard::getUrl(panel: 'admin', tenant: $tenant))
->assertSuccessful()
@ -292,7 +292,7 @@ function mockTenantDashboardSummaryPermissions(array $overview = []): void
workspaceOverviewSeedRestoreHistory($tenant, $backupSet);
$this->actingAs($user);
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
$response = $this->get(TenantDashboard::getUrl(panel: 'admin', tenant: $tenant))
->assertSuccessful()
@ -379,7 +379,7 @@ function mockTenantDashboardSummaryPermissions(array $overview = []): void
->and($items->pluck('id')->contains((int) $healthyRunningRun->getKey()))->toBeFalse();
$this->actingAs($user);
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
$this->get(TenantDashboard::getUrl(panel: 'admin', tenant: $tenant))
->assertSuccessful()
@ -415,7 +415,7 @@ function mockTenantDashboardSummaryPermissions(array $overview = []): void
expect($summary['activeOperationSummary'] ?? null)->toBeNull();
$this->actingAs($user);
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
$this->get(TenantDashboard::getUrl(panel: 'admin', tenant: $tenant))
->assertSuccessful()

View File

@ -1,12 +1,14 @@
<?php
use App\Support\ManagedEnvironmentLinks;
uses(Illuminate\Foundation\Testing\RefreshDatabase::class);
test('authenticated tenant member can load a tenant-scoped Filament page', function () {
test('authenticated tenant member can load the managed-environment registry', function () {
[$user, $tenant] = createUserWithTenant();
$this->actingAs($user)
->get(route('filament.admin.resources.tenants.index', filamentTenantRouteParams($tenant)))
->get(ManagedEnvironmentLinks::indexUrl($tenant))
->assertOk()
->assertSee($tenant->name);
});

View File

@ -64,7 +64,7 @@ function editTenantHeaderPrimaryNames(Testable $component): array
$component = Livewire::test(EditTenant::class, ['record' => $tenant->getRouteKey()])
->assertSee('Related context')
->assertSee('Open tenant detail')
->assertSee('Open environment detail')
->assertSee('Resume onboarding');
expect(editTenantHeaderPrimaryNames($component))->toBe([])

View File

@ -2,7 +2,6 @@
declare(strict_types=1);
use App\Filament\Resources\TenantResource;
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
use App\Models\AuditLog;
use App\Models\ManagedEnvironment;
@ -83,10 +82,8 @@
->where('managed_environment_id', (int) $tenant->getKey())
->exists())->toBeFalse();
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin'))
->assertSuccessful()
Livewire::actingAs($user)
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
->assertSee('Removed from workspace');
$this->actingAs($user)

View File

@ -37,7 +37,7 @@ function tenantDashboardArrivalWidget(\App\Models\User $user, \App\Models\Manage
{
test()->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
request()->attributes->remove('portfolio_triage.arrival_context');
return Livewire::withQueryParams([

View File

@ -19,7 +19,7 @@ function tenantSearchTitles($results): array
return collect($results)->map(fn ($result): string => (string) $result->title)->all();
}
it('keeps tenant global-search aligned with administrative discoverability in the current workspace', function (): void {
it('keeps retired tenant resources out of global search', function (): void {
$active = ManagedEnvironment::factory()->active()->create(['name' => 'Lifecycle Active']);
[$user, $active] = createUserWithTenant(tenant: $active, role: 'owner');
@ -49,17 +49,8 @@ function tenantSearchTitles($results): array
$results = TenantResource::getGlobalSearchResults('Lifecycle');
expect(tenantSearchTitles($results))->toEqualCanonicalizing([
'Lifecycle Active',
'Lifecycle Onboarding',
'Lifecycle Draft',
'Lifecycle Archived',
]);
expect($results->first()?->url)
->not->toBeNull();
expect(collect($results)->filter(fn ($result): bool => filled($result->url))->count())
->toBe($results->count());
expect(TenantResource::canGloballySearch())->toBeFalse()
->and(tenantSearchTitles($results))->toBe([]);
});
it('keeps first-slice taxonomy resources out of global search', function (): void {

View File

@ -1,9 +1,11 @@
<?php
use App\Filament\Resources\TenantResource\Pages\ListTenants;
use App\Filament\Resources\TenantResource;
use App\Jobs\BulkTenantSyncJob;
use App\Models\ManagedEnvironment;
use App\Support\Auth\UiTooltips;
use App\Support\ManagedEnvironmentLinks;
use Filament\Events\TenantSet;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
@ -33,10 +35,7 @@
[$user, $authorizedTenant] = createUserWithTenant($authorizedTenant, role: 'owner');
$this->actingAs($user);
$this->get(route('filament.admin.resources.tenants.view', array_merge(
filamentTenantRouteParams($unauthorizedTenant),
['record' => $unauthorizedTenant],
)))->assertNotFound();
$this->get(ManagedEnvironmentLinks::viewUrl($unauthorizedTenant))->assertNotFound();
});
test('tenant portfolio tenant edit returns 404 for non-member tenant record', function () {
@ -46,10 +45,7 @@
[$user, $authorizedTenant] = createUserWithTenant($authorizedTenant, role: 'owner');
$this->actingAs($user);
$this->get(route('filament.admin.resources.tenants.edit', array_merge(
filamentTenantRouteParams($unauthorizedTenant),
['record' => $unauthorizedTenant],
)))->assertNotFound();
$this->get(TenantResource::getUrl('edit', ['record' => $unauthorizedTenant], panel: 'admin'))->assertNotFound();
});
test('tenant portfolio lists only tenants the user can access', function () {
@ -66,7 +62,7 @@
[$user, $authorizedTenant] = createUserWithTenant($authorizedTenant, role: 'owner');
$this->actingAs($user);
$this->get(route('filament.admin.resources.tenants.index', filamentTenantRouteParams($authorizedTenant)))
$this->get(ManagedEnvironmentLinks::indexUrl($authorizedTenant))
->assertOk()
->assertSee($authorizedTenant->name)
->assertDontSee($unauthorizedTenant->name);

View File

@ -60,9 +60,8 @@ function tenantRegistryArrivalStateFromUrl(string $url): ?array
$this->usePortfolioTriageWorkspace($user, $tenant);
$this->get(TenantResource::getUrl('index', panel: 'admin'))
->assertOk()
->assertSee($expectedUrl, false);
$this->portfolioTriageRegistryList($user, $tenant)
->assertTableActionHasUrl('openTenant', $expectedUrl, $tenant);
expect($expectedUrl)->not->toContain(PortfolioArrivalContextToken::QUERY_PARAMETER.'=');
});

View File

@ -8,6 +8,7 @@
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\ManagedEnvironmentLinks;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
@ -70,7 +71,7 @@
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspaceA->getKey()])
->get(route('filament.admin.resources.tenants.index', filamentTenantRouteParams($tenantA)))
->get(ManagedEnvironmentLinks::indexUrl($tenantA))
->assertOk()
->assertSee('ManagedEnvironment A')
->assertDontSee('ManagedEnvironment B');

View File

@ -47,7 +47,7 @@ function tenantReviewHeaderGroupLabels(Testable $component): array
[$user, $tenant] = createUserWithTenant(role: 'owner');
$review = composeTenantReviewForTest($tenant, $user);
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
$component = Livewire::actingAs($user)
->test(ViewTenantReview::class, ['record' => $review->getKey()])
@ -68,7 +68,7 @@ function tenantReviewHeaderGroupLabels(Testable $component): array
'published_by_user_id' => (int) $user->getKey(),
])->save();
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
$component = Livewire::actingAs($user)
->test(ViewTenantReview::class, ['record' => $review->getKey()])

View File

@ -7,9 +7,11 @@
use App\Models\ManagedEnvironment;
use App\Models\TenantPermission;
use App\Models\User;
use App\Support\ManagedEnvironmentLinks;
use App\Support\OperationRunLinks;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Verification\VerificationReportSchema;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
@ -150,7 +152,7 @@
'status' => 'ok',
]);
$response = $this->get(route('filament.admin.resources.tenants.view', array_merge(filamentTenantRouteParams($tenant), ['record' => $tenant])));
$response = $this->get(ManagedEnvironmentLinks::requiredPermissionsUrl($tenant));
$response->assertOk();
$response->assertSee('Actions');
@ -172,10 +174,14 @@
[$user, $tenant] = createUserWithTenant($tenant, $user, role: 'owner');
$this->actingAs($user);
$response = $this->get(route('filament.admin.resources.tenants.index', filamentTenantRouteParams($tenant)));
Filament::setCurrentPanel('admin');
Filament::setTenant(null, true);
Filament::bootCurrentPanel();
session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
$response->assertOk();
$response->assertSee('Open in Entra');
Livewire::actingAs($user)
->test(\App\Filament\Resources\TenantResource\Pages\ListTenants::class)
->assertTableActionVisible('open_in_entra', $tenant);
});
test('tenant can be archived from the tenant detail action menu', function () {

View File

@ -173,12 +173,8 @@
bindFailHardGraphClient();
assertNoOutboundHttp(function () use ($user, $tenant): void {
$this->actingAs($user)
->get(route('filament.admin.resources.tenants.view', array_merge(
filamentTenantRouteParams($tenant),
['record' => $tenant]
)))
->assertOk()
Livewire::actingAs($user)
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
->assertSee('Verification report');
});
});

View File

@ -4,6 +4,7 @@
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\ManagedEnvironmentLinks;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
@ -59,7 +60,7 @@
$workspace = Workspace::query()->whereKey($tenant->workspace_id)->firstOrFail();
$this->actingAs($user)
->get(route('filament.admin.resources.tenants.index', filamentTenantRouteParams($tenant)))
->get(ManagedEnvironmentLinks::indexUrl($tenant))
->assertOk()
->assertSee($workspace->name)
->assertDontSee('name="workspace_id"', escape: false);

View File

@ -67,6 +67,7 @@ function workspaceOverviewArrivalStateFromUrl(string $url): ?array
'tenantRouteKey' => (string) $backupTenant->external_id,
'concernFamily' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
'concernState' => TenantBackupHealthAssessment::POSTURE_ABSENT,
])->and($recoveryDestinationUrl)->toContain('/admin/tenants')
])->and($recoveryDestinationUrl)->toContain('/admin/workspaces/')
->and($recoveryDestinationUrl)->toContain('/environments')
->and($recoveryDestinationUrl)->not->toContain(PortfolioArrivalContextToken::QUERY_PARAMETER.'=');
});

View File

@ -115,7 +115,7 @@ function materializeFindingOutcomeSnapshot(\App\Models\ManagedEnvironment $tenan
expect(data_get($review->summary, 'finding_outcomes.'.FindingOutcomeSemantics::OUTCOME_CLOSED_DUPLICATE))->toBe(1)
->and(data_get($review->summary, 'finding_report_buckets.administrative_closure'))->toBe(1);
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
$this->actingAs($user)
->get(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant))

View File

@ -103,6 +103,7 @@
use App\Support\Evidence\EvidenceCompletenessState;
use App\Support\Evidence\EvidenceSnapshotStatus;
use App\Support\Navigation\RelatedActionLabelCatalog;
use App\Support\ManagedEnvironmentLinks;
use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
@ -182,7 +183,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
expect($exemptions->hasClass('App\\Filament\\Resources\\ActionSurfaceUnknownResource'))->toBeFalse();
});
it('maps tenant/admin panel scope metadata from discovery sources', function (): void {
it('maps managed-environment/admin panel scope metadata from discovery sources', function (): void {
$components = collect(ActionSurfaceValidator::withBaselineExemptions()->discoveredComponents())
->keyBy('className');
@ -190,7 +191,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
$policyResource = $components->get(\App\Filament\Resources\PolicyResource::class);
expect($tenantResource)->not->toBeNull();
expect($tenantResource?->hasPanelScope(ActionSurfacePanelScope::Admin))->toBeTrue();
expect($tenantResource?->hasPanelScope(ActionSurfacePanelScope::Admin))->toBeFalse();
expect($policyResource)->not->toBeNull();
expect($policyResource?->hasPanelScope(ActionSurfacePanelScope::ManagedEnvironment))->toBeTrue();
@ -615,18 +616,18 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(TenantResource::getUrl('view', ['record' => $tenant->getRouteKey()], panel: 'admin'))
->assertOk()
->assertSee('Manage access scope')
->assertSee('href="'.$membershipsUrl.'"', false)
->assertDontSee('/admin/tenants', false)
->assertDontSee('/admin/t/', false)
->assertDontSeeLivewire(TenantMembershipsRelationManager::class);
session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
$membershipsPage = Livewire::actingAs($user)
->test(ManageTenantMemberships::class, ['record' => $tenant->getRouteKey()])
->test(ManageTenantMemberships::class, ['tenant' => $tenant->getRouteKey()])
->assertActionVisible('back_to_overview')
->assertActionDoesNotExist('memberships')
->assertActionExists('back_to_overview', fn ($action): bool => $action->getLabel() === 'Back to environment overview'
&& $action->getUrl() === TenantResource::getUrl('view', ['record' => $tenant->getRouteKey()], panel: 'admin'));
&& $action->getUrl() === ManagedEnvironmentLinks::viewUrl($tenant));
expect($membershipsPage->instance()->getRelationManagers())
->toContain(TenantMembershipsRelationManager::class);
@ -1315,7 +1316,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
$review = composeTenantReviewForTest($tenant, $user);
$this->actingAs($user);
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
$livewire = Livewire::test(ListTenantReviews::class)
->assertCanSeeTableRecords([$review]);
@ -1722,7 +1723,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
[$manager, $tenant] = createUserWithTenant(role: 'manager');
$this->actingAs($manager);
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
Livewire::test(TenantDiagnostics::class)
->assertActionExists('bootstrapOwner');

View File

@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\TenantResource;
use App\Models\OperationRun;
use App\Support\ManagedEnvironmentLinks;
use App\Support\OperationRunLinks;
use App\Support\Workspaces\WorkspaceContext;
it('generates canonical managed-environment route families only', function (): void {
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
$urls = [
ManagedEnvironmentLinks::indexUrl($tenant),
ManagedEnvironmentLinks::viewUrl($tenant),
ManagedEnvironmentLinks::requiredPermissionsUrl($tenant),
ManagedEnvironmentLinks::diagnosticsUrl($tenant),
ManagedEnvironmentLinks::accessScopesUrl($tenant),
ManagedEnvironmentLinks::operationsUrl($tenant),
ManagedEnvironmentLinks::providerConnectionsUrl($tenant),
TenantResource::getUrl('index'),
TenantResource::getUrl('view', ['record' => $tenant]),
TenantResource::getUrl('edit', ['record' => $tenant]),
TenantResource::getUrl('memberships', ['record' => $tenant]),
OperationRunLinks::index($tenant),
];
$run = OperationRun::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'managed_environment_id' => (int) $tenant->getKey(),
]);
$urls[] = OperationRunLinks::tenantlessView($run);
foreach ($urls as $url) {
expect($url)
->not->toContain('/admin/tenants')
->not->toContain('/admin/t/');
}
expect(ManagedEnvironmentLinks::viewUrl($tenant))->toContain('/admin/workspaces/')
->and(ManagedEnvironmentLinks::viewUrl($tenant))->toContain('/environments/'.$tenant->getRouteKey())
->and(ManagedEnvironmentLinks::requiredPermissionsUrl($tenant))->toEndWith('/required-permissions')
->and(ManagedEnvironmentLinks::diagnosticsUrl($tenant))->toEndWith('/diagnostics')
->and(ManagedEnvironmentLinks::accessScopesUrl($tenant))->toEndWith('/access-scopes')
->and(ManagedEnvironmentLinks::providerConnectionsUrl($tenant))->toContain('/admin/provider-connections?managed_environment_id='.(string) $tenant->external_id)
->and(OperationRunLinks::index($tenant))->toContain('/admin/workspaces/')
->and(OperationRunLinks::tenantlessView($run))->toContain('/admin/workspaces/');
});
it('keeps the retired TenantResource out of global search', function (): void {
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
expect(TenantResource::getGlobalSearchResults((string) $tenant->name))->toHaveCount(0);
});

View File

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Support\Facades\Route;
it('does not register active /admin/tenants product routes', function (): void {
$legacyRouteUris = collect(Route::getRoutes())
->map(fn ($route): string => ltrim((string) $route->uri(), '/'))
->filter(fn (string $uri): bool => preg_match('#^admin/tenants(?:/|$)#', $uri) === 1)
->values();
expect($legacyRouteUris)->toBeEmpty();
});
it('returns 404 for retired TenantResource route shapes', function (): void {
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
foreach ([
'/admin/tenants',
"/admin/tenants/{$tenant->external_id}",
"/admin/tenants/{$tenant->external_id}/edit",
"/admin/tenants/{$tenant->external_id}/memberships",
] as $path) {
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get($path)
->assertNotFound();
}
});

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Route;
it('does not keep a runtime TenantPanelProvider or tenant panel registration', function (): void {
expect(file_exists(app_path('Providers/Filament/TenantPanelProvider.php')))->toBeFalse()
->and(require base_path('bootstrap/providers.php'))->not->toContain('App\\Providers\\Filament\\TenantPanelProvider')
->and(Filament::getPanel('tenant'))->toBeNull();
});
it('does not register active /admin/t routes', function (): void {
$legacyRouteUris = collect(Route::getRoutes())
->map(fn ($route): string => ltrim((string) $route->uri(), '/'))
->filter(fn (string $uri): bool => preg_match('#^admin/t(?:/|$)#', $uri) === 1)
->values();
expect($legacyRouteUris)->toBeEmpty();
$this->get('/admin/t/example')->assertNotFound();
});

View File

@ -24,7 +24,6 @@ function operationRunLinkContractIncludePaths(): array
'system_ops_runs' => $root.'/app/Filament/System/Pages/Ops/Runs.php',
'system_ops_view_run' => $root.'/app/Filament/System/Pages/Ops/ViewRun.php',
'admin_panel_provider' => $root.'/app/Providers/Filament/AdminPanelProvider.php',
'tenant_panel_provider' => $root.'/app/Providers/Filament/TenantPanelProvider.php',
'ensure_filament_tenant_selected' => $root.'/app/Support/Middleware/EnsureFilamentTenantSelected.php',
'clear_tenant_context_controller' => $root.'/app/Http/Controllers/ClearTenantContextController.php',
'operation_run_url_delegate' => $root.'/app/Support/OpsUx/OperationRunUrl.php',

View File

@ -82,7 +82,7 @@
$review->forceFill(['current_export_review_pack_id' => (int) $pack->getKey()])->save();
App::setLocale('de');
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])

View File

@ -5,6 +5,7 @@
use App\Filament\Pages\ChooseWorkspace;
use App\Models\OperationRun;
use App\Models\ManagedEnvironment;
use App\Support\ManagedEnvironmentLinks;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
@ -77,7 +78,7 @@
->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
])
->get(route('filament.admin.resources.tenants.index', filamentTenantRouteParams($tenant)))
->get(ManagedEnvironmentLinks::viewUrl($tenant))
->assertOk()
->assertSee($tenant->getFilamentName())
->assertSee(__('localization.shell.clear_environment_scope'));
@ -109,7 +110,7 @@
(string) $currentTenant->workspace_id => (int) $currentTenant->getKey(),
],
])
->get(route('filament.admin.resources.tenants.view', ['record' => $routedTenant]))
->get(ManagedEnvironmentLinks::viewUrl($routedTenant))
->assertOk()
->assertSee(__('localization.shell.clear_environment_scope'));
});

View File

@ -397,7 +397,7 @@
->assertSuccessful()
->assertSee('Complete onboarding')
->assertDontSee('Activate tenant')
->assertDontSeeText('Restore tenant')
->assertDontSeeText('Restore environment')
->assertDontSeeText('Archive tenant')
->assertSee('After completion');
});

View File

@ -3,9 +3,9 @@
declare(strict_types=1);
use App\Filament\Resources\ProviderConnectionResource;
use App\Filament\Resources\TenantResource;
use App\Models\ProviderConnection;
use App\Models\TenantPermission;
use App\Support\Links\RequiredPermissionsLinks;
use Illuminate\Support\Facades\Bus;
it('Spec081 renders provider connection list/edit pages DB-only', function (): void {
@ -53,7 +53,7 @@
Bus::assertNothingDispatched();
});
it('Spec081 renders tenant view page DB-only', function (): void {
it('Spec081 renders required-permissions page DB-only', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
TenantPermission::query()->create([
@ -67,7 +67,7 @@
Bus::fake();
assertNoOutboundHttp(function () use ($tenant): void {
$this->get(TenantResource::getUrl('view', ['record' => $tenant], tenant: $tenant))
$this->get(RequiredPermissionsLinks::requiredPermissions($tenant, ['status' => 'all']))
->assertOk()
->assertSee($tenant->name)
->assertSee('DeviceManagementConfiguration.ReadWrite.All');

View File

@ -2,6 +2,9 @@
declare(strict_types=1);
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
use Livewire\Livewire;
it('renders humanized RBAC reasons while keeping the diagnostic code in tenant governance details', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
@ -10,12 +13,8 @@
'rbac_status_reason' => 'manual_assignment_required',
])->save();
$this->actingAs($user)
->get(route('filament.admin.resources.tenants.view', array_merge(
filamentTenantRouteParams($tenant),
['record' => $tenant]
)))
->assertSuccessful()
Livewire::actingAs($user)
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
->assertSee('Manual role assignment required')
->assertSee('This tenant requires a manual Intune RBAC role assignment outside the automated API path.')
->assertSee('manual_assignment_required');

View File

@ -194,7 +194,7 @@ function setReviewPackSubscriptionState(ManagedEnvironment $tenant, array $attri
->count();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
Livewire::actingAs($user)
->test(TenantReviewPackCard::class, ['record' => $tenant])
@ -257,7 +257,7 @@ function setReviewPackSubscriptionState(ManagedEnvironment $tenant, array $attri
->and($decision['warning_reason'])->toContain('grace');
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
Livewire::actingAs($user)
->test(TenantReviewPackCard::class, ['record' => $tenant])
@ -296,7 +296,7 @@ function setReviewPackSubscriptionState(ManagedEnvironment $tenant, array $attri
->and($decision['warning_reason'])->toContain('Commercial source: subscription-backed.');
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
Livewire::actingAs($user)
->test(TenantReviewPackCard::class, ['record' => $tenant])

View File

@ -159,7 +159,7 @@ function seedReviewPackEvidence(ManagedEnvironment $tenant): EvidenceSnapshot
'managed_environment_id' => (int) $otherTenant->getKey(),
]);
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
Livewire::actingAs($user)
->test(ListReviewPacks::class)

View File

@ -100,7 +100,7 @@ function seedWidgetReviewPackSnapshot(ManagedEnvironment $tenant): EvidenceSnaps
$tenant = ManagedEnvironment::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
Livewire::actingAs($user)
->test(TenantReviewPackCard::class, ['record' => $tenant])
@ -126,7 +126,7 @@ function seedWidgetReviewPackSnapshot(ManagedEnvironment $tenant): EvidenceSnaps
'file_disk' => 'exports',
]);
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
Livewire::actingAs($user)
->test(TenantReviewPackCard::class, ['record' => $tenant])
@ -147,7 +147,7 @@ function seedWidgetReviewPackSnapshot(ManagedEnvironment $tenant): EvidenceSnaps
'initiated_by_user_id' => (int) $user->getKey(),
]);
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
Livewire::actingAs($user)
->test(TenantReviewPackCard::class, ['record' => $tenant])
@ -167,7 +167,7 @@ function seedWidgetReviewPackSnapshot(ManagedEnvironment $tenant): EvidenceSnaps
'initiated_by_user_id' => (int) $user->getKey(),
]);
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
Livewire::actingAs($user)
->test(TenantReviewPackCard::class, ['record' => $tenant])
@ -187,7 +187,7 @@ function seedWidgetReviewPackSnapshot(ManagedEnvironment $tenant): EvidenceSnaps
'initiated_by_user_id' => (int) $user->getKey(),
]);
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
Livewire::actingAs($user)
->test(TenantReviewPackCard::class, ['record' => $tenant])
@ -230,7 +230,7 @@ function seedWidgetReviewPackSnapshot(ManagedEnvironment $tenant): EvidenceSnaps
expect($reasonEnvelope)->not->toBeNull()
->and($reasonSemantics)->not->toBeNull();
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
Livewire::actingAs($user)
->test(TenantReviewPackCard::class, ['record' => $tenant])
@ -252,7 +252,7 @@ function seedWidgetReviewPackSnapshot(ManagedEnvironment $tenant): EvidenceSnaps
'initiated_by_user_id' => (int) $user->getKey(),
]);
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
Livewire::actingAs($user)
->test(TenantReviewPackCard::class, ['record' => $tenant])
@ -267,7 +267,7 @@ function seedWidgetReviewPackSnapshot(ManagedEnvironment $tenant): EvidenceSnaps
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
seedWidgetReviewPackSnapshot($tenant);
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
Livewire::actingAs($user)
->test(TenantReviewPackCard::class, ['record' => $tenant])

View File

@ -2,7 +2,6 @@
declare(strict_types=1);
use App\Filament\Resources\TenantResource;
use App\Models\AuditLog;
use App\Models\ProviderConnection;
use App\Models\ManagedEnvironment;
@ -11,6 +10,7 @@
use App\Models\WorkspaceMembership;
use App\Services\Auth\TenantMembershipManager;
use App\Support\Audit\AuditActionId;
use App\Support\ManagedEnvironmentLinks;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
@ -22,12 +22,12 @@
Http::preventStrayRequests();
});
it('allows workspace members to open the workspace-managed tenants index', function (): void {
it('allows workspace members to open the canonical workspace-managed environments index', function (): void {
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get('/admin/tenants')
->get(ManagedEnvironmentLinks::indexUrl($tenant))
->assertOk();
});
@ -41,23 +41,27 @@
->assertNotFound();
});
it('allows workspace members to open the workspace-managed tenant view route', function (): void {
it('allows workspace members to open the canonical workspace-managed environment view route', function (): void {
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get("/admin/tenants/{$tenant->external_id}")
->get(ManagedEnvironmentLinks::viewUrl($tenant))
->assertOk();
});
it('exposes a provider connections link from the workspace-managed tenant view page', function (): void {
it('exposes a canonical provider connections link for a managed environment', function (): void {
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get("/admin/tenants/{$tenant->external_id}")
->assertOk()
->assertSee('/admin/provider-connections?managed_environment_id='.$tenant->external_id, false);
->get(ManagedEnvironmentLinks::viewUrl($tenant))
->assertOk();
expect(ManagedEnvironmentLinks::providerConnectionsUrl($tenant))
->toContain('/admin/provider-connections?managed_environment_id='.$tenant->external_id)
->not->toContain('/admin/tenants')
->not->toContain('/admin/t/');
});
it('returns 404 for non-members on the workspace-managed tenant view route', function (): void {
@ -70,12 +74,12 @@
->assertNotFound();
});
it('exposes memberships management under workspace scope', function (): void {
it('exposes access-scope management under workspace scope', function (): void {
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get("/admin/tenants/{$tenant->external_id}/memberships")
->get(ManagedEnvironmentLinks::accessScopesUrl($tenant))
->assertOk();
});
@ -228,17 +232,16 @@
->and($actions)->not->toContain(AuditActionId::TenantMembershipRoleChange->value);
});
it('keeps workspace navigation entries after panel split', function (): void {
it('keeps the canonical managed-environment index available after panel split', function (): void {
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get('/admin/tenants')
->get(ManagedEnvironmentLinks::indexUrl($tenant))
->assertOk()
->assertSee('Tenants')
->assertSee('Operations')
->assertSee('Alerts')
->assertSee('Audit Log');
->assertSee('Managed environments')
->assertDontSee('/admin/tenants', false)
->assertDontSee('/admin/t/', false);
});
it('does not expose tenant-management resources in tenant panel registration or navigation URLs', function (): void {
@ -248,13 +251,13 @@
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get("/admin/tenants/{$tenant->external_id}")
->get(ManagedEnvironmentLinks::viewUrl($tenant))
->assertOk()
->assertDontSee("/admin/t/{$tenant->external_id}/provider-connections", false)
->assertDontSee("/admin/t/{$tenant->external_id}/tenants", false);
});
it('keeps global search scoped to workspace-managed tenant resources only', function (): void {
it('disables global search on the retired TenantResource product route owner', function (): void {
[$workspaceUser, $tenant] = createMinimalUserWithTenant(role: 'owner');
Filament::setCurrentPanel('admin');
@ -262,9 +265,7 @@
$this->actingAs($workspaceUser);
$results = TenantResource::getGlobalSearchResults((string) $tenant->name);
expect($results->count())->toBeGreaterThan(0);
expect(\App\Filament\Resources\TenantResource::getGlobalSearchResults((string) $tenant->name))->toHaveCount(0);
$nonMember = User::factory()->create();
@ -273,7 +274,5 @@
$this->actingAs($nonMember);
$nonMemberResults = TenantResource::getGlobalSearchResults((string) $tenant->name);
expect($nonMemberResults)->toHaveCount(0);
expect(\App\Filament\Resources\TenantResource::getGlobalSearchResults((string) $tenant->name))->toHaveCount(0);
});

View File

@ -83,7 +83,7 @@ function storedReportDetailHeaderActionNames(ViewStoredReport $page): array
->assertSee('permission-fingerprint');
$this->actingAs($user);
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
$component = Livewire::test(ViewStoredReport::class, ['record' => $report->getKey()])
->assertActionHidden('open_current_report')
@ -149,7 +149,7 @@ function storedReportDetailHeaderActionNames(ViewStoredReport $page): array
]);
$this->actingAs($user);
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
Livewire::test(ViewStoredReport::class, ['record' => $historical->getKey()])
->assertActionVisible('open_current_report')

View File

@ -81,7 +81,7 @@ function storedReportEntitlementEntraReport(ManagedEnvironment $tenant): StoredR
Gate::define(Capabilities::PERMISSION_POSTURE_VIEW, fn (): bool => false);
$this->actingAs($user);
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
Livewire::test(ListStoredReports::class)
->assertCanSeeTableRecords([$entraReport])

View File

@ -94,7 +94,7 @@ function storedReportResourceEntraReport(ManagedEnvironment $tenant, array $payl
]);
$this->actingAs($user);
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
Livewire::test(ListStoredReports::class)
->assertCanSeeTableRecords([$current, $permission])
@ -118,7 +118,7 @@ function storedReportResourceEntraReport(ManagedEnvironment $tenant, array $payl
$report = storedReportResourcePermissionReport($tenant);
$this->actingAs($user);
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
$component = Livewire::test(ListStoredReports::class)
->assertCanSeeTableRecords([$report]);
@ -134,7 +134,7 @@ function storedReportResourceEntraReport(ManagedEnvironment $tenant, array $payl
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
Livewire::test(ListStoredReports::class)
->assertSee('No stored reports yet')

View File

@ -21,7 +21,7 @@ function productKnowledgeSupportDiagnosticsTenantAuthorizationComponent(User $us
{
test()->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
return Livewire::actingAs($user)->test(TenantDashboard::class);
}

View File

@ -21,7 +21,7 @@ function productKnowledgeTenantSupportDiagnosticsComponent(User $user, ManagedEn
{
test()->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
return Livewire::actingAs($user)->test(TenantDashboard::class);
}

View File

@ -23,7 +23,7 @@ function tenantDiagnosticsTelemetryComponent(User $user, ManagedEnvironment $ten
{
test()->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
return Livewire::actingAs($user)->test(TenantDashboard::class);
}

View File

@ -26,7 +26,7 @@ function supportDiagnosticsTenantAuditComponent(User $user, ManagedEnvironment $
{
test()->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
return Livewire::actingAs($user)->test(TenantDashboard::class);
}

View File

@ -20,7 +20,7 @@ function supportDiagnosticsTenantAuthorizationComponent(User $user, ManagedEnvir
{
test()->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
return Livewire::actingAs($user)->test(TenantDashboard::class);
}

View File

@ -30,7 +30,7 @@ function tenantSupportDiagnosticsComponent(User $user, ManagedEnvironment $tenan
{
test()->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
return Livewire::actingAs($user)->test(TenantDashboard::class);
}

View File

@ -26,7 +26,7 @@ function supportRequestAuditTenantComponent(User $user, ManagedEnvironment $tena
{
test()->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
return Livewire::actingAs($user)->test(TenantDashboard::class);
}

View File

@ -21,7 +21,7 @@ function supportRequestAuthorizationTenantComponent(User $user, ManagedEnvironme
{
test()->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
return Livewire::actingAs($user)->test(TenantDashboard::class);
}

View File

@ -32,7 +32,7 @@ function spec256AuditTenantComponent(User $user, ManagedEnvironment $tenant): \L
{
test()->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
return Livewire::actingAs($user)->test(TenantDashboard::class);
}

View File

@ -24,7 +24,7 @@ function spec256AuthorizationTenantComponent(User $user, ManagedEnvironment $ten
{
test()->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
return Livewire::actingAs($user)->test(TenantDashboard::class);
}

View File

@ -22,7 +22,7 @@ function tenantSupportRequestComponent(User $user, ManagedEnvironment $tenant):
{
test()->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
return Livewire::actingAs($user)->test(TenantDashboard::class);
}

View File

@ -31,7 +31,7 @@ function spec256TenantHandoffComponent(User $user, ManagedEnvironment $tenant):
{
test()->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
return Livewire::actingAs($user)->test(TenantDashboard::class);
}

View File

@ -50,7 +50,7 @@
test()->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
Livewire::actingAs($user)->test(TenantDashboard::class);

View File

@ -114,7 +114,7 @@
'published_by_user_id' => (int) $user->getKey(),
])->save();
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
$this->actingAs($user)
->get(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant).'?'.http_build_query([

View File

@ -82,7 +82,7 @@
$zip->close();
unlink($tempFile);
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
Livewire::actingAs($user)
->test(TenantReviewPackCard::class, ['record' => $tenant])

View File

@ -43,7 +43,7 @@
expect($reasonSemantics)->not->toBeNull();
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
$this->actingAs($user)
->get(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant))
@ -79,7 +79,7 @@
expect($review->operation_run_id)->not->toBeNull();
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
$this->actingAs($user)
->get(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant).'?'.http_build_query([

View File

@ -26,7 +26,7 @@
[$readonly] = createUserWithTenant(tenant: $tenant, user: User::factory()->create(), role: 'readonly');
$review = composeTenantReviewForTest($tenant, $owner);
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
Livewire::actingAs($readonly)
->test(ViewTenantReview::class, ['record' => $review->getKey()])
@ -40,7 +40,7 @@
[$user, $tenant] = createUserWithTenant(role: 'owner');
$review = composeTenantReviewForTest($tenant, $user);
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
$component = Livewire::actingAs($user)
->test(ViewTenantReview::class, ['record' => $review->getKey()])

View File

@ -36,7 +36,7 @@
->get(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant))
->assertOk();
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
Livewire::actingAs($readonly)
->test(ListTenantReviews::class)

View File

@ -50,7 +50,7 @@ function tenantReviewContractHeaderActions(Testable $component): array
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
Livewire::actingAs($user)
->test(ListTenantReviews::class)
@ -74,7 +74,7 @@ function tenantReviewContractHeaderActions(Testable $component): array
$review = composeTenantReviewForTest($tenant, $user);
$this->actingAs($user);
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
$livewire = Livewire::actingAs($user)
->test(ListTenantReviews::class)
@ -99,7 +99,7 @@ function tenantReviewContractHeaderActions(Testable $component): array
$review = composeTenantReviewForTest($tenant, $owner);
$refreshRule = GovernanceActionCatalog::rule('refresh_review');
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
Livewire::actingAs($readonly)
->test(ViewTenantReview::class, ['record' => $review->getKey()])
@ -142,7 +142,7 @@ function tenantReviewContractHeaderActions(Testable $component): array
[$owner, $tenant] = createUserWithTenant(role: 'owner');
$review = composeTenantReviewForTest($tenant, $owner);
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
$this->actingAs($owner)
->get(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant))
@ -195,7 +195,7 @@ function tenantReviewContractHeaderActions(Testable $component): array
$review->forceFill(['current_export_review_pack_id' => (int) $pack->getKey()])->save();
setTenantPanelContext($tenant);
setAdminEnvironmentContext($tenant);
$this->actingAs($user)
->get(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant).'?'.http_build_query([

View File

@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
use App\Support\ManagedEnvironmentLinks;
use App\Support\Workspaces\WorkspaceContext;
use App\Support\Workspaces\WorkspaceIntendedUrl;
use App\Support\Workspaces\WorkspaceRedirectResolver;
it('does not store retired tenant route intended URLs', function (string $path): void {
WorkspaceIntendedUrl::store($path);
expect(session()->get(WorkspaceContext::INTENDED_URL_SESSION_KEY))->toBeNull();
})->with([
'/admin/t',
'/admin/t/example',
'/admin/tenants',
'/admin/tenants/example',
'/admin/tenants/example/required-permissions',
'/admin/tenants/example/provider-connections',
]);
it('drops unsafe external intended URLs on consume', function (): void {
session()->put(WorkspaceContext::INTENDED_URL_SESSION_KEY, 'https://example.test/admin/workspaces/1/environments');
expect(WorkspaceIntendedUrl::consume())->toBeNull();
});
it('rejects retired intended URLs and falls back to the canonical environment destination', function (string $intendedUrl): void {
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$workspace = $tenant->workspace()->firstOrFail();
$resolved = app(WorkspaceRedirectResolver::class)->resolve($workspace, $user, $intendedUrl);
expect($resolved)->toBe(ManagedEnvironmentLinks::viewUrl($tenant))
->and($resolved)->not->toContain('/admin/tenants')
->and($resolved)->not->toContain('/admin/t/');
})->with([
'/admin/t/example',
'/admin/tenants',
'/admin/tenants/example',
'/admin/tenants/example/provider-connections',
'https://example.test/admin/tenants/example',
]);
it('normalizes legacy operations intended URL to the workspace-scoped operations route', function (): void {
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$workspace = $tenant->workspace()->firstOrFail();
$resolved = app(WorkspaceRedirectResolver::class)->resolve($workspace, $user, '/admin/operations?activeTab=active');
expect($resolved)->toBe(ManagedEnvironmentLinks::operationsUrl($workspace, ['activeTab' => 'active']))
->and($resolved)->toContain('/admin/workspaces/')
->and($resolved)->not->toContain('/admin/operations?');
});
it('does not preserve ambiguous legacy operation detail intended URLs', function (): void {
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$workspace = $tenant->workspace()->firstOrFail();
$resolved = app(WorkspaceRedirectResolver::class)->resolve($workspace, $user, '/admin/operations/123');
expect($resolved)->toBe(ManagedEnvironmentLinks::viewUrl($tenant));
});

View File

@ -1365,11 +1365,14 @@ function restateTenantReviewEvidenceSnapshot(
return $snapshot->fresh('items');
}
function setTenantPanelContext(ManagedEnvironment $tenant): void
function setAdminEnvironmentContext(ManagedEnvironment $tenant): void
{
setAdminPanelContext($tenant);
}
/**
* Set the workspace-first admin panel context for tests. There is no TenantPanel compatibility helper.
*/
function setAdminPanelContext(?ManagedEnvironment $tenant = null): void
{
if ($tenant instanceof ManagedEnvironment) {

View File

@ -9,7 +9,7 @@
uses(RefreshDatabase::class);
test('ProviderConnectionResource::getUrl infers tenant from referer during Livewire requests', function (): void {
test('ProviderConnectionResource::getUrl infers tenant from canonical referer query during Livewire requests', function (): void {
$tenant = ManagedEnvironment::factory()->create([
'external_id' => 'b0091e5d-944f-4a34-bcd9-12cbfb7b75cf',
]);
@ -22,7 +22,7 @@
$request = Request::create($updateUri, 'POST');
$request->headers->set('x-livewire', '1');
$request->headers->set('referer', "http://localhost/admin/tenants/{$tenant->external_id}/provider-connections/1/edit");
$request->headers->set('referer', "http://localhost/admin/provider-connections/1/edit?managed_environment_id={$tenant->external_id}");
app()->instance('request', $request);
expect(ManagedEnvironment::query()->where('slug', $tenant->external_id)->exists())->toBeTrue();

View File

@ -0,0 +1,51 @@
# Requirements Checklist: Managed Environment Canonical Route Cutover
**Purpose**: Preparation readiness checklist for Spec 297.
**Created**: 2026-05-12
## Spec Readiness
- [x] Problem statement names the active legacy surfaces.
- [x] Goals distinguish product/route/UI/test truth from DB/model rename.
- [x] Non-goals explicitly forbid compatibility layer, DB rename, broad localization, Package Execution, Guided Operations, Microsoft provider refactor, and broad RBAC refactor.
- [x] Functional requirements cover TenantPanelProvider, `/admin/t`, `/admin/tenants`, canonical managed-environment links, intended URLs, required permissions, provider connections, helper rename, copy cleanup, guards, and RBAC.
- [x] Acceptance criteria define route, link, helper, intended URL, provider/permission, RBAC, and validation outcomes.
- [x] Final implementation output contract is included.
## Constitution / Governance
- [x] SPEC-GATE-001 candidate check is filled.
- [x] Proportionality review is filled for the possible link helper and spec-local audit artifact.
- [x] No new persisted truth is introduced.
- [x] Workspace isolation and managed-environment entitlement are explicit.
- [x] RBAC 404/403 semantics are explicit.
- [x] Provider boundary handling distinguishes platform route truth from Microsoft tenant ID terminology.
- [x] Test governance is explicit and bounded.
## Filament / Laravel
- [x] Filament v5 / Livewire v4 compliance is explicitly stated.
- [x] Provider registration location is `apps/platform/bootstrap/providers.php`.
- [x] Global search impact for retired resources is called out.
- [x] Destructive-action confirmation and server authorization expectations are preserved.
- [x] Asset strategy is unchanged unless implementation discovers otherwise.
- [x] Testing plan names Filament/Page/Action/Guard proof surfaces.
## Implementation Readiness
- [x] `spec.md` exists.
- [x] `plan.md` exists.
- [x] `tasks.md` exists.
- [x] `research.md` exists.
- [x] `data-model.md` exists and says no persistence changes are planned.
- [x] `quickstart.md` exists.
- [x] `legacy-surface-audit.md` exists with initial prep findings and refresh requirements.
- [x] Logical route/link contract exists.
- [x] Tasks are ordered, small, and verifiable.
## Open Items For Implementation
- [ ] Refresh `legacy-surface-audit.md` after branch/session setup.
- [ ] Determine whether an existing managed-environment link helper can be extended.
- [ ] Determine the exact TenantResource retirement mechanism.
- [ ] Determine whether any old `/admin/tenants...` URL has safe canonical resolution; default remains 404.

View File

@ -0,0 +1,87 @@
# Contract: Managed Environment Canonical Route Cutover
**Status**: Logical route/link contract
**Runtime persistence**: none
**Compatibility**: no broad compatibility surface
## Canonical Route Families
| Product case | Canonical route family | Notes |
|---|---|---|
| Environment index | `/admin/workspaces/{workspace}/environments` | Workspace context required |
| Environment detail | `/admin/workspaces/{workspace}/environments/{environment}` | Environment must belong to workspace |
| Required permissions / readiness | `/admin/workspaces/{workspace}/environments/{environment}/required-permissions` | Existing repo-real route preferred |
| Diagnostics / provider health | `/admin/workspaces/{workspace}/environments/{environment}/diagnostics` or repo-real equivalent | If no route exists, implementation must document canonical equivalent |
| Access scopes / memberships | `/admin/workspaces/{workspace}/environments/{environment}/access-scopes` or repo-real equivalent | If no route exists, implementation must document canonical equivalent |
| Provider connections | `/admin/provider-connections...` | Tenantless admin resource with neutral scope context |
| Operations index | `/admin/workspaces/{workspace}/operations` | Workspace context required |
| Operation detail | `/admin/workspaces/{workspace}/operations/{run}` | Run entitlement required |
## Retired Route Families
| Route family | Contract |
|---|---|
| `/admin/t` | Absent or 404 |
| `/admin/t/*` | Absent or 404 |
| `/admin/tenants` | Not active product surface; 404 or documented safe canonical resolution only |
| `/admin/tenants/{environment}` | Not active product surface; 404 or documented safe canonical resolution only |
| `/admin/tenants/{environment}/edit` | 404 |
| `/admin/tenants/{environment}/memberships` | 404 or documented safe canonical access-scope resolution only |
| `/admin/tenants/{environment}/required-permissions` | 404 or documented safe canonical required-permissions resolution only |
| `/admin/tenants/{environment}/provider-connections...` | 404 |
| `/admin/operations` | Not final intended URL; normalize to workspace operations if workspace known |
## Link Helper Contract
If `ManagedEnvironmentLinks` is introduced or extended, it must provide or delegate these behaviors:
```php
ManagedEnvironmentLinks::indexUrl($workspace)
ManagedEnvironmentLinks::viewUrl($environment)
ManagedEnvironmentLinks::requiredPermissionsUrl($environment)
ManagedEnvironmentLinks::diagnosticsUrl($environment)
ManagedEnvironmentLinks::accessScopesUrl($environment)
ManagedEnvironmentLinks::operationsUrl($workspace, ?ManagedEnvironment $environment = null)
```
The exact method names may differ if the repo already has a canonical helper. The behavior must remain equivalent.
## Authorization Contract
- Link generation does not grant authorization.
- Page/action owners still enforce workspace membership and managed-environment entitlement.
- Non-member/out-of-scope access returns 404.
- Established member missing capability returns 403.
- Managed-environment scope cannot grant role/capability authority.
## Intended URL Contract
Rejected as final destination:
```text
/admin/t
/admin/t/*
/admin/tenants
/admin/tenants/*
/admin/tenants/*/required-permissions
/admin/tenants/*/provider-connections
external URLs
```
Normalized when safe:
```text
/admin/operations -> /admin/workspaces/{workspace}/operations
```
Fallback when unsafe:
```text
/admin/workspaces/{workspace}/overview
```
or:
```text
/admin/workspaces/{workspace}/environments
```

View File

@ -0,0 +1,61 @@
# Data Model: Managed Environment Canonical Route Cutover & Legacy Tenant Surface Retirement
**Date**: 2026-05-12
**Runtime persistence changes**: none planned.
## Existing Domain Truth
| Concept | Current role in this spec | Persistence change |
|---|---|---|
| Workspace | Primary SaaS/admin context and route scope | None |
| ManagedEnvironment | Secondary managed target context under a workspace | None |
| Tenant / internal tenant model | Existing technical implementation detail where repo-real | None |
| WorkspaceMembership | Role/capability authority | None |
| ManagedEnvironmentMembership | Access-scope / narrowing-only overlay | None |
| ProviderConnection | Tenantless admin resource with neutral scope context | None |
| OperationRun | Existing execution truth linked through workspace operations routes | None |
## Route Truth
| Old route family | New truth | Data implication |
|---|---|---|
| `/admin/t...` | Retired / 404 | None |
| `/admin/tenants...` | Retired as active product surface | None |
| `/admin/tenants/{environment}/required-permissions` | `/admin/workspaces/{workspace}/environments/{environment}/required-permissions` or repo-real equivalent | None |
| `/admin/tenants/{environment}/provider-connections...` | `/admin/provider-connections...` with neutral scope context or canonical environment context | None |
| `/admin/operations` | `/admin/workspaces/{workspace}/operations` when workspace known | None |
## New Structures
No new persisted tables, columns, enum/status families, lifecycle states, or source-of-truth records are introduced.
One bounded runtime helper may be introduced if needed:
```text
App\Support\ManagedEnvironmentLinks
```
Allowed helper responsibility:
- Generate canonical URLs for existing workspace/environment routes.
- Require enough workspace/environment context to avoid ambiguous routing.
- Delegate operations URLs to existing OperationRun link helpers.
Forbidden helper responsibility:
- Acting as a generic route registry.
- Creating compatibility redirects.
- Inferring authorization from route generation.
- Introducing a new persistent route mapping.
## RBAC Semantics
- Workspace membership carries role/capability authority.
- Managed-environment membership narrows access only.
- Non-member or out-of-scope workspace/environment access returns 404.
- Established member missing capability returns 403.
- Legacy `role` data on managed-environment membership, if present, is not authority.
## Compatibility
No compatibility data model exists for this cutover. Historical rows, old route aliases, and old helper aliases are not preserved.

View File

@ -0,0 +1,92 @@
# Legacy Surface Audit: Spec 297
**Prepared**: 2026-05-12
**Status**: Implementation and verification complete on branch `297-managed-environment-canonical-route-cutover`.
## Commands Run During Prep
| Command | Result |
|---|---|
| `git status --short --branch` | Clean on `platform-dev` before Spec Kit branch creation |
| `.specify/scripts/bash/create-new-feature.sh --json --number 297 --short-name managed-environment-canonical-route-cutover ...` | Created branch and spec directory `297-managed-environment-canonical-route-cutover` |
| `.specify/scripts/bash/setup-plan.sh --json` | Created `plan.md` from template |
| `cd apps/platform && ./vendor/bin/sail artisan route:list \| rg "admin/t\|admin/tenants\|provider-connections\|required-permissions\|workspaces/.*/environments\|operations"` | Found active `/admin/tenants` routes plus canonical workspace/environment and workspace operations routes |
| `cd apps/platform && ./vendor/bin/sail artisan route:list --path=admin/tenants` | Shows 4 TenantResource routes |
| `cd apps/platform && ./vendor/bin/sail artisan route:list --path=admin/workspaces` | Shows canonical `/admin/workspaces/{workspace}/environments...` and `/admin/workspaces/{workspace}/operations...` route families |
| `cd apps/platform && ./vendor/bin/sail artisan route:list --path=admin/provider-connections` | Shows tenantless provider-connection resource routes |
| `cd apps/platform && ./vendor/bin/sail artisan route:list --columns=...` | Unsupported option in current Artisan route:list; retried without `--columns` |
| `cd apps/platform && rg "TenantPanelProvider|panel:\\s*'tenant'|panel:\\s*\\\"tenant\\\"|/admin/t/|/admin/tenants|TenantResource::getUrl|TenantDashboard::getUrl|TenantRequiredPermissions::getUrl|setTenantPanelContext|admin\\.operations" . --glob '!vendor' --glob '!node_modules'` | Found active runtime and test references requiring implementation classification |
| `git status --short --branch` | `## 297-managed-environment-canonical-route-cutover`; only untracked spec package present before runtime edits |
| `git diff --stat` | Empty before runtime edits |
| `git log -1 --oneline` | `928d49b5 Merge remote-tracking branch 'origin/platform-dev' into platform-dev` |
| `cd apps/platform && ./vendor/bin/sail artisan route:list \| rg "admin/t\|admin/tenants\|provider-connections\|required-permissions\|workspaces/.*/environments\|operations"` | Confirmed active `/admin/tenants` TenantResource routes, canonical workspace/environment routes, canonical workspace operations routes, tenantless provider-connection routes, and canonical required-permissions route |
| `cd apps/platform && rg "TenantPanelProvider|panel:\\s*'tenant'|panel:\\s*\\\"tenant\\\"|/admin/t/|/admin/tenants|TenantResource::getUrl|TenantDashboard::getUrl|TenantRequiredPermissions::getUrl|setTenantPanelContext|admin\\.operations" . --glob '!vendor' --glob '!node_modules'` | Confirmed retired panel provider file, `TenantResource::getUrl(...)` runtime links, canonical `TenantDashboard::getUrl(...)` references, provider-connection legacy URL parser compatibility, legacy intended-URL acceptance, old Pest helper name, and historical/guard references |
## Findings
| Finding | Runtime/Test/Copy | Active? | Decision | Fixed? |
|---|---|---:|---|---:|
| `apps/platform/app/Providers/Filament/TenantPanelProvider.php` exists | Runtime | No | Deleted; `bootstrap/providers.php` remains Admin/System only | Yes |
| `/admin/tenants` route family exists via TenantResource | Runtime | No | Retired as active product surface; route scan now returns no matches | Yes |
| `/admin/workspaces/{workspace}/environments...` route family exists | Runtime | Yes | Reuse as canonical managed-environment route family | N/A |
| `/admin/workspaces/{workspace}/operations...` route family exists | Runtime | Yes | Reuse as canonical operations route family | N/A |
| `/admin/provider-connections...` tenantless route family exists | Runtime | Yes | Keep as canonical provider-connection route family | N/A |
| `setTenantPanelContext()` exists in `tests/Pest.php` | Test | No | Renamed to `setAdminEnvironmentContext()` with no alias; remaining hits are guard regex literals only | Yes |
| Runtime `TenantResource::getUrl(...)` references remain | Runtime | No | Replaced with `ManagedEnvironmentLinks` or canonical resource query URLs; app/resource scan is clean | Yes |
| Runtime `TenantDashboard::getUrl(...)` references remain | Runtime | No | Replaced in app/runtime surfaces; dashboard page `getUrl()` itself delegates to canonical helper | Yes |
| Runtime `TenantRequiredPermissions::getUrl(...)` references remain | Runtime/Test | No | Replaced with canonical required-permissions route/helper | Yes |
| Tests still assert `/admin/t...` or `/admin/tenants...` in multiple historical and current files | Test | Yes | Rebaselined to retired-route assertions, canonical routes, or historical-only allowed references | Yes |
| Old product copy may remain in touched active surfaces | Copy | Bounded | Touched active surfaces neutralized; remaining hits are outside touched files, guard fixtures, provider/technical names, or follow-up copy scope | Yes |
| `AdminPanelProvider` explicitly registers `TenantResource::class` | Runtime | No | Removed explicit registration; `TenantResource` stays technical/dormant with canonical URL override and global search disabled | Yes |
| `TenantResource` is still used as a table/form/action owner by canonical workspace pages | Runtime | Yes | Kept as technical owner only; URL generation is canonical and resource routes are inactive | Yes |
| `ProviderConnectionResource::extractTenantExternalIdFromUrl()` accepts old `/admin/tenants` and `/admin/t` referers | Runtime compatibility | No | Removed legacy referer parsing; canonical `managed_environment_id` query/context remains | Yes |
| `WorkspaceIntendedUrl` accepts `/admin/t...` and `/admin/tenants...` as safe admin intended URLs | Runtime | No | Rejects retired tenant routes at store/consume time | Yes |
| `WorkspaceRedirectResolver` preserves matching `/admin/t...` and `/admin/tenants...` intended URLs | Runtime | No | Rejects retired tenant routes and normalizes exact legacy `/admin/operations` to workspace-scoped operations | Yes |
| `TenantRequiredPermissions::reRunVerificationUrl()` uses `TenantResource::getUrl('view')` | Runtime | No | Uses canonical managed-environment detail helper | Yes |
| `ManageTenantMemberships` back action uses `TenantResource::getUrl('view')` | Runtime | No | Uses canonical managed-environment detail helper | Yes |
| `ProviderConnectionResource` environment backlink uses `TenantResource::getUrl('view')` | Runtime | No | Uses canonical managed-environment detail helper | Yes |
## Allowed Reference Classes
| Reference | Why allowed | Follow-up |
|---|---|---|
| Historical specs and audit docs | Repository history, not active runtime truth | None |
| Internal model/table/class names such as `Tenant` | DB/model rename is out of scope | Separate DB/model rename spec only if product requires it |
| Microsoft Entra tenant ID / provider-specific tenant copy | External provider terminology | None |
| Guard tests that assert old paths are 404 | Regression protection | Keep focused and explicit |
| `TenantResource` class name and internal helper references | Technical resource/model naming remains out of scope | Keep only if URL output is canonical and resource is not active as `/admin/tenants` |
| Copy scan hits in `lang/en/localization.php`, `FindingExceptionsQueue`, baseline compare, governance-action internals, and assignment relation internals | Outside touched active cutover surfaces, guard fixtures, or technical/historical tenant-domain wording | Future localization/spec only if product asks for broad copy neutralization |
## Implementation Verification Commands
| Command | Result |
|---|---|
| `cd apps/platform && ./vendor/bin/sail artisan route:list \| rg "admin/tenants\|admin/t/"` | No matches; retired routes are inactive |
| `cd apps/platform && ./vendor/bin/sail artisan route:list \| rg "workspaces/.*/environments\|provider-connections\|required-permissions\|operations"` | Shows canonical workspace/environment, provider-connection, required-permissions, and workspace operations route families |
| `cd apps/platform && rg "filament\\.admin\\.resources\\.tenants\|/admin/tenants\|/admin/t/\|TenantResource::getUrl\|TenantDashboard::getUrl\|TenantRequiredPermissions::getUrl\|setTenantPanelContext\|panel:\\s*'tenant'\|panel:\\s*\\\"tenant\\\"" app resources routes --glob '!vendor' --glob '!node_modules'` | No matches |
| `cd apps/platform && rg "setTenantPanelContext\|panel:\\s*'tenant'\|panel:\\s*\\\"tenant\\\"" tests --glob '!vendor' --glob '!node_modules'` | Only `Spec288NoLegacyRouteAndHelperGuardTest` regex guard literals remain |
| `cd apps/platform && rg "Tenant dashboard\|Tenant detail\|Open tenant\|Select tenant\|Tenant scope\|Remove tenant\|Restore tenant\|Tenant memberships" app resources lang tests --glob '!vendor' --glob '!node_modules'` | Remaining hits classified as allowed/out-of-scope above |
| `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/NoLegacyTenantPanelRuntimeTest.php tests/Feature/Guards/NoActiveTenantResourceRoutesTest.php tests/Feature/Guards/ManagedEnvironmentCanonicalRouteContractTest.php tests/Feature/Workspaces/WorkspaceIntendedUrlLegacyRejectionTest.php tests/Feature/ProviderConnections/LegacyRedirectTest.php tests/Feature/ManagedEnvironment/LegacyTenantCoreGuardTest.php tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php tests/Feature/Filament/ManagedEnvironmentAccessScopeManagementTest.php tests/Feature/Rbac/ProviderConnectionWorkspaceFirstPolicyTest.php` | 51 passed, 173 assertions |
| `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards` | 265 passed, 4653 assertions |
| `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Workspaces` | 96 passed, 276 assertions |
| `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections` | 78 passed, 588 assertions |
| `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/RequiredPermissions` | 21 passed, 82 assertions |
| `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament` | 765 passed, 5 skipped, 4975 assertions |
| `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Spec281ProviderConnectionScopeSmokeTest.php tests/Browser/Spec285WorkspaceRbacEnvironmentAccessSmokeTest.php` | 2 passed, 29 assertions |
| `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` | Pass |
| `git diff --check` | Pass |
## Implementation Refresh Requirements
Before runtime edits, refresh this audit with:
```bash
git status --short --branch
git diff --stat
cd apps/platform
./vendor/bin/sail artisan route:list | rg "admin/t|admin/tenants|provider-connections|required-permissions|workspaces/.*/environments|operations"
rg "TenantPanelProvider|panel:\s*'tenant'|panel:\s*\"tenant\"|/admin/t/|/admin/tenants|TenantResource::getUrl|TenantDashboard::getUrl|TenantRequiredPermissions::getUrl|setTenantPanelContext|admin\.operations" . --glob '!vendor' --glob '!node_modules'
```
Update the `Fixed?` column as implementation progresses.

View File

@ -0,0 +1,314 @@
# Implementation Plan: Managed Environment Canonical Route Cutover & Legacy Tenant Surface Retirement
**Branch**: `297-managed-environment-canonical-route-cutover` | **Date**: 2026-05-12 | **Spec**: [spec.md](/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/297-managed-environment-canonical-route-cutover/spec.md)
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/297-managed-environment-canonical-route-cutover/spec.md`
## Summary
Spec 297 completes the hard product cutover from legacy tenant surfaces to canonical workspace-managed-environment routes. The implementation retires active `/admin/tenants...` product routes, keeps `/admin/t...` dead, removes or permanently neutralizes `TenantPanelProvider`, replaces runtime link generation with one canonical managed-environment link contract, rejects legacy intended URLs, renames the old tenant-panel test helper with no alias, and adds guard tests that prevent backsliding.
This plan is preparation only. It does not implement application code.
## Technical Context
**Language/Version**: PHP 8.4.15
**Primary Dependencies**: Laravel 12.52.0, Filament 5.2.1, Livewire 4.1.4, Pest 4.3.1, Laravel Sail 1.52.0
**Storage**: PostgreSQL through Laravel/Sail for tests; no new storage planned
**Testing**: Pest via `./vendor/bin/sail artisan test --compact`; Browser tests only if visible navigation is touched
**Validation Lanes**: targeted Feature guards, Workspaces, ProviderConnections, RequiredPermissions, Filament, Spec 288 guard pack, Spec 293 cutover lane, optional Browser smoke
**Target Platform**: Laravel Sail local runtime and Gitea-compatible CI runners
**Project Type**: Laravel web application under `apps/platform`
**Performance Goals**: Route/link guards stay deterministic and focused; no new heavy or browser defaults
**Constraints**: no `/admin/t...` restoration, no `/admin/tenants...` compatibility surface, no TenantPanelProvider reactivation, no old helper alias, no DB/model rename, no broad localization or RBAC refactor
**Scale/Scope**: Route, link, intended URL, Filament resource registration, and test-helper cutover only
## Initial Repo Baseline
Preparation audit on 2026-05-12 found:
- Current branch before Spec Kit execution: `platform-dev`; Spec Kit switched to `297-managed-environment-canonical-route-cutover`.
- Working tree was clean before creating the spec package.
- `TenantPanelProvider` still exists at `apps/platform/app/Providers/Filament/TenantPanelProvider.php`.
- `apps/platform/bootstrap/providers.php` is already guarded by existing tests against registering `TenantPanelProvider`.
- `route:list --path=admin/tenants` currently shows four active Filament tenant resource routes: index, view, edit, memberships.
- `route:list --path=admin/workspaces` currently shows canonical environment routes under `/admin/workspaces/{workspace}/environments...` and workspace operations under `/admin/workspaces/{workspace}/operations...`.
- `rg` currently finds many active tests/runtime references to `TenantResource::getUrl(...)`, `TenantDashboard::getUrl(...)`, `TenantRequiredPermissions::getUrl(...)`, `/admin/t/...`, `/admin/tenants...`, and `setTenantPanelContext()`.
- The attempted `route:list --columns=...` option is unsupported in this Laravel version; retry without `--columns`.
The implementation must refresh `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/297-managed-environment-canonical-route-cutover/legacy-surface-audit.md` before editing runtime code.
## UI / Surface Guardrail Plan
- **Guardrail scope**: changed route/link/navigation contract for existing surfaces; no new product workflow.
- **Native vs custom classification summary**: Native Filament/resources/pages and shared link helpers. No custom Blade/Tailwind surface is planned.
- **Shared-family relevance**: navigation entry points, action links, notifications/toast actions, OperationRun links, provider/permission links, test context helpers.
- **State layers in scope**: route registration, URL helper, intended URL/session, Filament panel/resource registration, test panel/workspace/environment context.
- **Audience modes in scope**: operator-MSP and support-platform only through existing surfaces.
- **Decision/diagnostic/raw hierarchy plan**: existing environment/readiness/operations surfaces keep their hierarchy; the cutover only changes canonical route truth.
- **Raw/support gating plan**: unchanged; raw provider detail remains where existing policies allow it.
- **One-primary-action / duplicate-truth control**: do not add parallel actions to preserve legacy paths. Replace old destinations with canonical ones.
- **Handling modes by drift class or surface**: retire, replace, or document allowed technical reference. Unsafe or ambiguous legacy URL resolution falls back or 404s.
- **Repository-signal treatment**: review-mandatory for route-list output, source-scan allowlists, intended URL fallback, helper rename, and any remaining `Tenant` product copy in touched files.
- **Special surface test profiles**: `route-contract`, `standard-native-filament`, `global-context-shell`, `browser-smoke` if visible navigation changes.
- **Required tests or manual smoke**: targeted Pest guard tests first; Browser only when implementation touches visible navigation flows.
- **Exception path and spread control**: Allowed remaining technical `Tenant` references must be listed in `legacy-surface-audit.md` or final summary.
- **Active feature PR close-out entry**: Guardrail / Route Cutover / Smoke Coverage if browser proof was run.
## Shared Pattern & System Fit
- **Cross-cutting feature marker**: yes.
- **Systems touched**: Filament panel providers, TenantResource/TenantDashboard/TenantRequiredPermissions routes or links, WorkspaceRedirectResolver/intended URL support, OperationRunLinks, WorkspaceOverviewBuilder, provider/required-permissions link emitters, `tests/Pest.php`, guard tests, browser tests when route navigation is visible.
- **Shared abstractions reused**: existing workspace/environment routes, `WorkspaceContext`, `OperationRunLinks`, existing admin panel context helper, existing Spec 288/293 guard style.
- **New abstraction introduced? why?**: Only a bounded `ManagedEnvironmentLinks` helper if no existing repo-real helper owns canonical environment URLs. It exists to remove scattered route-name literals and prevent legacy URL generation.
- **Why the existing abstraction was sufficient or insufficient**: The canonical routes exist, but runtime link generation remains scattered and some helpers still emit old destinations.
- **Bounded deviation / spread control**: Technical `Tenant` model names and Microsoft tenant ID copy remain only where non-product or provider-specific.
## OperationRun UX Impact
- **Touches OperationRun start/completion/link UX?**: yes, route/link safety only.
- **Central contract reused**: `OperationRunLinks` and `admin.operations.index` / `admin.operations.view` with explicit workspace context.
- **Delegated UX behaviors**: preserve existing `View operation` / `Open operation` behavior.
- **Surface-owned behavior kept local**: environment/provider surfaces own only initiation inputs and page-local copy.
- **Queued DB-notification policy**: N/A.
- **Terminal notification path**: unchanged.
- **Exception path**: none.
## Provider Boundary & Portability Fit
- **Shared provider/platform boundary touched?**: yes.
- **Provider-owned seams**: Microsoft Entra tenant ID copy, Graph permission names, provider diagnostics payloads.
- **Platform-core seams**: route family, link generation, workspace/environment context, operations links, RBAC and access-scope semantics.
- **Neutral platform terms / contracts preserved**: workspace, managed environment, provider connection, target scope, required permissions, diagnostics, access scope, operation.
- **Retained provider-specific semantics and why**: Microsoft-specific identity/permission terms remain only when they identify external provider truth.
- **Bounded extraction or follow-up path**: No multi-provider framework. Follow-up only for DB/model rename or broader provider-boundary cleanup beyond route cutover.
## Constitution Check
*GATE: Must pass before runtime implementation and re-check before close-out.*
- Inventory-first: no new inventory or snapshot truth.
- Read/write separation: no new write workflow. Existing destructive actions touched by route/resource work keep confirmation, authorization, and audit behavior.
- Single Graph contract path: no new Graph calls.
- Deterministic capabilities: capability-first RBAC remains authoritative; no role-string checks.
- Proportionality / no premature abstraction: use existing helper if possible; any new link helper is bounded to current route generation.
- No new persisted truth: no migrations, tables, compatibility shims, or dual-read paths.
- Workspace isolation: all environment and operations links carry explicit workspace context or validate current workspace context.
- Tenant isolation: tenant-owned records exposed through canonical environment routes still enforce managed-environment entitlement.
- RBAC-UX: non-member/out-of-scope remains 404; established member missing capability remains 403; UI hiding is not security.
- Provider boundary: tenant-first platform route language is retired; provider-specific tenant terms remain only provider-owned.
- Test governance: guard tests are allowed and focused; no full-suite repair or new lane framework.
- Filament-native UI: Filament remains v5 on Livewire v4, no v3/v4 API usage, no ad-hoc UI redesign.
- Deployment/ops: no asset registration is planned. If assets are unexpectedly registered, deploy notes include `cd apps/platform && php artisan filament:assets`.
## Filament v5 Output Contract
- **Livewire compliance**: Filament v5 targets Livewire v4.0+; current app has Livewire 4.1.4.
- **Provider registration location**: Laravel 12 provider registration must remain in `apps/platform/bootstrap/providers.php`. `TenantPanelProvider` must not be registered there.
- **Globally searchable resources**: If `TenantResource` is retired or moved out of active discovery, global search must be disabled for it or it must no longer register. Any managed-environment resource that remains globally searchable must have Edit or View pages.
- **Destructive actions**: This spec does not add destructive actions. Any touched existing destructive action must still execute through `->action(...)`, use `->requiresConfirmation()`, and enforce server-side authorization.
- **Asset strategy**: No new Filament assets are planned. If implementation unexpectedly registers assets, deployment must include `cd apps/platform && php artisan filament:assets`.
- **Testing plan**: Pages/actions/helpers changed by the cutover are covered with Pest/Filament tests; guard tests cover route resurrection, helper resurrection, intended URL rejection, legacy URL generation, and managed-environment canonical links.
## Test Governance Check
- **Test purpose / classification by changed surface**: Feature guard tests for route/link/intended URL contracts; Unit tests for pure helper logic; Feature/Filament tests for pages/resources; Browser only for visible navigation smoke.
- **Affected validation lanes**: Feature/Guards, Feature/Workspaces, Feature/ProviderConnections, Feature/RequiredPermissions, Feature/Filament, Spec 288 guard pack, Spec 293 cutover lane, optional Browser lane.
- **Why this lane mix is the narrowest sufficient proof**: The risk is route/link resurrection, not complete product behavior. Focused guards plus existing domain test directories prove the changed contracts.
- **Narrowest proving commands**:
```bash
cd apps/platform
./vendor/bin/sail artisan test --compact tests/Feature/Guards/NoLegacyTenantPanelRuntimeTest.php
./vendor/bin/sail artisan test --compact tests/Feature/Guards/NoActiveTenantResourceRoutesTest.php
./vendor/bin/sail artisan test --compact tests/Feature/Guards/ManagedEnvironmentCanonicalRouteContractTest.php
./vendor/bin/sail artisan test --compact tests/Feature/Workspaces/WorkspaceIntendedUrlLegacyRejectionTest.php
```
- **Fixture / helper / factory / seed / context cost risks**: The replacement for `setTenantPanelContext()` must not make provider setup, browser fixtures, or broad workspace setup implicit.
- **Expensive defaults or shared helper growth introduced?**: none planned.
- **Heavy-family additions, promotions, or visibility changes**: none planned.
- **Surface-class relief / special coverage rule**: Standard-native Filament coverage unless route/navigation changes are visible in browser flows.
- **Closing validation and reviewer handoff**: run focused guards, affected domain directories, Spec 288 pack, Spec 293 pack, and Pint dirty.
- **Budget / baseline / trend follow-up**: document any material guard runtime increase in the implementation close-out.
- **Review-stop questions**: Does `/admin/tenants...` still return a product page? Does a helper still emit legacy URLs? Does intended URL handling preserve legacy paths? Did a test helper alias keep the old name? Did RBAC weaken?
- **Escalation path**: document-in-feature for allowed technical references; follow-up-spec for structural rename/localization issues.
- **Active feature PR close-out entry**: Guardrail / Route Cutover.
- **Why no dedicated follow-up spec is needed**: The route cutover is bounded. DB/model rename and broader copy/localization are explicit non-goals and can become follow-ups only if product needs them.
## Project Structure
### Documentation (this feature)
```text
specs/297-managed-environment-canonical-route-cutover/
├── spec.md
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── legacy-surface-audit.md
├── tasks.md
├── contracts/
│ └── managed-environment-canonical-route-contract.md
└── checklists/
└── requirements.md
```
### Source Code (repository root)
Expected touched surfaces during implementation:
```text
apps/platform/app/
├── Providers/Filament/
├── Filament/
│ ├── Pages/
│ └── Resources/
├── Support/
│ ├── Workspaces/
│ ├── OperationRunLinks.php
│ └── ManagedEnvironmentLinks.php (only if needed)
└── Http/
apps/platform/bootstrap/providers.php
apps/platform/routes/web.php
apps/platform/tests/
├── Pest.php
├── Feature/
├── Unit/
└── Browser/
```
**Structure Decision**: Use existing Laravel/Filament app structure and existing route/helper/test conventions. Do not create a new base application folder or dependency.
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|---|---|---|
| Cross-cutting route/link/test-helper cleanup | Legacy route truth exists in multiple owners and cannot be retired safely in one local page | Local cleanup would leave intended URLs, tests, or link builders able to resurrect old paths |
| Bounded canonical link helper if needed | Runtime link generation must have one owner to make guard tests meaningful | Scattered route-name literals would recreate the drift this spec removes |
| New guard tests | Regression risk is route/link resurrection after a cutover | Manual review and ad hoc source scans are not durable enough |
## Phase 0: Safety Gate
1. Run:
```bash
git status --short --branch
git diff --stat
git log -1 --oneline
```
2. Confirm the implementation branch is `297-managed-environment-canonical-route-cutover` or a session branch created from it.
3. Stop if unrelated uncommitted changes exist.
4. Read:
```text
.specify/memory/constitution.md
specs/287-cutover-prerequisite-completion/
specs/288-quality-gates-no-legacy-enforcement/
specs/293-post-cutover-suite-stabilization/
specs/296-full-suite-green-signal-restoration/
```
## Phase 1: Baseline Audit
Refresh `legacy-surface-audit.md` before code edits:
```bash
git status --short --branch
git diff --stat
cd apps/platform
./vendor/bin/sail artisan route:list | rg "admin/t|admin/tenants|provider-connections|required-permissions|workspaces/.*/environments|operations"
rg "TenantPanelProvider|panel:\s*'tenant'|panel:\s*\"tenant\"|/admin/t/|/admin/tenants|TenantResource::getUrl|TenantDashboard::getUrl|TenantRequiredPermissions::getUrl|setTenantPanelContext|admin\.operations" . --glob '!vendor' --glob '!node_modules'
```
Classify each finding as runtime, test, copy, historical, provider-specific, or allowed technical reference.
## Phase 2: Remove Dormant TenantPanelProvider
- Delete `apps/platform/app/Providers/Filament/TenantPanelProvider.php` if no true runtime dependency exists.
- Ensure `apps/platform/bootstrap/providers.php` does not reference it.
- Replace tests that inspect the file with provider-registration and route-list guards.
- Add/extend `NoLegacyTenantPanelRuntimeTest`.
## Phase 3: Establish Canonical Managed Environment Link Contract
- Locate repo-real managed-environment route helpers first.
- Create or extend `ManagedEnvironmentLinks` only if needed.
- Cover index/detail/required-permissions/diagnostics/access-scopes/operations.
- Replace direct legacy link generation in runtime surfaces.
- Add contract tests that assert no generated URL contains `/admin/tenants` or `/admin/t/`.
## Phase 4: Retire `/admin/tenants...`
- Remove active TenantResource route registration or move it out of active discovery.
- If a temporary redirect is unavoidable, require unique workspace/environment resolution and document the exception. Default is 404.
- Update global search for any retired resource.
- Add/extend `NoActiveTenantResourceRoutesTest`.
## Phase 5: Intended URL Legacy Rejection
- Update `WorkspaceRedirectResolver`, `WorkspaceIntendedUrl`, or repo-real intended URL owners.
- Reject `/admin/t...` and `/admin/tenants...` as final destinations.
- Normalize legacy `/admin/operations` to workspace operations when workspace is known.
- Fall back to workspace home or environment index when unsafe.
- Keep external URLs blocked.
## Phase 6: Required Permissions And Provider Connections
- Replace old required-permissions and provider-connection tenant URLs.
- Ensure tenantless provider-connection resource remains canonical.
- Ensure required-permissions uses the workspace/environment route.
- Add/extend legacy route tests proving old URLs do not return 200.
## Phase 7: Test Helper Rename
- Rename `setTenantPanelContext()` to the chosen canonical helper, for example `setAdminEnvironmentContext()`.
- Update every test usage.
- Do not keep an alias under the old name.
- Add guard coverage that fails on old helper resurrection.
## Phase 8: Copy Cleanup In Touched Active Surfaces
- Replace tenant-first product copy only in files touched by this cutover.
- Keep Microsoft/provider-specific tenant ID copy where correct.
- List remaining old references in `legacy-surface-audit.md`.
## Phase 9: Regression Proof Pack
Run focused proof:
```bash
cd apps/platform
./vendor/bin/sail artisan test --compact \
tests/Feature/Guards/NoLegacyTenantPanelRuntimeTest.php \
tests/Feature/Guards/NoActiveTenantResourceRoutesTest.php \
tests/Feature/Guards/ManagedEnvironmentCanonicalRouteContractTest.php \
tests/Feature/Workspaces/WorkspaceIntendedUrlLegacyRejectionTest.php \
tests/Feature/ProviderConnections/LegacyRedirectTest.php \
tests/Feature/ManagedEnvironment/LegacyTenantCoreGuardTest.php \
tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php \
tests/Feature/Filament/ManagedEnvironmentAccessScopeManagementTest.php \
tests/Feature/Rbac/ProviderConnectionWorkspaceFirstPolicyTest.php
```
Run the Spec 288 guard pack and Spec 293 cutover lane listed in the spec. Run browser smoke only if visible navigation flows were touched.
## Phase 10: Broad Validation
Run at least:
```bash
cd apps/platform
./vendor/bin/sail artisan test --compact tests/Feature/Guards
./vendor/bin/sail artisan test --compact tests/Feature/Workspaces
./vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections
./vendor/bin/sail artisan test --compact tests/Feature/RequiredPermissions
./vendor/bin/sail artisan test --compact tests/Feature/Filament
./vendor/bin/sail bin pint --dirty --format agent
git diff --check
```
Raw full suite is optional unless requested; if run, record the exact result.

View File

@ -0,0 +1,95 @@
# Quickstart: Managed Environment Canonical Route Cutover & Legacy Tenant Surface Retirement
Use this as the implementation command checklist. Refresh the baseline before editing code.
## 1. Safety
```bash
git status --short --branch
git diff --stat
git log -1 --oneline
```
## 2. Baseline Audit
```bash
cd apps/platform
./vendor/bin/sail artisan route:list | rg "admin/t|admin/tenants|provider-connections|required-permissions|workspaces/.*/environments|operations"
rg "TenantPanelProvider|panel:\s*'tenant'|panel:\s*\"tenant\"|/admin/t/|/admin/tenants|TenantResource::getUrl|TenantDashboard::getUrl|TenantRequiredPermissions::getUrl|setTenantPanelContext|admin\.operations" . --glob '!vendor' --glob '!node_modules'
```
Update:
```text
specs/297-managed-environment-canonical-route-cutover/legacy-surface-audit.md
```
## 3. Focused Proof
```bash
cd apps/platform
./vendor/bin/sail artisan test --compact \
tests/Feature/Guards/NoLegacyTenantPanelRuntimeTest.php \
tests/Feature/Guards/NoActiveTenantResourceRoutesTest.php \
tests/Feature/Guards/ManagedEnvironmentCanonicalRouteContractTest.php \
tests/Feature/Workspaces/WorkspaceIntendedUrlLegacyRejectionTest.php \
tests/Feature/ProviderConnections/LegacyRedirectTest.php \
tests/Feature/ManagedEnvironment/LegacyTenantCoreGuardTest.php \
tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php \
tests/Feature/Filament/ManagedEnvironmentAccessScopeManagementTest.php \
tests/Feature/Rbac/ProviderConnectionWorkspaceFirstPolicyTest.php
```
## 4. Existing Guard Pack
```bash
cd apps/platform
./vendor/bin/sail artisan test --compact \
tests/Feature/Guards/Spec288NoLegacyRouteAndHelperGuardTest.php \
tests/Feature/Guards/Spec288ProviderCoreAndRoleAuthorityGuardTest.php \
tests/Feature/Guards/AdminWorkspaceRoutesGuardTest.php \
tests/Feature/Guards/ProviderBoundaryPlatformCoreGuardTest.php \
tests/Feature/Guards/BrowserLaneIsolationTest.php \
tests/Feature/Guards/CiLaneFailureClassificationContractTest.php \
tests/Feature/Guards/CiHeavyBrowserWorkflowContractTest.php
```
## 5. Broad Focused Validation
```bash
cd apps/platform
./vendor/bin/sail artisan test --compact tests/Feature/Guards
./vendor/bin/sail artisan test --compact tests/Feature/Workspaces
./vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections
./vendor/bin/sail artisan test --compact tests/Feature/RequiredPermissions
./vendor/bin/sail artisan test --compact tests/Feature/Filament
./vendor/bin/sail bin pint --dirty --format agent
git diff --check
```
## 6. Browser Smoke
Run only if visible navigation/browser flows are touched:
```bash
cd apps/platform
./vendor/bin/sail artisan test --compact \
tests/Browser/Spec281ProviderConnectionScopeSmokeTest.php \
tests/Browser/Spec285WorkspaceRbacEnvironmentAccessSmokeTest.php
```
## 7. Final Decision
Close with exactly one:
```text
Managed Environment canonical cutover complete; legacy tenant surfaces retired.
```
```text
Blocked by true runtime dependency on legacy tenant surface.
```
```text
Incomplete; active legacy tenant routes remain.
```

View File

@ -0,0 +1,78 @@
# Research: Managed Environment Canonical Route Cutover & Legacy Tenant Surface Retirement
**Date**: 2026-05-12
**Scope**: Preparation research only. No application implementation performed.
## Decisions
### Decision 1: Default legacy behavior is 404, not compatibility redirect
**Decision**: `/admin/t...` and unsafe `/admin/tenants...` requests should be absent or return 404. A redirect is allowed only when the implementation can prove a unique, authorized workspace + managed-environment canonical URL.
**Rationale**: The repo is pre-production under LEAN-001. Compatibility shims would preserve old product truth and contradict the explicit user instruction.
**Alternatives rejected**:
- Keep `/admin/tenants...` as hidden compatibility routes: rejected because hidden-but-routable surfaces still become test/runtime truth.
- Redirect all old tenant URLs broadly: rejected because workspace/environment resolution can be ambiguous and unsafe.
### Decision 2: Canonical link generation gets one owner
**Decision**: Use an existing repo-real managed-environment route helper if present. If none exists, add a bounded `ManagedEnvironmentLinks` helper that maps only current workspace/environment objects to current named routes.
**Rationale**: Route names are currently spread through Filament resources/pages/tests. Centralizing the canonical contract makes guard tests meaningful without creating a routing framework.
**Alternatives rejected**:
- Scatter `route(...)` calls in each runtime surface: rejected because it recreates drift.
- Add a generic route registry/framework: rejected as disproportionate for current release truth.
### Decision 3: TenantPanelProvider should be deleted if no true dependency exists
**Decision**: Delete `TenantPanelProvider.php` when implementation confirms no runtime registration/dependency. If deletion is blocked, document the true dependency and guard against any route/provider activation.
**Rationale**: Dormant runtime-ready panel code is a resurrection risk after the cutover.
**Alternatives rejected**:
- Leave the file as dormant code: rejected because it keeps tenant panel reactivation cheap and ambiguous.
- Keep tests that require the file to exist: rejected because tests should protect route/provider behavior, not dead code.
### Decision 4: Test helper vocabulary must change without alias
**Decision**: Replace `setTenantPanelContext()` with a canonical admin/workspace/environment helper such as `setAdminEnvironmentContext()` or `setManagedEnvironmentContext()` and keep no alias.
**Rationale**: The helper name is product semantics. Keeping an alias preserves the retired panel as current test truth.
**Alternatives rejected**:
- Keep alias for migration ease: rejected under LEAN-001 and explicit user instruction.
- Rename only new tests: rejected because old tests would still encode the wrong product model.
### Decision 5: Technical `Tenant` references may remain only as implementation detail
**Decision**: Internal model/table/class names may remain where DB/model rename is out of scope. Product-facing routes, copy, and link truth must move to workspace/managed-environment terminology.
**Rationale**: The spec explicitly forbids a DB rename migration and targets product, routing, UI, test, and link truth.
**Alternatives rejected**:
- Rename all Tenant classes/tables now: rejected as out of scope and high blast radius.
- Leave user-facing tenant-first copy in touched files: rejected because it contradicts the cutover.
## Repo Findings From Preparation Audit
| Finding | Evidence | Decision |
|---|---|---|
| Active `/admin/tenants` routes remain | `route:list --path=admin/tenants` shows TenantResource index/view/edit/memberships | Retire as active product surface |
| Canonical environment routes exist | `route:list --path=admin/workspaces` shows `/admin/workspaces/{workspace}/environments...` and workspace operations | Reuse as canonical target |
| TenantPanelProvider exists | `apps/platform/app/Providers/Filament/TenantPanelProvider.php` | Delete if no true dependency |
| Old helper remains | `apps/platform/tests/Pest.php` defines `setTenantPanelContext()` | Rename with no alias |
| Runtime/test legacy link calls remain | `rg` finds TenantResource/TenantDashboard/TenantRequiredPermissions URL generation | Replace or document allowed technical references |
## Open Research Items For Implementation
- Exact repo-real helper owner, if any, for managed-environment links.
- Exact route names for environment index/detail and access-scope route after TenantResource retirement.
- Whether TenantResource can be removed from discovery directly or must be moved/neutralized to preserve internal tests during the cutover.
- Whether any old `/admin/tenants...` request can be safely resolved to a canonical workspace/environment URL. Default remains 404.

View File

@ -0,0 +1,378 @@
# Feature Specification: Managed Environment Canonical Route Cutover & Legacy Tenant Surface Retirement
**Feature Branch**: `297-managed-environment-canonical-route-cutover`
**Created**: 2026-05-12
**Status**: Ready
**Input**: User-provided Spec 297 prompt: retire active `/admin/tenants...` and remaining legacy tenant surfaces, remove dormant TenantPanel runtime readiness, and make Workspace -> Managed Environments the only active product route truth.
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
- **Problem**: The workspace-first managed-environment cutover is not complete while active `/admin/tenants...` product routes, `TenantPanelProvider`, tenant-panel test helpers, legacy intended URLs, and old TenantResource/TenantDashboard link generation still exist as runtime or test truth.
- **Today's failure**: Operators, tests, and contributors can still follow or assert `/admin/tenants...`, `TenantResource::getUrl(...)`, `TenantDashboard::getUrl(...)`, `TenantRequiredPermissions::getUrl(...)`, `setTenantPanelContext()`, or dormant tenant-panel provider code even though Specs 279-296 moved the product direction to workspace-first and managed-environment-first routing.
- **User-visible improvement**: Admin operators and maintainers get one product truth: Workspace -> Managed Environments -> Environment Detail / Readiness / Permissions / Diagnostics / Operations. Legacy tenant routes stop competing with canonical routes and cannot return through intended URL handling or stale test fixtures.
- **Smallest enterprise-capable version**: Remove the dormant tenant panel provider if unused, retire active `/admin/tenants...` product routes, centralize canonical managed-environment link generation, reject or safely normalize legacy intended URLs, rename the tenant-panel test helper without an alias, and add guard tests proving legacy surfaces stay retired.
- **Explicit non-goals**: No database rename from `Tenant` to `ManagedEnvironment`, no new product workflow, no compatibility layer for old URLs, no broad localization sweep, no Package Execution, no Guided Operations, no Microsoft provider refactor, no large RBAC refactor, and no new persisted data model.
- **Permanent complexity imported**: One small canonical link helper or extension of an existing helper, targeted guard tests, and one spec-local legacy surface audit. No new table, enum, status family, provider framework, or cross-domain UI framework is introduced.
- **Why now**: Specs 287, 288, and 293 completed prerequisites and guard/stabilization work but repo truth still shows active legacy route and helper surfaces. Delaying this cutover lets the retired tenant product language keep spreading into future specs and tests.
- **Why not local**: The drift is cross-cutting across routes, Filament resource registration, link builders, intended URL resolution, tests, helpers, and operator copy. A local page fix would leave multiple active sources of truth.
- **Approval class**: Cleanup
- **Red flags triggered**: Cross-cutting route/test-helper cleanup and active route retirement. Defense: the scope is bounded to retiring known legacy surfaces and replacing them with existing canonical workspace/environment routes; it adds guard tests, not a new product framework.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
- **Decision**: approve
## Spec Scope Fields *(mandatory)*
- **Scope**: canonical-view
- **Primary Routes**:
- Retired: `/admin/t`, `/admin/t/...`
- Retired active product surface: `/admin/tenants`, `/admin/tenants/{environment}`, `/admin/tenants/{environment}/edit`, `/admin/tenants/{environment}/memberships`, `/admin/tenants/{environment}/required-permissions`, `/admin/tenants/{environment}/provider-connections...`
- Canonical: `/admin/workspaces/{workspace}/environments`
- Canonical: `/admin/workspaces/{workspace}/environments/{environment}`
- Canonical: `/admin/workspaces/{workspace}/environments/{environment}/required-permissions`
- Canonical: `/admin/workspaces/{workspace}/environments/{environment}/diagnostics` if repo-real or equivalent diagnostics/readiness route exists
- Canonical: `/admin/workspaces/{workspace}/environments/{environment}/access-scopes` if repo-real or equivalent membership/access-scope route exists
- Canonical operations: `/admin/workspaces/{workspace}/operations` and `/admin/workspaces/{workspace}/operations/{run}`
- **Data Ownership**:
- No new persisted entity, table, enum, state, or status family.
- Existing `ManagedEnvironment` / internal `Tenant` model reality may remain technical implementation truth where repo-real.
- Workspace membership remains the role/capability authority.
- Managed environment membership remains narrowing/access-scope only and must not regain role authority.
- **RBAC**:
- Workspace membership is required before revealing workspace or environment surfaces.
- Managed-environment access scope may narrow access but cannot grant role authority.
- Non-member or out-of-scope actors receive 404 (deny-as-not-found).
- Established members missing capability receive 403.
- UI visibility is never authorization; policies/gates remain the server-side truth.
For canonical-view specs:
- **Default filter behavior when tenant-context is active**: Any legacy or stale environment hint must be resolved into explicit workspace + managed-environment context before links or pages are shown. If safe resolution is not possible, fall back to workspace home or environment index.
- **Explicit entitlement checks preventing cross-tenant leakage**: Any canonical environment route, operation link, relation/action link, or intended URL normalization must verify workspace entitlement and managed-environment entitlement before revealing tenant-owned records.
## Cross-Cutting / Shared Pattern Reuse *(mandatory)*
- **Cross-cutting feature?**: yes
- **Interaction class(es)**: route/link generation, navigation entry points, intended URL handling, Filament resource URLs, action links, test helper context, guard tests, provider/required-permissions launch points, workspace operations links
- **Systems touched**:
- `apps/platform/app/Providers/Filament/TenantPanelProvider.php`
- `apps/platform/bootstrap/providers.php`
- `apps/platform/app/Filament/Resources/TenantResource.php`
- `apps/platform/app/Filament/Pages/TenantDashboard.php`
- `apps/platform/app/Filament/Pages/TenantRequiredPermissions.php`
- `apps/platform/app/Support/Workspaces/WorkspaceRedirectResolver.php`
- `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php`
- `apps/platform/app/Support/OperationRunLinks.php`
- existing workspace/environment route helpers or new bounded `ManagedEnvironmentLinks`
- `apps/platform/tests/Pest.php`
- targeted Feature, Browser, Unit, and Guard tests that still assert old route truth
- **Existing pattern(s) to extend**: current workspace-first admin routes, `OperationRunLinks`, `WorkspaceContext`, existing admin panel context helpers, existing Spec 288/293 guard style, and any repo-real managed-environment route helper.
- **Shared contract / presenter / builder / renderer to reuse**: reuse existing route helpers where they already generate canonical workspace/environment URLs. Introduce `ManagedEnvironmentLinks` only if no repo-real helper owns index/detail/permissions/diagnostics/access-scope URLs.
- **Why the existing shared path is sufficient or insufficient**: The canonical route family already exists for workspace/environment pages and operations. What is insufficient is that old link builders, tests, and intended URL handling still accept or emit retired route families.
- **Allowed deviation and why**: Technical class/model names containing `Tenant` may remain where a database/model rename is out of scope. Provider-specific Microsoft tenant ID copy may remain when it refers to Microsoft Entra tenant identity, not TenantPilot route/product identity.
- **Consistency impact**: Runtime links, Filament resource pages, notifications/toast actions, test helpers, browser tests, and intended URL handling must all converge on the same workspace-first managed-environment routes.
- **Review focus**: Reviewers must verify that no compatibility surface is preserved for `/admin/tenants...`, no `/admin/t...` route returns as active product truth, `TenantPanelProvider` cannot be re-enabled by registration, and no old helper alias remains.
## OperationRun UX Impact *(mandatory)*
- **Touches OperationRun start/completion/link UX?**: yes, only for canonical operations links and route generation.
- **Shared OperationRun UX contract/layer reused**: `OperationRunLinks` and canonical `admin.operations.index` / `admin.operations.view` routes with explicit workspace context.
- **Delegated start/completion UX behaviors**: Existing `Open operation` / `View operation` behavior remains owned by the shared OperationRun UX path.
- **Local surface-owned behavior that remains**: Environment and provider surfaces may still own local launch inputs and local copy, but not workspace-safe operation URL construction.
- **Queued DB-notification policy**: `N/A` - unchanged.
- **Terminal notification path**: existing central lifecycle mechanism, unchanged.
- **Exception required?**: none.
## Provider Boundary / Platform Core Check *(mandatory)*
- **Shared provider/platform boundary touched?**: yes
- **Boundary classification**: mixed
- **Seams affected**: provider-connection route ownership, required-permissions route ownership, managed-environment route naming, operations URL generation, provider diagnostics/readiness links, and provider-owned Microsoft tenant ID copy.
- **Neutral platform terms preserved or introduced**: `workspace`, `managed environment`, `environment`, `provider connection`, `required permissions`, `diagnostics`, `access scope`, `operation`.
- **Provider-specific semantics retained and why**: Microsoft Entra tenant ID and Graph permission terminology may remain where the provider itself is the subject.
- **Why this does not deepen provider coupling accidentally**: The spec removes tenant-first route/product truth from platform core while allowing provider-owned Microsoft terminology only where it describes external identity or permission data.
- **Follow-up path**: document-in-feature for contained technical `Tenant` names that remain because DB/model rename is out of scope; follow-up-spec only if implementation discovers a structural provider/platform boundary gap outside route cutover.
## UI / Surface Guardrail Impact *(mandatory)*
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|---|---|---|---|---|---|---|
| Retire `/admin/tenants...` as active product surface | yes | Native Filament routes/resources, no custom UI planned | navigation, resource links, breadcrumbs, action links | route, page, URL, test context | no | remove or neutralize old surface; do not redesign it |
| Canonical managed-environment links | yes | Native Filament routes and shared route helper | navigation, action links, notifications, operations links | URL/helper only | no | centralize link generation without new UI framework |
| Intended URL rejection/normalization | no direct new UI | N/A | redirect workflow, workspace chooser | session/intended URL | no | behavior-only route safety |
| Test helper rename | no | N/A | test support only | test panel/workspace/environment context | no | helper name must reflect no TenantPanel exists |
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|---|---|---|---|---|---|---|---|
| Managed environment index/detail | Primary Decision Surface | Operator chooses or inspects the managed environment inside a workspace | workspace, environment identity, readiness/health links, primary next action | domain-specific diagnostics and raw provider detail remain on existing surfaces | Primary because it is the canonical starting point for environment work | Workspace -> Managed Environments -> Environment Detail | removes need to choose between `/admin/tenants` and workspace routes |
| Required permissions / diagnostics / access scopes | Secondary Context Surface | Operator checks readiness, provider health, or access narrowing before acting | current workspace/environment context and status/action affordance | provider-owned diagnostics and evidence remain secondary | Secondary because it supports environment readiness and operation decisions | stays under canonical environment route family | reduces route and copy ambiguity |
| Workspace operations | Secondary Context Surface | Operator follows a run from environment context to operation detail | workspace-scoped run list/detail | run diagnostics and logs remain in operation detail | Secondary because execution truth belongs to Operations | uses existing OperationRun route contract | avoids tenant-scoped operation back links |
## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)*
| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention |
|---|---|---|---|---|---|---|---|
| Managed environment index/detail | operator-MSP, support-platform | workspace/environment scope, environment name, readiness/action links | provider readiness and access-scope context | raw provider identifiers only where already allowed | `Open environment` or current primary environment action | raw provider payloads and support diagnostics | one canonical environment detail URL |
| Required permissions / diagnostics / access scopes | operator-MSP, support-platform | current environment, required permission state, access-scope state | provider health, missing permissions, narrowing details | Microsoft tenant ID / Graph identifiers only as provider detail | page-owned primary readiness or access action | raw/provider-owned data | no legacy tenant-detail fallback |
| Workspace operations | operator-MSP, support-platform | run status and workspace context | run detail and failure reason | raw logs only where existing gates allow | `View operation` | support/raw detail | operations links always workspace-scoped |
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Managed environments | List / Detail | Workspace-scoped environment registry | Open environment | full-row or identifier open to canonical detail | required when table exists | More/detail header | More/detail header with confirmation | `/admin/workspaces/{workspace}/environments` | `/admin/workspaces/{workspace}/environments/{environment}` | workspace + environment | Managed environment | current workspace/environment and readiness links | none |
| Required permissions | Detail / Readiness | Environment-scoped readiness page | Review missing permissions | direct page route | n/a | existing page actions | unchanged; destructive-like actions require confirmation | inherited environment route | `/admin/workspaces/{workspace}/environments/{environment}/required-permissions` | workspace + environment | Required permissions | permission readiness and safe next action | none |
| Provider connections | List / Detail / Integrations | Tenantless admin resource with neutral scope context | Open provider connection | existing provider resource detail | required where table exists | More/detail header | More/detail header with confirmation | `/admin/provider-connections` | `/admin/provider-connections/{record}` | workspace/environment context through record/query/domain | Provider connection | provider connection and target scope | no tenant-scoped route family |
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|---|---|---|---|---|---|---|---|---|---|---|
| Managed environments | Workspace operator | Pick the correct environment and continue safely | list/detail | Which managed environment am I working in? | workspace/environment scope, environment identity, current route truth | provider identifiers and low-level diagnostics | readiness, lifecycle, access scope | TenantPilot only unless page-specific action says otherwise | Open environment | existing environment mutations only |
| Required permissions | Workspace operator | Decide whether provider permissions are ready | readiness detail | What permission gap blocks this environment? | missing permission state, provider context, primary remediation path | raw provider scopes and Graph detail | readiness and verification | Microsoft tenant only when explicitly remediating provider permission | Review required permissions | none added |
| Provider connections | Workspace operator | Inspect or manage provider connection | integration resource | Which provider connection applies to this scope? | provider connection, neutral target scope | provider-owned profile detail | lifecycle, authorization, target scope | TenantPilot record / Microsoft provider depending on existing action | Open provider connection | existing destructive actions only |
| Operations | Workspace operator | Follow execution truth | operations list/detail | What happened or is running in this workspace? | run status, workspace scope, environment filter when present | low-level run diagnostics | lifecycle, outcome, progress | execution record only | View operation | none added |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: no
- **New persisted entity/table/artifact?**: no application persistence; one spec-local `legacy-surface-audit.md` artifact is used as implementation evidence.
- **New abstraction?**: yes, only if no existing canonical link helper exists. The allowed abstraction is a bounded `ManagedEnvironmentLinks` helper for route generation.
- **New enum/state/reason family?**: no
- **New cross-domain UI framework/taxonomy?**: no
- **Current operator problem**: legacy tenant routes and helpers still act as product truth and can resurrect retired surfaces.
- **Existing structure is insufficient because**: direct calls to TenantResource/TenantDashboard/TenantRequiredPermissions and old intended URL handling are scattered; route truth is not centralized enough for guard tests to enforce cleanly.
- **Narrowest correct implementation**: extend an existing helper if present; otherwise add one small link helper that only maps current workspace/environment objects to existing named routes.
- **Ownership cost**: low; one helper plus guard tests must be maintained when route names change.
- **Alternative intentionally rejected**: preserving redirects or aliases for `/admin/tenants...` or `/admin/t...`; that would keep the legacy surface active.
- **Release truth**: current-release cutover cleanup in a pre-production environment.
### Compatibility posture
This feature assumes the repo's pre-production lean doctrine.
Backward compatibility, legacy aliases, route shims, old helper aliases, dual routing, and compatibility-specific tests are out of scope unless a safe canonical URL can be uniquely resolved and the spec/plan is updated with an explicit exception. The default behavior for unsafe legacy paths is 404 or canonical workspace fallback, not silent redirect compatibility.
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
- **Test purpose / classification**: Feature guard tests for route/link/intended URL contracts; Feature/Filament tests for resource/page link changes; Unit tests for helper/link resolver behavior; Browser only if visible navigation flows are touched.
- **Validation lane(s)**: targeted Feature guards, Workspaces, ProviderConnections, RequiredPermissions, Filament, Spec 288 guard pack, Spec 293 cutover lane, and optional Browser smoke if navigation is visibly changed.
- **Why this classification and these lanes are sufficient**: The change is route/link/test-helper cutover, not a new business workflow. Guard tests prove retired paths stay dead; focused resource/page tests prove canonical replacements work.
- **New or expanded test families**: new/expanded guard tests under `tests/Feature/Guards` and `tests/Feature/Workspaces`; no new permanent test lane.
- **Fixture / helper cost impact**: `setTenantPanelContext()` is replaced by an explicit admin/workspace/environment helper. The new helper must not make expensive provider, browser, or full workspace defaults implicit.
- **Heavy-family visibility / justification**: none by default. Heavy/browser tests only run when existing visible flows are touched.
- **Special surface test profile**: `standard-native-filament`, `global-context-shell`, `route-contract`, and `browser-smoke` only when UI navigation is touched.
- **Standard-native relief or required special coverage**: ordinary Pest/Filament coverage is sufficient unless a browser-facing route/navigation flow changes.
- **Reviewer handoff**: Reviewers must confirm Filament v5 on Livewire v4, provider registration remains in `apps/platform/bootstrap/providers.php`, globally searchable resources have Edit/View pages or global search disabled, destructive actions still use `->action(...)`, `->requiresConfirmation()`, and authorization, asset strategy is unchanged unless documented, and tests cover mutated pages/actions via Livewire/Filament where applicable.
- **Budget / baseline / trend impact**: targeted guards grow; no full-suite budget change is planned. Any material lane runtime drift must be documented in the active PR/spec close-out.
- **Escalation needed**: document-in-feature for contained old technical names; follow-up-spec for structural DB/model rename or broader localization.
- **Active feature PR close-out entry**: Guardrail / Route Cutover / Smoke Coverage as applicable.
- **Planned validation commands**:
```bash
cd apps/platform
./vendor/bin/sail artisan test --compact tests/Feature/Guards
./vendor/bin/sail artisan test --compact tests/Feature/Workspaces
./vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections
./vendor/bin/sail artisan test --compact tests/Feature/RequiredPermissions
./vendor/bin/sail artisan test --compact tests/Feature/Filament
./vendor/bin/sail bin pint --dirty --format agent
```
### Required Regression Proof Pack
The implementation close-out must record exact results for the focused Spec 297 proof:
```bash
cd apps/platform
./vendor/bin/sail artisan test --compact \
tests/Feature/Guards/NoLegacyTenantPanelRuntimeTest.php \
tests/Feature/Guards/NoActiveTenantResourceRoutesTest.php \
tests/Feature/Guards/ManagedEnvironmentCanonicalRouteContractTest.php \
tests/Feature/Workspaces/WorkspaceIntendedUrlLegacyRejectionTest.php \
tests/Feature/ProviderConnections/LegacyRedirectTest.php \
tests/Feature/ManagedEnvironment/LegacyTenantCoreGuardTest.php \
tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php \
tests/Feature/Filament/ManagedEnvironmentAccessScopeManagementTest.php \
tests/Feature/Rbac/ProviderConnectionWorkspaceFirstPolicyTest.php
```
The existing Spec 288 guard pack must also remain green:
```bash
cd apps/platform
./vendor/bin/sail artisan test --compact \
tests/Feature/Guards/Spec288NoLegacyRouteAndHelperGuardTest.php \
tests/Feature/Guards/Spec288ProviderCoreAndRoleAuthorityGuardTest.php \
tests/Feature/Guards/AdminWorkspaceRoutesGuardTest.php \
tests/Feature/Guards/ProviderBoundaryPlatformCoreGuardTest.php \
tests/Feature/Guards/BrowserLaneIsolationTest.php \
tests/Feature/Guards/CiLaneFailureClassificationContractTest.php \
tests/Feature/Guards/CiHeavyBrowserWorkflowContractTest.php
```
If visible navigation flows are touched, run browser smoke:
```bash
cd apps/platform
./vendor/bin/sail artisan test --compact \
tests/Browser/Spec281ProviderConnectionScopeSmokeTest.php \
tests/Browser/Spec285WorkspaceRbacEnvironmentAccessSmokeTest.php
```
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Legacy Tenant Routes Stay Dead (Priority: P1)
As a workspace operator, I must not be routed into retired tenant-panel or tenant-resource product surfaces.
**Why this priority**: Retired routes are the direct source of product-truth drift.
**Independent Test**: Request `/admin/t/example`, `/admin/t/example/operations`, `/admin/t/example/provider-connections`, `/admin/tenants`, `/admin/tenants/{environment}`, and legacy provider/required-permissions paths and verify they are not active product pages.
**Acceptance Scenarios**:
1. **Given** an authenticated workspace member, **When** they request `/admin/t/example`, **Then** the response is 404.
2. **Given** an authenticated workspace member, **When** they request `/admin/tenants/{environment}/provider-connections`, **Then** the response is 404 unless a documented safe canonical resolution exists.
3. **Given** route:list output, **When** it is scanned, **Then** no active `/admin/t...` route exists and `/admin/tenants...` is not an active product surface.
---
### User Story 2 - Canonical Managed Environment Links Are The Only Runtime Links (Priority: P1)
As an operator following links from dashboards, notifications, provider connections, evidence, reviews, or operations, I need every environment link to resolve through workspace-first managed-environment routes.
**Why this priority**: Old link generation silently revives legacy surfaces even after routes are hidden.
**Independent Test**: Render or call runtime link builders for environment detail, required permissions, diagnostics, access scopes, provider connections, and operations and verify generated URLs contain `/admin/workspaces/{workspace}/environments...` or tenantless provider/operations canonical routes, never `/admin/tenants...` or `/admin/t...`.
**Acceptance Scenarios**:
1. **Given** a workspace and managed environment, **When** `ManagedEnvironmentLinks::viewUrl($environment)` or the repo-real equivalent is called, **Then** the URL points to canonical environment detail.
2. **Given** an environment with required permissions, **When** the page action/back link is rendered, **Then** the link points to canonical environment required-permissions route.
3. **Given** an operation for an environment, **When** a related link is generated, **Then** the operation URL is workspace-scoped.
---
### User Story 3 - Intended URLs Cannot Resurrect Legacy Surfaces (Priority: P1)
As a user returning after workspace selection or login, I must never land on retired tenant routes through stored intended URLs.
**Why this priority**: Intended URL persistence can bypass navigation cleanup and silently re-enable old route truth.
**Independent Test**: Store legacy intended URLs and verify the resolver rejects them, normalizes `/admin/operations` to workspace operations when possible, and falls back to workspace home or environment index when safe resolution is impossible.
**Acceptance Scenarios**:
1. **Given** `/admin/t/example/provider-connections` as an intended URL, **When** the workspace redirect resolver runs, **Then** it does not return or persist that URL.
2. **Given** `/admin/operations` as an intended URL and a known workspace, **When** the resolver runs, **Then** it returns `/admin/workspaces/{workspace}/operations`.
3. **Given** `/admin/tenants/example/required-permissions` without safe workspace/environment resolution, **When** the resolver runs, **Then** it falls back to workspace home or environment index.
---
### User Story 4 - Test Harness Uses Admin Workspace/Environment Context (Priority: P2)
As a maintainer, I need tests to express the current admin panel and managed-environment context rather than reviving TenantPanel vocabulary.
**Why this priority**: Old helper names keep the retired panel as a mental and test-runtime model.
**Independent Test**: `rg "setTenantPanelContext|panel:\\s*'tenant'|panel:\\s*\"tenant\"" apps/platform/tests` returns no active helper or non-guard use; all migrated tests use a new admin/workspace/environment context helper.
**Acceptance Scenarios**:
1. **Given** `tests/Pest.php`, **When** it is scanned, **Then** `setTenantPanelContext` is absent.
2. **Given** a test that needs environment context, **When** it sets context, **Then** it uses `setAdminEnvironmentContext()` or the chosen canonical helper.
3. **Given** guard tests, **When** an old helper name is reintroduced, **Then** the guard fails.
---
### User Story 5 - Narrowing-Only Environment Access Remains Intact (Priority: P2)
As a security reviewer, I need managed-environment memberships to remain access-scope/narrowing-only and never regain role authority.
**Why this priority**: Route cutover must not backslide into tenant-scope role authority while replacing test fixtures.
**Independent Test**: RBAC tests prove workspace membership is the only role-bearing truth and managed-environment scope cannot grant role/capability authority.
**Acceptance Scenarios**:
1. **Given** a user with managed-environment scope but no workspace capability, **When** they access a protected action, **Then** capability denial remains 403 after membership is established or 404 when not entitled.
2. **Given** a managed-environment membership row with legacy/placeholder role data, **When** authorization runs, **Then** the role value is ignored as authority.
## Functional Requirements
- **FR-297-001**: `apps/platform/app/Providers/Filament/TenantPanelProvider.php` MUST be deleted if it is not runtime-registered and not required by runtime code. If deletion proves impossible, `legacy-surface-audit.md` MUST document the true dependency and the implementation MUST still prove it cannot be registered or route `/admin/t...`.
- **FR-297-002**: `apps/platform/bootstrap/providers.php` MUST NOT register `TenantPanelProvider`.
- **FR-297-003**: `/admin/t`, `/admin/t/...`, and nested legacy tenant-panel paths MUST return 404 or be absent from route:list. Default behavior is 404, not redirect.
- **FR-297-004**: `/admin/tenants...` MUST no longer be active product truth. TenantResource list/view/edit/memberships routes MUST be removed, moved out of auto-discovery, or otherwise made non-product routes with explicit guard proof.
- **FR-297-005**: Canonical managed-environment URLs MUST exist for environment index, detail, required permissions/readiness, diagnostics/provider health, access scopes/membership narrowing, and workspace operations, reusing repo-real routes where present.
- **FR-297-006**: Runtime code MUST NOT generate active product links through `TenantResource::getUrl(...)`, `TenantDashboard::getUrl(...)`, or `TenantRequiredPermissions::getUrl(...)` unless the target class has been changed to emit canonical workspace/environment routes and guard tests prove no `/admin/tenants...` or `/admin/t...` URL.
- **FR-297-007**: The implementation MUST introduce or extend one canonical link owner for managed-environment URLs, such as `ManagedEnvironmentLinks`, instead of scattering route-name literals across runtime surfaces.
- **FR-297-008**: `WorkspaceRedirectResolver`, `WorkspaceIntendedUrl`, or their repo-real equivalents MUST reject `/admin/t...`, `/admin/tenants...`, `/admin/tenants/*/required-permissions`, `/admin/tenants/*/provider-connections`, and external URLs as final intended destinations.
- **FR-297-009**: Legacy `/admin/operations` intended URLs MUST normalize to workspace-scoped operations when a workspace is known, or fall back safely when not.
- **FR-297-010**: Legacy required-permissions URLs under `/admin/tenants/{environment}/required-permissions` MUST not return 200 as active product pages.
- **FR-297-011**: Legacy provider-connection URLs under `/admin/tenants/{environment}/provider-connections...` MUST not return 200 as active product pages.
- **FR-297-012**: `setTenantPanelContext()` MUST be removed or renamed with no alias under the old name. The replacement helper MUST express admin + workspace + managed-environment context.
- **FR-297-013**: Runtime and test copy touched by this cutover MUST use managed-environment/environment product language instead of tenant-first labels, except for Microsoft/provider-specific tenant ID terminology, technical model names, migrations, historical specs, or audit history.
- **FR-297-014**: Guard tests MUST fail if `TenantPanelProvider` is registered, `/admin/t...` becomes routable, `/admin/tenants...` is revived as product truth, old helper names return, or runtime URL generation emits legacy tenant paths.
- **FR-297-015**: Workspace-first RBAC MUST remain intact: workspace membership carries role/capability authority, managed-environment membership narrows access only, and non-member scope remains deny-as-not-found.
## Non-Functional Requirements
- **NFR-297-001**: No new persisted storage, migration, compatibility shim, or dual-read/dual-write path.
- **NFR-297-002**: Route/link guards must be deterministic and actionable, with failure messages that name the forbidden path/helper and owning file.
- **NFR-297-003**: New helpers must be small, explicit, and easy to delete or rename if the route contract changes.
- **NFR-297-004**: Test updates must not broaden heavy/browser lanes unless the touched surface requires it.
- **NFR-297-005**: Any remaining technical `Tenant` references must be documented as allowed or listed as follow-up.
## Acceptance Criteria
- **AC-297-001**: No runtime provider file or provider registration can re-enable the tenant panel.
- **AC-297-002**: `/admin/t...` is not routable as product truth and is guarded.
- **AC-297-003**: `/admin/tenants...` is retired as active product truth; no navigation, intended URL, runtime link, or test helper depends on it.
- **AC-297-004**: One canonical managed-environment link contract covers index, detail, readiness/permissions, diagnostics, access scopes, and workspace operations where repo-real routes exist.
- **AC-297-005**: Intended URL handling cannot return retired tenant routes.
- **AC-297-006**: Runtime links no longer generate old TenantResource/TenantPanel URLs.
- **AC-297-007**: `setTenantPanelContext()` is gone with no alias.
- **AC-297-008**: Provider and required-permissions tenant-scoped legacy routes do not return 200.
- **AC-297-009**: Managed-environment access scopes remain narrowing-only.
- **AC-297-010**: Spec 288 and Spec 293 guard/stabilization proof remains green after the cutover.
- **AC-297-011**: Pint dirty passes.
## Success Criteria
- **SC-297-001**: Route-list and guard tests prove no active `/admin/t...` route and no active product `/admin/tenants...` route family.
- **SC-297-002**: Source scans in runtime code find no unallowed `/admin/tenants`, `/admin/t/`, `TenantResource::getUrl`, `TenantDashboard::getUrl`, `TenantRequiredPermissions::getUrl`, or `setTenantPanelContext` references.
- **SC-297-003**: Focused route/link/intended URL tests pass without compatibility redirects.
- **SC-297-004**: Final implementation summary can state: `Managed Environment canonical cutover complete; legacy tenant surfaces retired.`
## Risks
- **R-297-001**: Some old tests may still assert historical `/admin/tenants...` behavior from Spec 080/143/147. Mitigation: classify as historical tests only if moved/retired or update them to current product truth.
- **R-297-002**: TenantResource may still be globally searchable. Mitigation: retire it from discovery or disable global search unless canonical Edit/View routes exist.
- **R-297-003**: Link helper creation could become over-generalized. Mitigation: helper may only map existing workspace/environment routes and must not become a routing framework.
- **R-297-004**: Intended URL normalization can create unsafe cross-workspace redirects. Mitigation: require workspace/environment entitlement checks and fallback when resolution is ambiguous.
## Assumptions
- The product is still pre-production under LEAN-001, so compatibility routes and legacy aliases are not required.
- Internal `Tenant` class/table names may remain technical implementation detail until a separate DB/model rename spec exists.
- Existing canonical workspace/environment routes are the preferred target and must be reused rather than duplicated.
- Filament remains v5 on Livewire v4. Provider registration remains in `apps/platform/bootstrap/providers.php`.
- No new asset registration is planned. If implementation unexpectedly registers Filament assets, deploy notes must include `cd apps/platform && php artisan filament:assets`.
## Final Output Required For Implementation
At implementation close-out, report:
1. Commands run and result.
2. Deleted legacy code and why deletion was safe.
3. Retired route families, new behavior, and guard.
4. Canonical replacements for old usage.
5. Remaining legacy references, why allowed, and follow-up if any.
6. Test/lane results.
7. Final decision, exactly one of:
- `Managed Environment canonical cutover complete; legacy tenant surfaces retired.`
- `Blocked by true runtime dependency on legacy tenant surface.`
- `Incomplete; active legacy tenant routes remain.`

Some files were not shown because too many files have changed in this diff Show More