Spec 315: implement environment CTA explicit filter contract #370

Merged
ahmido merged 1 commits from 315-environment-cta-explicit-filter-contract into platform-dev 2026-05-16 11:50:24 +00:00
101 changed files with 2301 additions and 596 deletions

View File

@ -5,15 +5,18 @@
namespace App\Filament\Pages;
use App\Filament\Pages\Governance\GovernanceInbox;
use App\Filament\Widgets\ManagedEnvironment\ManagedEnvironmentTriageArrivalContinuity;
use App\Filament\Widgets\Dashboard\DashboardKpis;
use App\Filament\Widgets\Dashboard\EnvironmentDashboardContextChips;
use App\Filament\Widgets\Dashboard\EnvironmentDashboardOverview;
use App\Models\SupportRequest;
use App\Filament\Widgets\ManagedEnvironment\ManagedEnvironmentTriageArrivalContinuity;
use App\Models\ManagedEnvironment;
use App\Models\SupportRequest;
use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\EnvironmentDashboard\EnvironmentDashboardSummary;
use App\Support\EnvironmentDashboard\EnvironmentDashboardSummaryBuilder;
use App\Support\ManagedEnvironmentLinks;
use App\Support\ProductTelemetry\ProductTelemetryRecorder;
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
@ -21,15 +24,13 @@
use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder;
use App\Support\SupportRequests\ExternalSupportDeskHandoffService;
use App\Support\SupportRequests\SupportRequestSubmissionService;
use App\Support\EnvironmentDashboard\EnvironmentDashboardSummary;
use App\Support\EnvironmentDashboard\EnvironmentDashboardSummaryBuilder;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Facades\Filament;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Pages\Dashboard;
use Filament\Schemas\Components\Utilities\Get;
@ -41,8 +42,6 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\HtmlString;
use App\Filament\Widgets\Dashboard\DashboardKpis;
class EnvironmentDashboard extends Dashboard
{
protected Width|string|null $maxContentWidth = Width::Full;
@ -59,7 +58,7 @@ public static function getNavigationLabel(): string
return __('localization.dashboard.environment_title');
}
public function getTitle(): string | Htmlable
public function getTitle(): string|Htmlable
{
$tenant = Filament::getTenant();
@ -81,7 +80,7 @@ public function getTitle(): string | Htmlable
));
}
public function getSubheading(): string | Htmlable | null
public function getSubheading(): string|Htmlable|null
{
return __('localization.dashboard.overview.page_subheading');
}
@ -265,7 +264,7 @@ private function governanceInboxHeaderAction(): ?Action
->icon('heroicon-o-inbox-stack')
->color('primary')
->url(GovernanceInbox::getUrl(panel: 'admin').'?'.http_build_query([
'managed_environment_id' => (int) $tenant->getKey(),
'environment_id' => (int) $tenant->getKey(),
]));
}

View File

@ -18,6 +18,7 @@
use App\Support\Filament\TablePaginationProfiles;
use App\Support\GovernanceDecisions\GovernanceDecisionRegisterBuilder;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\Navigation\WorkspaceHubEnvironmentFilter;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
@ -34,7 +35,6 @@
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use UnitEnum;
@ -144,8 +144,8 @@ public function mount(): void
public function pageUrl(array $overrides = []): string
{
$selectedTenant = $this->selectedTenant();
$resolvedTenant = array_key_exists('tenant', $overrides)
? $overrides['tenant']
$resolvedTenant = array_key_exists('environment_id', $overrides)
? $overrides['environment_id']
: ($selectedTenant?->getKey() !== null ? (string) $selectedTenant->getKey() : null);
$resolvedRegisterState = array_key_exists('register_state', $overrides)
? $overrides['register_state']
@ -154,7 +154,7 @@ public function pageUrl(array $overrides = []): string
return static::getUrl(
panel: 'admin',
parameters: array_filter([
'managed_environment_id' => is_string($resolvedTenant) && $resolvedTenant !== '' ? $resolvedTenant : null,
'environment_id' => (is_string($resolvedTenant) || is_numeric($resolvedTenant)) && (string) $resolvedTenant !== '' ? (string) $resolvedTenant : null,
'register_state' => is_string($resolvedRegisterState) && $resolvedRegisterState !== 'open' ? $resolvedRegisterState : null,
], static fn (mixed $value): bool => $value !== null && $value !== ''),
);
@ -243,7 +243,7 @@ public function emptyStateActionLabel(): ?string
public function emptyStateActionUrl(): ?string
{
if ($this->tenantFilterAloneExcludesRows()) {
return $this->pageUrl(['tenant' => null]);
return $this->pageUrl(['environment_id' => null, 'register_state' => 'open']);
}
if ($this->registerState === 'recently_closed') {
@ -265,7 +265,7 @@ public function table(Table $table): Table
->recordUrl(fn (FindingException $record): ?string => $this->decisionUrl($record))
->columns([
TextColumn::make('tenant.name')
->label('ManagedEnvironment')
->label('Environment')
->searchable()
->sortable(),
TextColumn::make('status')
@ -464,22 +464,28 @@ private function visibleDecisionTenants(): array
private function applyRequestedTenantPrefilter(): void
{
$requestedTenant = request()->query('managed_environment_id', request()->query('tenant'));
$workspace = $this->workspace();
if (! is_string($requestedTenant) && ! is_numeric($requestedTenant)) {
if (! $workspace instanceof Workspace) {
return;
}
foreach ($this->authorizedTenants() as $tenant) {
if ((string) $tenant->getKey() !== (string) $requestedTenant && (string) $tenant->external_id !== (string) $requestedTenant) {
continue;
}
$this->tenantId = (int) $tenant->getKey();
$filter = WorkspaceHubEnvironmentFilter::fromRequest(request(), $workspace);
if (! $filter instanceof WorkspaceHubEnvironmentFilter) {
return;
}
$environmentId = $filter->environmentId();
foreach ($this->visibleDecisionTenants() as $tenant) {
if ((int) $tenant->getKey() === $environmentId) {
$this->tenantId = $environmentId;
return;
}
}
throw new NotFoundHttpException;
}
@ -496,13 +502,6 @@ private function resolveRequestedRegisterState(): string
: 'open';
}
private static function hasRequestedTenantPrefilter(): bool
{
$requestedTenant = request()->query('managed_environment_id', request()->query('tenant'));
return is_string($requestedTenant) || is_numeric($requestedTenant);
}
private static function resolveWorkspaceFromRequest(): ?Workspace
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());

View File

@ -4,8 +4,6 @@
namespace App\Filament\Pages\Governance;
use App\Filament\Pages\Findings\FindingsIntakeQueue;
use App\Filament\Pages\Findings\MyFindingsInbox;
use App\Models\ManagedEnvironment;
use App\Models\User;
use App\Models\Workspace;
@ -15,6 +13,7 @@
use App\Support\Auth\Capabilities;
use App\Support\GovernanceInbox\GovernanceInboxSectionBuilder;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\Navigation\WorkspaceHubEnvironmentFilter;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
@ -148,7 +147,7 @@ public function calmEmptyState(): array
'title' => 'This environment filter is hiding other visible attention',
'body' => 'The current environment scope is calm, but other visible environments in this workspace still have governance attention.',
'action_label' => 'Clear environment filter',
'action_url' => $this->pageUrl(['tenant' => null]),
'action_url' => $this->pageUrl(['environment_id' => null, 'family' => null]),
];
}
@ -173,8 +172,8 @@ public function isActiveFamily(?string $familyKey): bool
public function pageUrl(array $overrides = []): string
{
$selectedTenant = $this->selectedTenant();
$resolvedTenant = array_key_exists('tenant', $overrides)
? $overrides['tenant']
$resolvedTenant = array_key_exists('environment_id', $overrides)
? $overrides['environment_id']
: ($selectedTenant?->getKey() !== null ? (string) $selectedTenant->getKey() : null);
$resolvedFamily = array_key_exists('family', $overrides)
? $overrides['family']
@ -183,7 +182,7 @@ public function pageUrl(array $overrides = []): string
return static::getUrl(
panel: 'admin',
parameters: array_filter([
'managed_environment_id' => is_string($resolvedTenant) && $resolvedTenant !== '' ? $resolvedTenant : null,
'environment_id' => (is_string($resolvedTenant) || is_numeric($resolvedTenant)) && (string) $resolvedTenant !== '' ? (string) $resolvedTenant : null,
'family' => is_string($resolvedFamily) && $resolvedFamily !== '' ? $resolvedFamily : null,
], static fn (mixed $value): bool => $value !== null && $value !== ''),
);
@ -367,22 +366,28 @@ private function authorizedTenants(): array
private function applyRequestedTenantPrefilter(): void
{
$requestedTenant = request()->query('managed_environment_id', request()->query('tenant'));
$workspace = $this->workspace();
if (! is_string($requestedTenant) && ! is_numeric($requestedTenant)) {
if (! $workspace instanceof Workspace) {
return;
}
foreach ($this->authorizedTenants() as $tenant) {
if ((string) $tenant->getKey() !== (string) $requestedTenant && (string) $tenant->external_id !== (string) $requestedTenant) {
continue;
}
$this->tenantId = (int) $tenant->getKey();
$filter = WorkspaceHubEnvironmentFilter::fromRequest(request(), $workspace);
if (! $filter instanceof WorkspaceHubEnvironmentFilter) {
return;
}
$environmentId = $filter->environmentId();
foreach ($this->authorizedTenants() as $tenant) {
if ((int) $tenant->getKey() === $environmentId) {
$this->tenantId = $environmentId;
return;
}
}
throw new NotFoundHttpException;
}

View File

@ -5,25 +5,25 @@
namespace App\Filament\Pages\Monitoring;
use App\Filament\Resources\EvidenceSnapshotResource;
use App\Models\EnvironmentReview;
use App\Models\EvidenceSnapshot;
use App\Models\ManagedEnvironment;
use App\Models\EnvironmentReview;
use App\Models\User;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Models\Workspace;
use App\Support\EnvironmentReviewStatus;
use App\Support\Filament\CanonicalAdminTenantFilterState;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\Navigation\WorkspaceHubEnvironmentFilter;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
use App\Support\Ui\GovernanceArtifactTruth\CompressedGovernanceOutcome;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use App\Support\Ui\GovernanceArtifactTruth\CompressedGovernanceOutcome;
use App\Support\Ui\GovernanceArtifactTruth\SurfaceCompressionContext;
use App\Support\Workspaces\WorkspaceContext;
use App\Support\Filament\TablePaginationProfiles;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Pages\Page;
@ -36,6 +36,7 @@
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use UnitEnum;
class EvidenceOverview extends Page implements HasTable
@ -47,7 +48,7 @@ class EvidenceOverview extends Page implements HasTable
'surfaceType' => 'simple_monitoring',
'stateFields' => [
[
'stateKey' => 'managed_environment_id',
'stateKey' => 'environment_id',
'stateClass' => 'contextual_prefilter',
'carrier' => 'query_param',
'queryRole' => 'durable_restorable',
@ -91,7 +92,7 @@ class EvidenceOverview extends Page implements HasTable
'precedenceOrder' => ['query', 'session', 'default'],
'appliesOnInitialMountOnly' => true,
'activeStateBecomesAuthoritativeAfterMount' => true,
'clearsOnTenantSwitch' => ['managed_environment_id'],
'clearsOnTenantSwitch' => ['environment_id', 'managed_environment_id'],
'invalidRequestedStateFallback' => 'discard_and_continue',
],
'inspectContract' => [
@ -102,7 +103,7 @@ class EvidenceOverview extends Page implements HasTable
'shareable' => false,
'invalidSelectionFallback' => 'discard_and_continue',
],
'shareableStateKeys' => ['managed_environment_id', 'search'],
'shareableStateKeys' => ['environment_id', 'search'],
'localOnlyStateKeys' => [],
];
@ -151,8 +152,10 @@ public static function monitoringPageStateContract(): array
public function mount(): void
{
$this->authorizeWorkspaceAccess();
app(CanonicalAdminTenantFilterState::class)
->forgetEnvironmentLikeFiltersForCleanWorkspaceHubEntry($this->getTableFiltersSessionKey(), request());
if (! request()->query->has('environment_id')) {
app(CanonicalAdminTenantFilterState::class)
->forgetEnvironmentLikeFilters($this->getTableFiltersSessionKey(), request());
}
$this->seedTableStateFromQuery();
$this->rows = $this->rowsForState($this->tableFilters ?? [], $this->tableSearch)->values()->all();
@ -185,13 +188,13 @@ public function table(Table $table): Table
})
->filters([
SelectFilter::make('managed_environment_id')
->label('ManagedEnvironment')
->label('Environment')
->options(fn (): array => $this->tenantFilterOptions())
->searchable(),
])
->columns([
TextColumn::make('tenant_name')
->label('ManagedEnvironment')
->label('Environment')
->sortable(),
TextColumn::make('artifact_truth_label')
->label('Outcome')
@ -215,7 +218,7 @@ public function table(Table $table): Table
->emptyStateHeading('No evidence snapshots in this scope')
->emptyStateDescription(fn (): string => $this->hasActiveOverviewFilters()
? 'Clear the current filters to return to the full workspace evidence overview.'
: 'Adjust filters or create a tenant snapshot to populate the workspace overview.')
: 'Adjust filters or create an environment snapshot to populate the workspace overview.')
->emptyStateActions([
Action::make('clear_filters')
->label('Clear filters')
@ -255,6 +258,23 @@ public function clearOverviewFilters(): void
$this->redirect($this->overviewUrl(), navigate: true);
}
/**
* @return array{label: string, clear_url: string}|null
*/
public function environmentFilterChip(): ?array
{
$tenant = $this->filteredTenant();
if (! $tenant instanceof ManagedEnvironment) {
return null;
}
return [
'label' => (string) $tenant->name,
'clear_url' => $this->overviewUrl(),
];
}
private function snapshotTruth(EvidenceSnapshot $snapshot, bool $fresh = false): ArtifactTruthEnvelope
{
$presenter = app(ArtifactTruthPresenter::class);
@ -497,14 +517,26 @@ private function seedTableStateFromQuery(): void
$this->tableSearch = trim((string) request()->query('search', ''));
}
if (! array_key_exists('managed_environment_id', $query)) {
if (! array_key_exists('environment_id', $query)) {
return;
}
$tenantFilter = $this->normalizeTenantFilter(request()->query('managed_environment_id'));
$workspace = $this->workspace();
if (! $workspace instanceof Workspace) {
return;
}
$filter = WorkspaceHubEnvironmentFilter::fromRequest(request(), $workspace);
if (! $filter instanceof WorkspaceHubEnvironmentFilter) {
return;
}
$tenantFilter = $this->normalizeTenantFilter($filter->environmentId());
if ($tenantFilter === null) {
return;
throw new NotFoundHttpException;
}
$this->tableFilters = [
@ -555,4 +587,32 @@ private function workspaceId(): int
->currentWorkspaceForMemberOrFail($user, request())
->getKey();
}
private function workspace(): ?Workspace
{
$user = auth()->user();
if (! $user instanceof User) {
throw new AuthenticationException;
}
return app(WorkspaceContext::class)->currentWorkspaceForMemberOrFail($user, request());
}
private function filteredTenant(): ?ManagedEnvironment
{
$tenantId = $this->normalizeTenantFilter(data_get($this->tableFilters, 'managed_environment_id.value'));
if (! is_int($tenantId)) {
return null;
}
foreach ($this->accessibleTenants() as $tenant) {
if ((int) $tenant->getKey() === $tenantId) {
return $tenant;
}
}
return null;
}
}

View File

@ -20,6 +20,7 @@
use App\Support\Filament\FilterOptionCatalog;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\Navigation\WorkspaceHubEnvironmentFilter;
use App\Support\OperateHub\OperateHubShell;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
@ -67,7 +68,7 @@ class FindingExceptionsQueue extends Page implements HasTable
'invalidFallback' => 'clear_selection_and_continue',
],
[
'stateKey' => 'tenant',
'stateKey' => 'environment_id',
'stateClass' => 'contextual_prefilter',
'carrier' => 'query_param',
'queryRole' => 'durable_restorable',
@ -101,7 +102,7 @@ class FindingExceptionsQueue extends Page implements HasTable
'precedenceOrder' => ['query', 'session', 'default'],
'appliesOnInitialMountOnly' => true,
'activeStateBecomesAuthoritativeAfterMount' => true,
'clearsOnTenantSwitch' => ['tenant', 'managed_environment_id', 'status', 'current_validity_state'],
'clearsOnTenantSwitch' => ['environment_id', 'managed_environment_id', 'status', 'current_validity_state'],
'invalidRequestedStateFallback' => 'clear_selection_and_continue',
],
'inspectContract' => [
@ -112,7 +113,7 @@ class FindingExceptionsQueue extends Page implements HasTable
'shareable' => true,
'invalidSelectionFallback' => 'clear_selection_and_continue',
],
'shareableStateKeys' => ['tenant', 'exception'],
'shareableStateKeys' => ['environment_id', 'exception'],
'localOnlyStateKeys' => [],
];
@ -192,8 +193,10 @@ public static function canAccess(): bool
public function mount(): void
{
app(CanonicalAdminTenantFilterState::class)
->forgetEnvironmentLikeFiltersForCleanWorkspaceHubEntry($this->getTableFiltersSessionKey(), request());
if (! request()->query->has('environment_id')) {
app(CanonicalAdminTenantFilterState::class)
->forgetEnvironmentLikeFilters($this->getTableFiltersSessionKey(), request());
}
$this->mountInteractsWithTable();
$this->applyRequestedTenantPrefilter();
@ -273,45 +276,45 @@ protected function getHeaderActions(): array
$selectedDecisionActions = [
Action::make('approve_selected_exception')
->label(GovernanceActionCatalog::rule('approve_exception')->canonicalLabel)
->color('success')
->visible(fn (): bool => $this->selectedFindingException()?->isPending() ?? false)
->requiresConfirmation()
->modalHeading(GovernanceActionCatalog::rule('approve_exception')->modalHeading)
->modalDescription(GovernanceActionCatalog::rule('approve_exception')->modalDescription)
->form([
DateTimePicker::make('effective_from')
->label('Effective from')
->required()
->seconds(false),
DateTimePicker::make('expires_at')
->label('Expires at')
->required()
->seconds(false),
Textarea::make('approval_reason')
->label('Approval reason')
->rows(3)
->required()
->maxLength(2000),
])
->action(function (array $data, FindingExceptionService $service): void {
$record = $this->selectedFindingException();
$user = auth()->user();
->label(GovernanceActionCatalog::rule('approve_exception')->canonicalLabel)
->color('success')
->visible(fn (): bool => $this->selectedFindingException()?->isPending() ?? false)
->requiresConfirmation()
->modalHeading(GovernanceActionCatalog::rule('approve_exception')->modalHeading)
->modalDescription(GovernanceActionCatalog::rule('approve_exception')->modalDescription)
->form([
DateTimePicker::make('effective_from')
->label('Effective from')
->required()
->seconds(false),
DateTimePicker::make('expires_at')
->label('Expires at')
->required()
->seconds(false),
Textarea::make('approval_reason')
->label('Approval reason')
->rows(3)
->required()
->maxLength(2000),
])
->action(function (array $data, FindingExceptionService $service): void {
$record = $this->selectedFindingException();
$user = auth()->user();
if (! $record instanceof FindingException || ! $user instanceof User) {
abort(404);
}
if (! $record instanceof FindingException || ! $user instanceof User) {
abort(404);
}
$wasRenewalRequest = $record->isPendingRenewal();
$updated = $service->approve($record, $user, $data);
$this->selectedFindingExceptionId = (int) $updated->getKey();
$this->resetTable();
$wasRenewalRequest = $record->isPendingRenewal();
$updated = $service->approve($record, $user, $data);
$this->selectedFindingExceptionId = (int) $updated->getKey();
$this->resetTable();
Notification::make()
->title($wasRenewalRequest ? 'Exception renewed' : GovernanceActionCatalog::rule('approve_exception')->successTitle)
->success()
->send();
}),
Notification::make()
->title($wasRenewalRequest ? 'Exception renewed' : GovernanceActionCatalog::rule('approve_exception')->successTitle)
->success()
->send();
}),
Action::make('reject_selected_exception')
->label(GovernanceActionCatalog::rule('reject_exception')->canonicalLabel)
@ -386,7 +389,7 @@ public function table(Table $table): Table
->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity)),
TextColumn::make('tenant.name')
->label('ManagedEnvironment')
->label('Environment')
->searchable(),
TextColumn::make('finding_summary')
->label('Finding')
@ -420,7 +423,7 @@ public function table(Table $table): Table
])
->filters([
SelectFilter::make('managed_environment_id')
->label('ManagedEnvironment')
->label('Environment')
->options(fn (): array => $this->tenantFilterOptions())
->searchable(),
SelectFilter::make('status')
@ -438,7 +441,7 @@ public function table(Table $table): Table
])
->bulkActions([])
->emptyStateHeading('No exceptions match this queue')
->emptyStateDescription('Adjust the current tenant or lifecycle filters to review governed exceptions in this workspace.')
->emptyStateDescription('Adjust the current environment or lifecycle filters to review governed exceptions in this workspace.')
->emptyStateIcon('heroicon-o-shield-check')
->emptyStateActions([
Action::make('clear_filters')
@ -517,6 +520,23 @@ public function clearSelectedException(): void
$this->selectedFindingExceptionId = null;
}
/**
* @return array{label: string, clear_url: string}|null
*/
public function environmentFilterChip(): ?array
{
$tenant = $this->filteredTenant();
if (! $tenant instanceof ManagedEnvironment) {
return null;
}
return [
'label' => (string) $tenant->name,
'clear_url' => static::getUrl(panel: 'admin'),
];
}
/**
* @return array<int, ManagedEnvironment>
*/
@ -584,22 +604,30 @@ private function tenantFilterOptions(): array
private function applyRequestedTenantPrefilter(): void
{
$requestedTenant = request()->query('tenant');
$workspace = $this->currentWorkspace();
if (! is_string($requestedTenant) && ! is_numeric($requestedTenant)) {
if (! $workspace instanceof Workspace) {
return;
}
$filter = WorkspaceHubEnvironmentFilter::fromRequest(request(), $workspace);
if (! $filter instanceof WorkspaceHubEnvironmentFilter) {
return;
}
$environmentId = $filter->environmentId();
foreach ($this->authorizedTenants() as $tenant) {
if ((string) $tenant->getKey() !== (string) $requestedTenant && (string) $tenant->external_id !== (string) $requestedTenant) {
continue;
if ((int) $tenant->getKey() === $environmentId) {
$this->tableFilters['managed_environment_id']['value'] = (string) $environmentId;
$this->tableDeferredFilters['managed_environment_id']['value'] = (string) $environmentId;
return;
}
$this->tableFilters['managed_environment_id']['value'] = (string) $tenant->getKey();
$this->tableDeferredFilters['managed_environment_id']['value'] = (string) $tenant->getKey();
return;
}
throw new NotFoundHttpException;
}
private function filteredTenant(): ?ManagedEnvironment
@ -655,7 +683,7 @@ private function queueUrl(array $overrides = []): string
$parameters = array_merge(
$this->navigationContext()?->toQuery() ?? [],
[
'tenant' => $this->filteredTenant()?->getKey(),
'environment_id' => $this->filteredTenant()?->getKey(),
'exception' => $this->selectedFindingExceptionId,
],
$overrides,
@ -667,6 +695,17 @@ private function queueUrl(array $overrides = []): string
);
}
private function currentWorkspace(): ?Workspace
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if (! is_int($workspaceId)) {
return null;
}
return Workspace::query()->whereKey($workspaceId)->first();
}
private function navigationContext(): ?CanonicalNavigationContext
{
return CanonicalNavigationContext::fromRequest(request());

View File

@ -6,12 +6,16 @@
use App\Filament\Resources\OperationRunResource;
use App\Filament\Widgets\Operations\OperationsKpiHeader;
use App\Models\OperationRun;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\ManagedEnvironmentAccessScopeResolver;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Filament\CanonicalAdminTenantFilterState;
use App\Support\ManagedEnvironmentLinks;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\Navigation\WorkspaceHubEnvironmentFilter;
use App\Support\OperateHub\OperateHubShell;
use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome;
@ -24,9 +28,6 @@
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
use App\Support\Workspaces\WorkspaceContext;
use App\Models\User;
use App\Services\Auth\ManagedEnvironmentAccessScopeResolver;
use App\Services\Auth\WorkspaceCapabilityResolver;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Facades\Filament;
@ -49,7 +50,7 @@ class Operations extends Page implements HasForms, HasTable
'surfaceType' => 'simple_monitoring',
'stateFields' => [
[
'stateKey' => 'managed_environment_id',
'stateKey' => 'environment_id',
'stateClass' => 'contextual_prefilter',
'carrier' => 'query_param',
'queryRole' => 'durable_restorable',
@ -58,16 +59,6 @@ class Operations extends Page implements HasForms, HasTable
'tenantSensitive' => true,
'invalidFallback' => 'discard_and_continue',
],
[
'stateKey' => 'tenant_scope',
'stateClass' => 'contextual_prefilter',
'carrier' => 'query_param',
'queryRole' => 'durable_restorable',
'shareable' => true,
'restorableOnRefresh' => true,
'tenantSensitive' => true,
'invalidFallback' => 'reset_to_default_scope',
],
[
'stateKey' => 'problemClass',
'stateClass' => 'contextual_prefilter',
@ -103,7 +94,7 @@ class Operations extends Page implements HasForms, HasTable
'precedenceOrder' => ['query', 'session', 'default'],
'appliesOnInitialMountOnly' => true,
'activeStateBecomesAuthoritativeAfterMount' => true,
'clearsOnTenantSwitch' => ['managed_environment_id', 'type', 'initiator_name'],
'clearsOnTenantSwitch' => ['environment_id', 'managed_environment_id', 'type', 'initiator_name'],
'invalidRequestedStateFallback' => 'discard_and_continue',
],
'inspectContract' => [
@ -114,7 +105,7 @@ class Operations extends Page implements HasForms, HasTable
'shareable' => false,
'invalidSelectionFallback' => 'discard_and_continue',
],
'shareableStateKeys' => ['managed_environment_id', 'tenant_scope', 'problemClass', 'activeTab'],
'shareableStateKeys' => ['environment_id', 'problemClass', 'activeTab'],
'localOnlyStateKeys' => [],
];
@ -209,15 +200,16 @@ public function mount(): void
$this->navigationContextPayload = is_array(request()->query('nav')) ? request()->query('nav') : null;
$this->applyRequestedTenantScope();
app(CanonicalAdminTenantFilterState::class)->sync(
$this->getTableFiltersSessionKey(),
['type', 'initiator_name'],
request(),
);
if (! request()->query->has('environment_id')) {
app(CanonicalAdminTenantFilterState::class)
->forgetEnvironmentLikeFilters($this->getTableFiltersSessionKey(), request());
}
$this->mountInteractsWithTable();
if (! request()->query->has('environment_id')) {
$this->tableFilters['managed_environment_id']['value'] = null;
$this->tableDeferredFilters['managed_environment_id']['value'] = null;
}
$this->applyRequestedDashboardPrefilter();
}
@ -243,7 +235,9 @@ protected function getHeaderActions(): array
->disabled(),
];
$activeTenant = $operateHubShell->activeEntitledTenant(request());
$activeTenant = $this->currentTenantFilterId() === null
? $operateHubShell->activeEntitledTenant(request())
: null;
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
$actions[] = Action::make('operate_hub_back_to_origin_operations')
@ -292,7 +286,10 @@ public function landingHierarchySummary(): array
{
$operateHubShell = app(OperateHubShell::class);
$navigationContext = $this->navigationContext();
$activeTenant = $operateHubShell->activeEntitledTenant(request());
$filteredTenant = $this->filteredTenant();
$activeTenant = $filteredTenant instanceof ManagedEnvironment
? null
: $operateHubShell->activeEntitledTenant(request());
$returnLabel = null;
$returnBody = null;
@ -307,9 +304,11 @@ public function landingHierarchySummary(): array
return [
'scope_label' => $operateHubShell->scopeLabel(request()),
'scope_body' => $activeTenant instanceof ManagedEnvironment
'scope_body' => $filteredTenant instanceof ManagedEnvironment
? 'The landing is workspace-scoped and filtered by an explicit environment filter.'
: ($activeTenant instanceof ManagedEnvironment
? 'The landing is currently narrowed to one environment inside the active workspace.'
: 'The landing is currently showing workspace-wide monitoring across all entitled environments.',
: 'The landing is currently showing workspace-wide monitoring across all entitled environments.'),
'return_label' => $returnLabel,
'return_body' => $returnBody,
'scope_reset_label' => $activeTenant instanceof ManagedEnvironment ? __('localization.shell.show_all_environments') : null,
@ -330,6 +329,25 @@ public function tabUrl(string $tab): string
]);
}
/**
* @return array{label: string, clear_url: string}|null
*/
public function environmentFilterChip(): ?array
{
$tenant = $this->filteredTenant();
if (! $tenant instanceof ManagedEnvironment) {
return null;
}
return [
'label' => (string) $tenant->name,
'clear_url' => route('admin.operations.index', [
'workspace' => app(WorkspaceContext::class)->currentWorkspace(request()),
]),
];
}
private function navigationContext(): ?CanonicalNavigationContext
{
if (! is_array($this->navigationContextPayload)) {
@ -384,17 +402,6 @@ function (Builder $query) use ($allowedTenantIds): Builder {
});
}
private function applyRequestedTenantScope(): void
{
if (! $this->shouldForceWorkspaceWideTenantScope()) {
return;
}
Filament::setTenant(null, true);
app(WorkspaceContext::class)->clearLastTenantId(request());
}
/**
* @return array{likely_stale:int,reconciled:int}
*/
@ -477,10 +484,18 @@ function (Builder $query) use ($allowedTenantIds): Builder {
private function applyRequestedDashboardPrefilter(): void
{
if (! $this->shouldForceWorkspaceWideTenantScope()) {
$requestedTenantId = $this->normalizeEntitledTenantFilter(request()->query('managed_environment_id'));
$workspace = app(WorkspaceContext::class)->currentWorkspace(request());
if ($workspace instanceof Workspace) {
$filter = WorkspaceHubEnvironmentFilter::fromRequest(request(), $workspace);
if ($filter instanceof WorkspaceHubEnvironmentFilter) {
$requestedTenantId = $this->normalizeEntitledTenantFilter($filter->environmentId());
if ($requestedTenantId === null) {
abort(404);
}
if ($requestedTenantId !== null) {
$tenantId = (string) $requestedTenantId;
$this->tableFilters['managed_environment_id']['value'] = $tenantId;
$this->tableDeferredFilters['managed_environment_id']['value'] = $tenantId;
@ -502,19 +517,13 @@ private function applyRequestedDashboardPrefilter(): void
}
}
private function shouldForceWorkspaceWideTenantScope(): bool
{
return request()->query('tenant_scope') === 'all';
}
private function operationsUrl(array $overrides = []): string
{
$parameters = array_merge(
['workspace' => app(WorkspaceContext::class)->currentWorkspace(request())],
$this->navigationContext()?->toQuery() ?? [],
[
'tenant_scope' => $this->shouldForceWorkspaceWideTenantScope() ? 'all' : null,
'managed_environment_id' => $this->shouldForceWorkspaceWideTenantScope() ? null : $this->currentTenantFilterId(),
'environment_id' => $this->currentTenantFilterId(),
'activeTab' => $this->activeTab !== 'all' ? $this->activeTab : null,
'problemClass' => in_array($this->activeTab, self::problemClassTabs(), true) ? $this->activeTab : null,
],
@ -538,6 +547,23 @@ private function currentTenantFilterId(): ?int
return $this->normalizeEntitledTenantFilter($tenantFilter);
}
private function filteredTenant(): ?ManagedEnvironment
{
$tenantId = $this->currentTenantFilterId();
if (! is_int($tenantId)) {
return null;
}
foreach ($this->authorizedTenants() as $tenant) {
if ((int) $tenant->getKey() === $tenantId) {
return $tenant;
}
}
return null;
}
/**
* Null means inherited access to all environments in the workspace.
*
@ -594,6 +620,25 @@ private function authorizedTenantIds(): array
->all();
}
/**
* @return array<int, ManagedEnvironment>
*/
private function authorizedTenants(): array
{
$user = auth()->user();
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if (! $user instanceof User || ! is_int($workspaceId)) {
return [];
}
return collect($user->getTenants(Filament::getCurrentOrDefaultPanel()))
->filter(static fn (ManagedEnvironment $tenant): bool => (int) $tenant->workspace_id === $workspaceId)
->filter(static fn (ManagedEnvironment $tenant): bool => $tenant->isActive())
->values()
->all();
}
/**
* @return list<string>
*/

View File

@ -5,11 +5,11 @@
namespace App\Filament\Pages\Reviews;
use App\Filament\Resources\EnvironmentReviewResource;
use App\Models\EnvironmentReview;
use App\Models\EvidenceSnapshot;
use App\Models\FindingException;
use App\Models\ReviewPack;
use App\Models\ManagedEnvironment;
use App\Models\EnvironmentReview;
use App\Models\ReviewPack;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Audit\WorkspaceAuditLogger;
@ -17,13 +17,14 @@
use App\Services\ReviewPackService;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\Capabilities;
use App\Support\Findings\FindingOutcomeSemantics;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\Governance\Controls\ComplianceEvidenceMappingV1;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\ReviewPackStatus;
use App\Support\EnvironmentReviewCompletenessState;
use App\Support\Filament\CanonicalAdminTenantFilterState;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\Findings\FindingOutcomeSemantics;
use App\Support\Governance\Controls\ComplianceEvidenceMappingV1;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\Navigation\WorkspaceHubEnvironmentFilter;
use App\Support\ReviewPackStatus;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
@ -99,12 +100,8 @@ public function getTitle(): string
public static function tenantPrefilterUrl(ManagedEnvironment $tenant): string
{
$tenantIdentifier = filled($tenant->external_id)
? (string) $tenant->external_id
: (string) $tenant->getKey();
return static::getUrl(panel: 'admin').'?'.http_build_query([
'tenant' => $tenantIdentifier,
'environment_id' => (int) $tenant->getKey(),
]);
}
@ -116,8 +113,10 @@ public static function tenantPrefilterUrl(ManagedEnvironment $tenant): string
public function mount(): void
{
$this->authorizePageAccess();
app(CanonicalAdminTenantFilterState::class)
->forgetEnvironmentLikeFiltersForCleanWorkspaceHubEntry($this->getTableFiltersSessionKey(), request());
if (! request()->query->has('environment_id')) {
app(CanonicalAdminTenantFilterState::class)
->forgetEnvironmentLikeFilters($this->getTableFiltersSessionKey(), request());
}
$this->applyRequestedTenantPrefilter();
$this->mountInteractsWithTable();
$this->auditWorkspaceOpen();
@ -160,7 +159,7 @@ public function table(Table $table): Table
->persistSortInSession()
->recordUrl(null)
->columns([
TextColumn::make('name')->label(__('localization.review.tenant'))->searchable(),
TextColumn::make('name')->label('Environment')->searchable(),
TextColumn::make('package_availability')
->label(__('localization.review.governance_package'))
->width('9rem')
@ -196,7 +195,7 @@ public function table(Table $table): Table
])
->filters([
SelectFilter::make('managed_environment_id')
->label(__('localization.review.tenant'))
->label('Environment')
->options(fn (): array => $this->tenantFilterOptions())
->default(fn (): ?string => $this->defaultTenantFilter())
->query(function (Builder $query, array $data): Builder {
@ -243,15 +242,24 @@ public function authorizedTenants(): array
public function activeEnvironmentFilterLabel(): ?string
{
$tenantId = $this->currentTenantFilterId();
return $this->filteredTenant()?->name;
}
if ($tenantId === null) {
/**
* @return array{label: string, clear_url: string}|null
*/
public function environmentFilterChip(): ?array
{
$tenant = $this->filteredTenant();
if (! $tenant instanceof ManagedEnvironment) {
return null;
}
$tenant = $this->authorizedTenants()[$tenantId] ?? null;
return $tenant instanceof ManagedEnvironment ? $tenant->name : null;
return [
'label' => (string) $tenant->name,
'clear_url' => static::getUrl(panel: 'admin'),
];
}
/**
@ -390,23 +398,29 @@ private function defaultTenantFilter(): ?string
private function applyRequestedTenantPrefilter(): void
{
$requestedTenant = request()->query('tenant', request()->query('managed_environment_id'));
$workspace = $this->workspace();
if (! is_string($requestedTenant) && ! is_numeric($requestedTenant)) {
if (! $workspace instanceof Workspace) {
return;
}
foreach ($this->authorizedTenants() as $tenant) {
if ((string) $tenant->getKey() !== (string) $requestedTenant && (string) $tenant->external_id !== (string) $requestedTenant) {
continue;
}
$this->tableFilters['managed_environment_id']['value'] = (string) $tenant->getKey();
$this->tableDeferredFilters['managed_environment_id']['value'] = (string) $tenant->getKey();
$filter = WorkspaceHubEnvironmentFilter::fromRequest(request(), $workspace);
if (! $filter instanceof WorkspaceHubEnvironmentFilter) {
return;
}
$environmentId = $filter->environmentId();
foreach ($this->authorizedTenants() as $tenant) {
if ((int) $tenant->getKey() === $environmentId) {
$this->tableFilters['managed_environment_id']['value'] = (string) $environmentId;
$this->tableDeferredFilters['managed_environment_id']['value'] = (string) $environmentId;
return;
}
}
throw new NotFoundHttpException;
}
@ -472,6 +486,23 @@ private function currentTenantFilterId(): ?int
return is_numeric($tenantFilter) ? (int) $tenantFilter : null;
}
private function filteredTenant(): ?ManagedEnvironment
{
$tenantId = $this->currentTenantFilterId();
if (! is_int($tenantId)) {
return null;
}
foreach ($this->authorizedTenants() as $tenant) {
if ((int) $tenant->getKey() === $tenantId) {
return $tenant;
}
}
return null;
}
private function workspace(): ?Workspace
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
@ -1347,13 +1378,7 @@ private function visibleInterpretationVersions(): array
private function currentTenantFilterInterpretationVersion(): ?string
{
$tenantId = $this->currentTenantFilterId();
if ($tenantId === null) {
return null;
}
$tenant = $this->authorizedTenants()[$tenantId] ?? null;
$tenant = $this->filteredTenant();
if (! $tenant instanceof ManagedEnvironment) {
return null;

View File

@ -5,29 +5,30 @@
namespace App\Filament\Pages\Reviews;
use App\Filament\Resources\EnvironmentReviewResource;
use App\Models\ManagedEnvironment;
use App\Models\EnvironmentReview;
use App\Models\ManagedEnvironment;
use App\Models\User;
use App\Models\Workspace;
use App\Services\ReviewPackService;
use App\Services\EnvironmentReviews\EnvironmentReviewRegisterService;
use App\Services\ReviewPackService;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Findings\FindingOutcomeSemantics;
use App\Support\EnvironmentReviewCompletenessState;
use App\Support\Filament\CanonicalAdminTenantFilterState;
use App\Support\Filament\FilterPresets;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\EnvironmentReviewCompletenessState;
use App\Support\Findings\FindingOutcomeSemantics;
use App\Support\Navigation\WorkspaceHubEnvironmentFilter;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
use App\Support\Ui\GovernanceArtifactTruth\CompressedGovernanceOutcome;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use App\Support\Ui\GovernanceArtifactTruth\CompressedGovernanceOutcome;
use App\Support\Ui\GovernanceArtifactTruth\SurfaceCompressionContext;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
@ -79,11 +80,10 @@ public function mount(): void
{
$this->authorizePageAccess();
app(CanonicalAdminTenantFilterState::class)->sync(
$this->getTableFiltersSessionKey(),
['status', 'published_state', 'completeness_state'],
request(),
);
if (! request()->query->has('environment_id')) {
app(CanonicalAdminTenantFilterState::class)
->forgetEnvironmentLikeFilters($this->getTableFiltersSessionKey(), request());
}
$this->applyRequestedTenantPrefilter();
$this->mountInteractsWithTable();
@ -114,7 +114,7 @@ public function table(Table $table): Table
->persistSortInSession()
->recordUrl(fn (EnvironmentReview $record): string => EnvironmentReviewResource::tenantScopedUrl('view', ['record' => $record], $record->tenant))
->columns([
TextColumn::make('tenant.name')->label('ManagedEnvironment')->searchable(),
TextColumn::make('tenant.name')->label('Environment')->searchable(),
TextColumn::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EnvironmentReviewStatus))
@ -139,7 +139,7 @@ public function table(Table $table): Table
])
->filters([
SelectFilter::make('managed_environment_id')
->label('ManagedEnvironment')
->label('Environment')
->options(fn (): array => $this->tenantFilterOptions())
->default(fn (): ?string => $this->defaultTenantFilter())
->searchable(),
@ -228,6 +228,23 @@ public function authorizedTenants(): array
return $this->authorizedTenants = app(EnvironmentReviewRegisterService::class)->authorizedTenants($user, $workspace);
}
/**
* @return array{label: string, clear_url: string}|null
*/
public function environmentFilterChip(): ?array
{
$tenant = $this->filteredTenant();
if (! $tenant instanceof ManagedEnvironment) {
return null;
}
return [
'label' => (string) $tenant->name,
'clear_url' => static::getUrl(panel: 'admin'),
];
}
private function authorizePageAccess(): void
{
$user = auth()->user();
@ -283,22 +300,30 @@ private function defaultTenantFilter(): ?string
private function applyRequestedTenantPrefilter(): void
{
$requestedTenant = request()->query('tenant');
$workspace = $this->workspace();
if (! is_string($requestedTenant) && ! is_numeric($requestedTenant)) {
if (! $workspace instanceof Workspace) {
return;
}
$filter = WorkspaceHubEnvironmentFilter::fromRequest(request(), $workspace);
if (! $filter instanceof WorkspaceHubEnvironmentFilter) {
return;
}
$environmentId = $filter->environmentId();
foreach ($this->authorizedTenants() as $tenant) {
if ((string) $tenant->getKey() !== (string) $requestedTenant && (string) $tenant->external_id !== (string) $requestedTenant) {
continue;
if ((int) $tenant->getKey() === $environmentId) {
$this->tableFilters['managed_environment_id']['value'] = (string) $environmentId;
$this->tableDeferredFilters['managed_environment_id']['value'] = (string) $environmentId;
return;
}
$this->tableFilters['managed_environment_id']['value'] = (string) $tenant->getKey();
$this->tableDeferredFilters['managed_environment_id']['value'] = (string) $tenant->getKey();
return;
}
throw new NotFoundHttpException;
}
private function hasActiveFilters(): bool
@ -327,6 +352,23 @@ private function currentTenantFilterId(): ?int
return is_numeric($tenantFilter) ? (int) $tenantFilter : null;
}
private function filteredTenant(): ?ManagedEnvironment
{
$tenantId = $this->currentTenantFilterId();
if (! is_int($tenantId)) {
return null;
}
foreach ($this->authorizedTenants() as $tenant) {
if ((int) $tenant->getKey() === $tenantId) {
return $tenant;
}
}
return null;
}
private function workspace(): ?Workspace
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());

View File

@ -4,9 +4,9 @@
use App\Filament\Support\VerificationReportChangeIndicator;
use App\Filament\Support\VerificationReportViewer;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Models\RestoreRun;
use App\Models\ManagedEnvironment;
use App\Models\User;
use App\Models\VerificationCheckAcknowledgement;
use App\Support\Badges\BadgeCatalog;
@ -41,9 +41,9 @@
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
use App\Support\Ui\GovernanceArtifactTruth\CompressedGovernanceOutcome;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use App\Support\Ui\GovernanceArtifactTruth\CompressedGovernanceOutcome;
use App\Support\Ui\GovernanceArtifactTruth\SurfaceCompressionContext;
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
use App\Support\Workspaces\WorkspaceContext;
@ -176,7 +176,7 @@ public static function table(Table $table): Table
])
->filters([
Tables\Filters\SelectFilter::make('managed_environment_id')
->label('ManagedEnvironment')
->label('Environment')
->options(function (): array {
$user = auth()->user();

View File

@ -6,9 +6,9 @@
use App\Filament\Resources\ProviderConnectionResource\Pages;
use App\Jobs\ProviderComplianceSnapshotJob;
use App\Jobs\ProviderInventorySyncJob;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\ManagedEnvironment;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\ManagedEnvironmentAccessScopeResolver;
@ -21,11 +21,11 @@
use App\Support\Badges\BadgeRenderer;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\ManagedEnvironmentLinks;
use App\Support\Navigation\WorkspaceHubEnvironmentFilter;
use App\Support\OperationRunLinks;
use App\Support\OperationRunType;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
use App\Support\Providers\ProviderConnectionType;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Providers\ProviderVerificationStatus;
@ -181,15 +181,26 @@ public static function resolveTenantForRecord(?ProviderConnection $record = null
public static function resolveRequestedTenantExternalId(): ?string
{
$queryTenant = request()->query('managed_environment_id');
$environment = static::resolveRequestedEnvironment();
if (is_string($queryTenant) && $queryTenant !== '') {
return $queryTenant;
if ($environment instanceof ManagedEnvironment) {
return (string) $environment->slug;
}
return static::resolveTenantExternalIdFromLivewireRequest();
}
public static function resolveRequestedEnvironment(): ?ManagedEnvironment
{
$workspace = app(WorkspaceContext::class)->currentWorkspace(request());
if (! $workspace instanceof \App\Models\Workspace) {
return null;
}
return WorkspaceHubEnvironmentFilter::fromRequest(request(), $workspace)?->environment();
}
public static function resolveContextTenantExternalId(): ?string
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
@ -202,14 +213,14 @@ public static function resolveContextTenantExternalId(): ?string
->first();
if ($tenant instanceof ManagedEnvironment) {
return (string) $tenant->external_id;
return (string) $tenant->slug;
}
}
$tenant = static::resolveTenantContextForCurrentPanel();
if ($tenant instanceof ManagedEnvironment) {
return (string) $tenant->external_id;
return (string) $tenant->slug;
}
return null;
@ -278,10 +289,15 @@ private static function extractTenantExternalIdFromUrl(string $url): ?string
if (is_string($query) && $query !== '') {
parse_str($query, $queryParams);
$tenantExternalId = $queryParams['managed_environment_id'] ?? null;
$tenantExternalId = $queryParams['environment_id'] ?? null;
if (is_string($tenantExternalId) && $tenantExternalId !== '') {
return $tenantExternalId;
if (is_numeric($tenantExternalId)) {
$tenant = ManagedEnvironment::query()
->whereKey((int) $tenantExternalId)
->where('workspace_id', app(WorkspaceContext::class)->currentWorkspaceId(request()))
->first();
return $tenant instanceof ManagedEnvironment ? (string) $tenant->slug : null;
}
}
@ -382,7 +398,7 @@ private static function tenantFilterOptions(): array
$environment = strtoupper((string) ($tenant->environment ?? ''));
$label = $environment !== '' ? "{$tenant->name} ({$environment})" : (string) $tenant->name;
return [(string) $tenant->external_id => $label];
return [(string) $tenant->slug => $label];
})
->all();
}
@ -802,7 +818,7 @@ public static function table(Table $table): Table
->recordUrl(fn (ProviderConnection $record): string => static::getUrl('view', ['record' => $record]))
->columns([
Tables\Columns\TextColumn::make('tenant.name')
->label('ManagedEnvironment')
->label('Environment')
->description(function (ProviderConnection $record): ?string {
$environment = $record->tenant?->environment;
@ -890,7 +906,7 @@ public static function table(Table $table): Table
])
->filters([
SelectFilter::make('tenant')
->label('ManagedEnvironment')
->label('Environment')
->default(static::resolveRequestedTenantExternalId())
->options(static::tenantFilterOptions())
->query(function (Builder $query, array $data): Builder {
@ -1633,7 +1649,7 @@ public static function getPages(): array
private static function normalizeTenantExternalId(mixed $tenant): ?string
{
if ($tenant instanceof ManagedEnvironment) {
return (string) $tenant->external_id;
return (string) $tenant->slug;
}
if (is_string($tenant) && $tenant !== '') {
@ -1644,7 +1660,7 @@ private static function normalizeTenantExternalId(mixed $tenant): ?string
$tenantModel = ManagedEnvironment::query()->whereKey((int) $tenant)->first();
if ($tenantModel instanceof ManagedEnvironment) {
return (string) $tenantModel->external_id;
return (string) $tenantModel->slug;
}
}
@ -1666,23 +1682,27 @@ public static function getUrl(?string $name = null, array $parameters = [], bool
}
if ($tenantExternalId === null && $tenant instanceof ManagedEnvironment) {
$tenantExternalId = (string) $tenant->external_id;
$tenantExternalId = (string) $tenant->slug;
}
if ($tenantExternalId === null) {
$record = $parameters['record'] ?? null;
if ($record instanceof ProviderConnection) {
$tenantExternalId = static::resolveTenantForRecord($record)?->external_id;
$tenantExternalId = static::resolveTenantForRecord($record)?->slug;
}
}
if ($tenantExternalId === null && ! $isIndexUrl) {
$tenantExternalId = static::resolveScopedTenant()?->external_id;
$tenantExternalId = static::resolveScopedTenant()?->slug;
}
if (! array_key_exists('managed_environment_id', $parameters) && is_string($tenantExternalId) && $tenantExternalId !== '') {
$parameters['managed_environment_id'] = $tenantExternalId;
if (! array_key_exists('environment_id', $parameters) && is_string($tenantExternalId) && $tenantExternalId !== '') {
$environment = static::resolveTenantByExternalId($tenantExternalId);
if ($environment instanceof ManagedEnvironment) {
$parameters['environment_id'] = (int) $environment->getKey();
}
}
return parent::getUrl($name, $parameters, $isAbsolute, $panel, null, $shouldGuessMissingParameters);

View File

@ -3,29 +3,19 @@
namespace App\Filament\Resources\ProviderConnectionResource\Pages;
use App\Filament\Resources\ProviderConnectionResource;
use App\Jobs\ProviderComplianceSnapshotJob;
use App\Jobs\ProviderInventorySyncJob;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\ManagedEnvironment;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\Intune\AuditLogger;
use App\Services\Providers\ProviderConnectionMutationService;
use App\Services\Providers\ProviderOperationStartGate;
use App\Services\Verification\StartVerification;
use App\Support\Auth\Capabilities;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Providers\ProviderConnectionType;
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor;
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
use App\Support\Rbac\UiEnforcement;
use Filament\Actions;
use Filament\Actions\Action;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Validation\ValidationException;
@ -49,15 +39,19 @@ public function mount($record): void
: null;
if ($recordTenant instanceof ManagedEnvironment) {
$this->scopedTenantExternalId = (string) $recordTenant->external_id;
$this->scopedTenantExternalId = (string) $recordTenant->slug;
return;
}
$tenantIdFromQuery = request()->query('managed_environment_id');
$tenantIdFromQuery = request()->query('environment_id');
if (is_string($tenantIdFromQuery) && $tenantIdFromQuery !== '') {
$this->scopedTenantExternalId = $tenantIdFromQuery;
if (is_numeric($tenantIdFromQuery)) {
$environment = ManagedEnvironment::query()->whereKey((int) $tenantIdFromQuery)->first();
if ($environment instanceof ManagedEnvironment) {
$this->scopedTenantExternalId = (string) $environment->slug;
}
return;
}
@ -65,7 +59,7 @@ public function mount($record): void
$tenant = request()->route('tenant');
if ($tenant instanceof ManagedEnvironment) {
$this->scopedTenantExternalId = (string) $tenant->external_id;
$this->scopedTenantExternalId = (string) $tenant->slug;
return;
}

View File

@ -10,6 +10,11 @@
use App\Support\Filament\CanonicalAdminTenantFilterState;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
use Filament\Schemas\Components\EmbeddedTable;
use Filament\Schemas\Components\RenderHook;
use Filament\Schemas\Components\View;
use Filament\Schemas\Schema;
use Filament\View\PanelsRenderHook;
class ListProviderConnections extends ListRecords
{
@ -17,12 +22,10 @@ class ListProviderConnections extends ListRecords
public function mount(): void
{
app(CanonicalAdminTenantFilterState::class)->sync(
$this->getTableFiltersSessionKey(),
request: request(),
tenantFilterName: 'tenant',
tenantAttribute: 'external_id',
);
if (! request()->query->has('environment_id')) {
app(CanonicalAdminTenantFilterState::class)
->forgetEnvironmentLikeFilters($this->getTableFiltersSessionKey(), request());
}
parent::mount();
}
@ -37,88 +40,107 @@ protected function getHeaderActions(): array
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
return [
Actions\CreateAction::make()
->label('New connection')
->url(function (): string {
$tenantExternalId = $this->resolveTenantExternalIdForCreateAction();
$actions = [];
if (! is_string($tenantExternalId) || $tenantExternalId === '') {
return ProviderConnectionResource::getUrl('create');
}
$actions[] = Actions\CreateAction::make()
->label('New connection')
->url(function (): string {
$tenant = $this->resolveTenantForCreateAction();
return ProviderConnectionResource::getUrl('create', [
'managed_environment_id' => $tenantExternalId,
]);
})
->visible(function () use ($resolver): bool {
if (! $this->tableHasRecords()) {
return false;
}
if (! $tenant instanceof ManagedEnvironment) {
return ProviderConnectionResource::getUrl('create');
}
$tenant = $this->resolveTenantForCreateAction();
$user = auth()->user();
return ProviderConnectionResource::getUrl('create', [
'environment_id' => (int) $tenant->getKey(),
]);
})
->visible(function () use ($resolver): bool {
if (! $this->tableHasRecords()) {
return false;
}
if (! $tenant instanceof ManagedEnvironment) {
return true;
}
$tenant = $this->resolveTenantForCreateAction();
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
if (! $tenant instanceof ManagedEnvironment) {
return true;
}
return $resolver->isMember($user, $tenant);
})
->disabled(function () use ($resolver): bool {
$tenant = $this->resolveTenantForCreateAction();
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
if (! $tenant instanceof ManagedEnvironment) {
return true;
}
return $resolver->isMember($user, $tenant);
})
->disabled(function () use ($resolver): bool {
$tenant = $this->resolveTenantForCreateAction();
$user = auth()->user();
if (! $user instanceof User) {
return true;
}
if (! $tenant instanceof ManagedEnvironment) {
return true;
}
if (! $resolver->isMember($user, $tenant)) {
return true;
}
if (! $user instanceof User) {
return true;
}
return ! $resolver->can($user, $tenant, Capabilities::PROVIDER_MANAGE);
})
->tooltip(function () use ($resolver): ?string {
$tenant = $this->resolveTenantForCreateAction();
if (! $resolver->isMember($user, $tenant)) {
return true;
}
if (! $tenant instanceof ManagedEnvironment) {
return 'Select a tenant to create provider connections.';
}
return ! $resolver->can($user, $tenant, Capabilities::PROVIDER_MANAGE);
})
->tooltip(function () use ($resolver): ?string {
$tenant = $this->resolveTenantForCreateAction();
$user = auth()->user();
if (! $tenant instanceof ManagedEnvironment) {
return 'Select an environment to create provider connections.';
}
if (! $user instanceof User) {
return null;
}
if (! $resolver->isMember($user, $tenant)) {
return null;
}
if (! $resolver->can($user, $tenant, Capabilities::PROVIDER_MANAGE)) {
return 'You do not have permission to create provider connections.';
}
$user = auth()->user();
if (! $user instanceof User) {
return null;
})
->authorize(function () use ($resolver): bool {
$tenant = $this->resolveTenantForCreateAction();
$user = auth()->user();
}
return $tenant instanceof ManagedEnvironment
&& $user instanceof User
&& $resolver->isMember($user, $tenant);
}),
];
if (! $resolver->isMember($user, $tenant)) {
return null;
}
if (! $resolver->can($user, $tenant, Capabilities::PROVIDER_MANAGE)) {
return 'You do not have permission to create provider connections.';
}
return null;
})
->authorize(function () use ($resolver): bool {
$tenant = $this->resolveTenantForCreateAction();
$user = auth()->user();
return $tenant instanceof ManagedEnvironment
&& $user instanceof User
&& $resolver->isMember($user, $tenant);
});
return $actions;
}
public function content(Schema $schema): Schema
{
return $schema
->components([
$this->getTabsContentComponent(),
View::make('filament.partials.workspace-hub-environment-filter-chip')
->viewData(fn (): array => [
'label' => $this->environmentFilterChip()['label'] ?? null,
'clearUrl' => $this->environmentFilterChip()['clear_url'] ?? null,
])
->visible(fn (): bool => $this->environmentFilterChip() !== null),
RenderHook::make(PanelsRenderHook::RESOURCE_PAGES_LIST_RECORDS_TABLE_BEFORE),
EmbeddedTable::make(),
RenderHook::make(PanelsRenderHook::RESOURCE_PAGES_LIST_RECORDS_TABLE_AFTER),
]);
}
private function makeEmptyStateCreateAction(): Actions\CreateAction
@ -129,14 +151,14 @@ private function makeEmptyStateCreateAction(): Actions\CreateAction
return Actions\CreateAction::make()
->label('New connection')
->url(function (): string {
$tenantExternalId = $this->resolveTenantExternalIdForCreateAction();
$tenant = $this->resolveTenantForCreateAction();
if (! is_string($tenantExternalId) || $tenantExternalId === '') {
if (! $tenant instanceof ManagedEnvironment) {
return ProviderConnectionResource::getUrl('create');
}
return ProviderConnectionResource::getUrl('create', [
'managed_environment_id' => $tenantExternalId,
'environment_id' => (int) $tenant->getKey(),
]);
})
->visible(function () use ($resolver): bool {
@ -175,7 +197,7 @@ private function makeEmptyStateCreateAction(): Actions\CreateAction
$tenant = $this->resolveTenantForCreateAction();
if (! $tenant instanceof ManagedEnvironment) {
return 'Select a tenant to create provider connections.';
return 'Select an environment to create provider connections.';
}
$user = auth()->user();
@ -212,14 +234,30 @@ private function resolveTenantExternalIdForCreateAction(): ?string
return $filterValue;
}
$requested = ProviderConnectionResource::resolveRequestedTenantExternalId()
?? ProviderConnectionResource::resolveContextTenantExternalId();
$requested = ProviderConnectionResource::resolveRequestedTenantExternalId();
if (is_string($requested) && $requested !== '') {
return $requested;
}
return ProviderConnectionResource::resolveContextTenantExternalId();
return null;
}
/**
* @return array{label: string, clear_url: string}|null
*/
private function environmentFilterChip(): ?array
{
$environment = ProviderConnectionResource::resolveRequestedEnvironment();
if (! $environment instanceof ManagedEnvironment) {
return null;
}
return [
'label' => (string) $environment->name,
'clear_url' => ProviderConnectionResource::getUrl('index', panel: 'admin'),
];
}
private function resolveTenantForCreateAction(): ?ManagedEnvironment

View File

@ -9,6 +9,7 @@
use App\Models\WorkspaceMembership;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Support\Audit\AuditActionId;
use App\Support\Navigation\WorkspaceHubRegistry;
use App\Support\Workspaces\WorkspaceContext;
use App\Support\Workspaces\WorkspaceIntendedUrl;
use App\Support\Workspaces\WorkspaceRedirectResolver;
@ -209,6 +210,10 @@ private function isChooserFirstPath(string $path): bool
private function requestHasExplicitTenantContext(Request $request): bool
{
if (WorkspaceHubRegistry::isWorkspaceHubPath('/'.ltrim((string) $request->path(), '/'))) {
return false;
}
if (filled($request->query('tenant')) || filled($request->query('managed_environment_id'))) {
return true;
}

View File

@ -2,8 +2,8 @@
namespace App\Policies;
use App\Models\ProviderConnection;
use App\Models\ManagedEnvironment;
use App\Models\ProviderConnection;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\ManagedEnvironmentAccessScopeResolver;
@ -237,9 +237,9 @@ private function currentWorkspace(User $user): ?Workspace
private function resolveCreateTenant(Workspace $workspace): ?ManagedEnvironment
{
$tenantExternalId = request()->query('managed_environment_id');
$requestedEnvironmentId = request()->query('environment_id');
if (! is_string($tenantExternalId) || $tenantExternalId === '') {
if (! is_numeric($requestedEnvironmentId)) {
$lastTenantId = app(WorkspaceContext::class)->lastTenantId(request());
if (is_int($lastTenantId)) {
@ -259,7 +259,7 @@ private function resolveCreateTenant(Workspace $workspace): ?ManagedEnvironment
}
return ManagedEnvironment::query()
->where('slug', $tenantExternalId)
->whereKey((int) $requestedEnvironmentId)
->where('workspace_id', (int) $workspace->getKey())
->first();
}

View File

@ -73,8 +73,8 @@ public static function accessScopesUrl(ManagedEnvironment $environment, array $q
*/
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;
if ($environment instanceof ManagedEnvironment && ! array_key_exists('environment_id', $query)) {
$query['environment_id'] = (int) $environment->getKey();
}
return ProviderConnectionResource::getUrl('index', $query, panel: 'admin');
@ -89,8 +89,8 @@ public static function providerConnectionUrl(
?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;
if ($environment instanceof ManagedEnvironment && ! array_key_exists('environment_id', $query)) {
$query['environment_id'] = (int) $environment->getKey();
}
$query['record'] = $connection;
@ -103,8 +103,8 @@ public static function providerConnectionUrl(
*/
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();
if ($scope instanceof ManagedEnvironment && ! array_key_exists('environment_id', $query)) {
$query['environment_id'] = (int) $scope->getKey();
}
$workspace = self::workspaceFromScope($scope);

View File

@ -5,6 +5,7 @@
use App\Models\ManagedEnvironment;
use App\Models\User;
use App\Support\Navigation\NavigationScope;
use App\Support\Navigation\WorkspaceHubRegistry;
use App\Support\Navigation\WorkspaceSidebarNavigation;
use App\Support\OperateHub\OperateHubShell;
use App\Support\Tenants\TenantPageCategory;
@ -179,6 +180,10 @@ private function isCanonicalWorkspaceRecordViewerPath(string $path): bool
private function requestHasExplicitTenantHint(Request $request): bool
{
if (WorkspaceHubRegistry::isWorkspaceHubPath('/'.ltrim((string) $request->path(), '/'))) {
return false;
}
return filled($request->query('tenant')) || filled($request->query('managed_environment_id'));
}

View File

@ -53,7 +53,7 @@ public static function fromRequest(Request $request): ?self
return null;
}
$tenantId = $payload['managed_environment_id'] ?? null;
$tenantId = $payload['environment_id'] ?? null;
return new self(
sourceSurface: $sourceSurface,
@ -163,7 +163,7 @@ private function navPayload(): array
return array_filter([
'source_surface' => $this->sourceSurface,
'canonical_route_name' => $this->canonicalRouteName,
'managed_environment_id' => $this->tenantId,
'environment_id' => $this->tenantId,
'family_key' => $this->familyKey,
'back_label' => $this->backLinkLabel,
'back_url' => $this->backLinkUrl,

View File

@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace App\Support\Navigation;
use App\Models\ManagedEnvironment;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final readonly class WorkspaceHubEnvironmentFilter
{
private function __construct(
private ManagedEnvironment $environment,
) {}
public static function fromRequest(Request $request, Workspace $workspace): ?self
{
if (! $request->query->has('environment_id')) {
return null;
}
$environmentId = $request->query('environment_id');
if (is_array($environmentId) || filter_var($environmentId, FILTER_VALIDATE_INT) === false) {
throw new NotFoundHttpException;
}
$environmentId = (int) $environmentId;
$environment = ManagedEnvironment::query()
->whereKey($environmentId)
->where('workspace_id', $workspace->getKey())
->first();
if (! $environment instanceof ManagedEnvironment) {
throw new NotFoundHttpException;
}
$user = $request->user();
if ($user instanceof User && ! $user->canAccessTenant($environment)) {
throw new NotFoundHttpException;
}
return new self($environment);
}
public function environment(): ManagedEnvironment
{
return $this->environment;
}
public function environmentId(): int
{
return (int) $this->environment->getKey();
}
public function displayName(): string
{
return (string) ($this->environment->name ?: $this->environment->external_id ?: ('Environment '.$this->environmentId()));
}
/**
* @template TModel of \Illuminate\Database\Eloquent\Model
*
* @param Builder<TModel> $query
* @return Builder<TModel>
*/
public function applyToQuery(Builder $query, string $column = 'managed_environment_id'): Builder
{
return $query->where($column, $this->environmentId());
}
public function queryParameters(): array
{
return [
'environment_id' => $this->environmentId(),
];
}
public function clearUrl(string $url): string
{
return WorkspaceHubRegistry::cleanUrl($url);
}
}

View File

@ -16,6 +16,7 @@ final class WorkspaceHubRegistry
'tenant_id',
'managed_environment_id',
'environment_id',
'environment',
'tenant_scope',
'tableFilters',
];
@ -36,11 +37,7 @@ final class WorkspaceHubRegistry
* @var list<string>
*/
private const ENVIRONMENT_FILTER_QUERY_KEYS = [
'tenant',
'tenant_id',
'managed_environment_id',
'environment_id',
'environment',
];
/**

View File

@ -10,6 +10,7 @@
use App\Services\Auth\CapabilityResolver;
use App\Services\Tenants\TenantOperabilityService;
use App\Support\ManagedEnvironmentLinks;
use App\Support\Navigation\WorkspaceHubRegistry;
use App\Support\Tenants\TenantOperabilityQuestion;
use App\Support\Tenants\TenantPageCategory;
use App\Support\Workspaces\WorkspaceContext;
@ -367,6 +368,10 @@ private function resolveRouteTenantCandidate(?Request $request = null, ?TenantPa
private function resolveQueryTenantHint(?Request $request = null): ?ManagedEnvironment
{
if ($request instanceof Request && WorkspaceHubRegistry::isWorkspaceHubPath('/'.ltrim((string) $request->path(), '/'))) {
return null;
}
$queryTenant = $request?->query('tenant');
if (filled($queryTenant)) {
@ -384,6 +389,10 @@ private function resolveQueryTenantHint(?Request $request = null): ?ManagedEnvir
private function hasExplicitQueryTenantHint(?Request $request = null): bool
{
if ($request instanceof Request && WorkspaceHubRegistry::isWorkspaceHubPath('/'.ltrim((string) $request->path(), '/'))) {
return false;
}
return filled($request?->query('tenant')) || filled($request?->query('managed_environment_id'));
}

View File

@ -7,17 +7,17 @@
use App\Filament\Resources\BackupSetResource;
use App\Filament\Resources\BaselineSnapshotResource;
use App\Filament\Resources\EntraGroupResource;
use App\Filament\Resources\EnvironmentReviewResource;
use App\Filament\Resources\EvidenceSnapshotResource;
use App\Filament\Resources\InventoryItemResource;
use App\Filament\Resources\PolicyResource;
use App\Filament\Resources\RestoreRunResource;
use App\Filament\Resources\ReviewPackResource;
use App\Filament\Resources\EnvironmentReviewResource;
use App\Models\EnvironmentReview;
use App\Models\EvidenceSnapshot;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Models\ReviewPack;
use App\Models\ManagedEnvironment;
use App\Models\EnvironmentReview;
use App\Models\Workspace;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\Workspaces\WorkspaceContext;
@ -98,9 +98,7 @@ public static function index(
$parameters['workspace'] = $workspace;
if ($tenant instanceof ManagedEnvironment) {
$parameters['managed_environment_id'] = (int) $tenant->getKey();
} elseif ($allTenants) {
$parameters['tenant_scope'] = 'all';
$parameters['environment_id'] = (int) $tenant->getKey();
}
if (is_string($activeTab) && $activeTab !== '') {

View File

@ -110,7 +110,7 @@ private function resolveSafeIntendedUrl(string $intendedUrl, Workspace $workspac
return null;
}
$tenantIdentifier = $query['tenant'] ?? $query['managed_environment_id'] ?? null;
$tenantIdentifier = $query['environment_id'] ?? $query['tenant'] ?? $query['managed_environment_id'] ?? null;
if ($tenantIdentifier !== null && ! $this->tenantIdentifierMatchesWorkspace((string) $tenantIdentifier, $workspace, $user)) {
return null;

View File

@ -38,11 +38,18 @@
@if (filled($scope['tenant_label'] ?? null))
<span class="inline-flex items-center rounded-full bg-warning-50 px-3 py-1 text-xs font-medium text-warning-700 dark:bg-warning-500/10 dark:text-warning-300">
ManagedEnvironment: {{ $scope['tenant_label'] }}
Environment: {{ $scope['tenant_label'] }}
</span>
@endif
</div>
@if ($this->hasTenantPrefilter())
@include('filament.partials.workspace-hub-environment-filter-chip', [
'label' => $scope['tenant_label'] ?? null,
'clearUrl' => $this->pageUrl(['environment_id' => null, 'register_state' => 'open']),
])
@endif
<div class="flex flex-wrap gap-2">
@foreach ($registerStates as $registerState)
<a
@ -55,15 +62,6 @@ class="inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm fo
@endforeach
</div>
@if ($this->hasTenantPrefilter())
<div class="flex flex-wrap items-center gap-3 text-sm text-gray-600 dark:text-gray-300">
<span>The register is currently filtered to one environment.</span>
<a href="{{ $this->pageUrl(['tenant' => null]) }}" class="font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300">
Clear environment filter
</a>
</div>
@endif
</div>
</x-filament::section>

View File

@ -39,11 +39,18 @@
@if (filled($scope['tenant_label'] ?? null))
<span class="inline-flex items-center rounded-full bg-warning-50 px-3 py-1 text-xs font-medium text-warning-700 dark:bg-warning-500/10 dark:text-warning-300">
ManagedEnvironment: {{ $scope['tenant_label'] }}
Environment: {{ $scope['tenant_label'] }}
</span>
@endif
</div>
@if ($this->hasTenantPrefilter())
@include('filament.partials.workspace-hub-environment-filter-chip', [
'label' => $scope['tenant_label'] ?? null,
'clearUrl' => $this->pageUrl(['environment_id' => null, 'family' => null]),
])
@endif
<div class="flex flex-wrap gap-2">
<a
href="{{ $this->pageUrl(['family' => null]) }}"
@ -64,15 +71,6 @@ class="inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm fo
@endforeach
</div>
@if ($this->hasTenantPrefilter())
<div class="flex flex-wrap items-center gap-3 text-sm text-gray-600 dark:text-gray-300">
<span>The inbox is currently filtered to one environment.</span>
<a href="{{ $this->pageUrl(['tenant' => null]) }}" class="font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300">
Clear environment filter
</a>
</div>
@endif
</div>
</x-filament::section>

View File

@ -1,9 +1,17 @@
<x-filament-panels::page>
@php($environmentFilterChip = $this->environmentFilterChip())
<div class="space-y-6">
<x-filament::section>
<div class="flex flex-col gap-2 text-sm text-gray-600 dark:text-gray-300">
<p>ManagedEnvironment and search query seeds can reopen this overview in a specific monitoring slice.</p>
<p>Compatible filters and sorting still restore from the last session, but row inspection always leaves the page for the canonical evidence detail.</p>
<div class="flex flex-col gap-3 text-sm text-gray-600 dark:text-gray-300">
<p>This workspace evidence overview stays workspace-scoped; environment-owned entries appear as an explicit page filter.</p>
@if ($environmentFilterChip !== null)
@include('filament.partials.workspace-hub-environment-filter-chip', [
'label' => $environmentFilterChip['label'],
'clearUrl' => $environmentFilterChip['clear_url'],
])
@endif
</div>
</x-filament::section>

View File

@ -1,5 +1,6 @@
<x-filament-panels::page>
@php($selectedException = $this->selectedFindingException())
@php($environmentFilterChip = $this->environmentFilterChip())
<x-filament::section>
<div class="flex flex-col gap-3">
@ -14,6 +15,13 @@
<div class="text-sm text-gray-600 dark:text-gray-300">
The focused review lane is bound to the <span class="font-mono text-xs">exception</span> query parameter. If that exception drops out of the current queue view, the page falls back to quiet monitoring mode without stale decision state.
</div>
@if ($environmentFilterChip !== null)
@include('filament.partials.workspace-hub-environment-filter-chip', [
'label' => $environmentFilterChip['label'],
'clearUrl' => $environmentFilterChip['clear_url'],
])
@endif
</div>
</x-filament::section>

View File

@ -1,5 +1,6 @@
<x-filament-panels::page>
@php($landingHierarchy = $this->landingHierarchySummary())
@php($environmentFilterChip = $this->environmentFilterChip())
@php($lifecycleSummary = $this->lifecycleVisibilitySummary())
@php($staleAttentionTab = \App\Models\OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION)
@php($terminalFollowUpTab = \App\Models\OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP)
@ -38,6 +39,15 @@
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $landingHierarchy['inspect_body'] }}</p>
</div>
</div>
@if ($environmentFilterChip !== null)
<div class="mt-4">
@include('filament.partials.workspace-hub-environment-filter-chip', [
'label' => $environmentFilterChip['label'],
'clearUrl' => $environmentFilterChip['clear_url'],
])
</div>
@endif
</x-filament::section>
<x-filament::tabs label="Operations tabs">
@ -100,7 +110,7 @@
</x-filament::tabs>
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
ManagedEnvironment prefilters and the selected operations tab remain shareable through the URL. Additional table filters still restore from the last compatible session state.
Environment filters and the selected operations tab remain shareable through the URL. Additional table filters still restore from the last compatible session state.
</p>
@if (($lifecycleSummary['likely_stale'] ?? 0) > 0 || ($lifecycleSummary['reconciled'] ?? 0) > 0)
@ -112,4 +122,3 @@
{{ $this->table }}
</x-filament-panels::page>

View File

@ -1,6 +1,6 @@
<x-filament-panels::page>
@php
$activeEnvironmentFilterLabel = $this->activeEnvironmentFilterLabel();
$environmentFilterChip = $this->environmentFilterChip();
$reviewPayload = $this->latestReviewConsumptionPayload();
@endphp
@ -22,10 +22,11 @@
{{ __('localization.review.customer_workspace_non_certification_disclosure') }}
</div>
@if ($activeEnvironmentFilterLabel)
<div class="text-sm font-medium text-gray-700 dark:text-gray-200">
{{ __('localization.review.filtered_by_environment', ['environment' => $activeEnvironmentFilterLabel]) }}
</div>
@if ($environmentFilterChip !== null)
@include('filament.partials.workspace-hub-environment-filter-chip', [
'label' => $environmentFilterChip['label'],
'clearUrl' => $environmentFilterChip['clear_url'],
])
@endif
</div>
</x-filament::section>

View File

@ -1,4 +1,6 @@
<x-filament-panels::page>
@php($environmentFilterChip = $this->environmentFilterChip())
<x-filament::section>
<div class="flex flex-col gap-3">
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">
@ -6,12 +8,19 @@
</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
Review draft, published, archived, and superseded environment reviews across the tenants you are entitled to manage.
Review draft, published, archived, and superseded environment reviews across the environments you are entitled to manage.
</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
Opening a row returns to the tenant-scoped detail surface so executive review history stays tenant-safe and audit-friendly.
Opening a row returns to the environment detail surface so executive review history stays environment-safe and audit-friendly.
</div>
@if ($environmentFilterChip !== null)
@include('filament.partials.workspace-hub-environment-filter-chip', [
'label' => $environmentFilterChip['label'],
'clearUrl' => $environmentFilterChip['clear_url'],
])
@endif
</div>
</x-filament::section>

View File

@ -0,0 +1,27 @@
@props([
'label',
'clearUrl',
'description' => null,
])
@if (filled($label) && filled($clearUrl))
<div
data-testid="workspace-hub-environment-filter-chip"
class="flex flex-wrap items-center gap-2 rounded-lg border border-primary-200 bg-primary-50 px-3 py-2 text-sm text-primary-900 dark:border-primary-700/60 dark:bg-primary-950/30 dark:text-primary-100"
>
<span class="font-medium">Environment filter:</span>
<span>{{ $label }}</span>
@if (filled($description))
<span class="text-primary-700 dark:text-primary-300">{{ $description }}</span>
@endif
<a
href="{{ $clearUrl }}"
class="ms-auto text-sm font-medium text-primary-700 underline-offset-4 hover:underline dark:text-primary-300"
data-testid="workspace-hub-environment-filter-clear"
>
Clear filter
</a>
</div>
@endif

View File

@ -3,11 +3,10 @@
declare(strict_types=1);
use App\Filament\Resources\ManagedEnvironmentResource;
use App\Filament\Resources\ManagedEnvironmentResource\Pages\ViewManagedEnvironment;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\ManagedEnvironment;
use App\Models\ManagedEnvironmentOnboardingSession;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\User;
use App\Models\Workspace;
use App\Support\OperationRunLinks;
@ -67,7 +66,7 @@
->assertSee(OperationRunLinks::openCollectionLabel())
->assertSee('Review operation')
->assertDontSee('Start verification')
->assertScript("Array.from(document.querySelectorAll('a[href*=\"/operations\"]')).some((element) => element.textContent?.includes('Open operations') && element.href.includes('managed_environment_id='))", true);
->assertScript("Array.from(document.querySelectorAll('a[href*=\"/operations\"]')).some((element) => element.textContent?.includes('Open operations') && element.href.includes('environment_id='))", true);
visit($operationsIndexUrl)
->assertNoJavaScriptErrors()

View File

@ -12,8 +12,8 @@
use App\Models\EvidenceSnapshot;
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\OperationRun;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Support\Evidence\EvidenceCompletenessState;
use App\Support\Evidence\EvidenceSnapshotStatus;
use App\Support\OperationRunOutcome;
@ -96,11 +96,11 @@
visit(route('admin.operations.index', [
'workspace' => $tenant->workspace,
'managed_environment_id' => (int) $tenant->getKey(),
'environment_id' => (int) $tenant->getKey(),
'activeTab' => 'active',
]))
->waitForText('Monitoring landing')
->assertSee('ManagedEnvironment prefilters and the selected operations tab remain shareable through the URL.')
->assertSee('Environment filters and the selected operations tab remain shareable through the URL.')
->assertSee('Open run detail')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
@ -120,10 +120,10 @@
->assertNoConsoleLogs();
visit(route('admin.evidence.overview', [
'managed_environment_id' => (int) $tenant->getKey(),
'environment_id' => (int) $tenant->getKey(),
'search' => $tenant->name,
]))
->waitForText('ManagedEnvironment and search query seeds can reopen this overview in a specific monitoring slice.')
->waitForText('This workspace evidence overview stays workspace-scoped; environment-owned entries appear as an explicit page filter.')
->assertSee($tenant->name)
->assertSee('Clear filters')
->assertNoJavaScriptErrors()

View File

@ -101,7 +101,7 @@ function spec265SmokeLoginUrl(User $user, ManagedEnvironment $tenant, string $re
]);
$decisionRegisterUrl = DecisionRegister::getUrl(panel: 'admin', parameters: [
'managed_environment_id' => (string) $tenant->getKey(),
'environment_id' => (string) $tenant->getKey(),
]);
visit(spec265SmokeLoginUrl($user, $tenant))
@ -113,7 +113,7 @@ function spec265SmokeLoginUrl(User $user, ManagedEnvironment $tenant, string $re
->waitForText('Decision register')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs()
->assertSee('The register is currently filtered to one environment.')
->assertSee('Environment filter:')
->assertSee($tenant->name)
->assertSee('Visible rows: 2')
->assertSee('1 proof item')
@ -147,7 +147,7 @@ function spec265SmokeLoginUrl(User $user, ManagedEnvironment $tenant, string $re
->waitForText('Decision register')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs()
->assertSee('The register is currently filtered to one environment.')
->assertSee('Environment filter:')
->assertSee($tenant->name)
->assertSee('Visible rows: 2');
});

View File

@ -99,7 +99,7 @@
->assertSee('Open run detail')
->assertSee('Spec 280 Production')
->assertScript("window.location.pathname.includes('/admin/workspaces/{$workspace->getRouteKey()}/operations')", true)
->assertScript("window.location.search.includes('managed_environment_id={$tenant->getKey()}')", true)
->assertScript("window.location.search.includes('environment_id={$tenant->getKey()}')", true)
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
});
});

View File

@ -4,8 +4,8 @@
use App\Filament\Resources\ProviderConnectionResource;
use App\Models\ManagedEnvironment;
use App\Models\ProviderConnection;
use App\Models\ManagedEnvironmentOnboardingSession;
use App\Models\ProviderConnection;
use App\Support\ManagedEnvironmentLinks;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
@ -58,13 +58,13 @@
$providerConnectionViewPath = (string) parse_url(ProviderConnectionResource::getUrl('view', [
'record' => $connection,
'managed_environment_id' => $tenant->external_id,
'environment_id' => (int) $tenant->getKey(),
], panel: 'admin'), PHP_URL_PATH);
$tenantViewPath = (string) parse_url(ManagedEnvironmentLinks::viewUrl($tenant), PHP_URL_PATH);
visit(ProviderConnectionResource::getUrl('view', [
'record' => $connection,
'managed_environment_id' => $tenant->external_id,
'environment_id' => (int) $tenant->getKey(),
], panel: 'admin'))
->waitForText('Spec 281 Browser Connection')
->assertScript("window.location.pathname === '{$providerConnectionViewPath}'", true)
@ -83,7 +83,7 @@
->assertNoConsoleLogs();
visit(ManagedEnvironmentLinks::viewUrl($tenant))
->waitForText('Provider connection')
->waitForText('Provider Health')
->assertScript("window.location.pathname === '{$tenantViewPath}'", true)
->assertSee('Spec 281 Browser Connection')
->assertSee('Spec 281 Browser Environment')

View File

@ -104,13 +104,13 @@
->waitForText('Monitoring landing')
->assertSee('Spec 300 Production')
->assertScript("window.location.pathname === '{$operationsPath}'", true)
->assertScript("window.location.search.includes('managed_environment_id={$environment->getKey()}')", true)
->assertScript("window.location.search.includes('environment_id={$environment->getKey()}')", true)
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
visit(ProviderConnectionResource::getUrl('view', [
'record' => $connection,
'managed_environment_id' => $environment->external_id,
'environment_id' => (int) $environment->getKey(),
], panel: 'admin'))
->waitForText('Spec 300 Microsoft Connection')
->assertScript("window.location.pathname === '{$providerConnectionPath}'", true)

View File

@ -12,7 +12,7 @@ final class KpiHeaderTenantlessTest extends TestCase
{
use RefreshDatabase;
public function test_hides_operations_kpi_stats_when_tenant_context_is_absent(): void
public function test_shows_workspace_operations_kpi_stats_when_environment_shell_context_is_absent(): void
{
[$user, $tenant] = createUserWithTenant(role: 'owner');
@ -27,9 +27,9 @@ public function test_hides_operations_kpi_stats_when_tenant_context_is_absent():
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(\App\Support\OperationRunLinks::index())
->assertOk()
->assertDontSee('Total Operations (30 days)')
->assertDontSee('Active Operations')
->assertDontSee('Failed/Partial (7 days)')
->assertDontSee('Avg Duration (7 days)');
->assertSee('Total Operations (30 days)')
->assertSee('Active Operations')
->assertSee('Failed/Partial (7 days)')
->assertSee('Avg Duration (7 days)');
}
}

View File

@ -2,8 +2,8 @@
declare(strict_types=1);
use App\Models\OperationRun;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Workspaces\WorkspaceContext;
@ -14,7 +14,7 @@ final class CanonicalOperationViewerContextMismatchTest extends TestCase
{
use RefreshDatabase;
public function test_shows_non_blocking_mismatch_context_when_the_selected_tenant_differs_from_the_run_tenant(): void
public function test_keeps_canonical_run_viewer_workspace_scoped_when_selected_tenant_differs_from_the_run_tenant(): void
{
$runTenant = ManagedEnvironment::factory()->create([
'name' => 'Run ManagedEnvironment',
@ -43,10 +43,10 @@ public function test_shows_non_blocking_mismatch_context_when_the_selected_tenan
->withSession([WorkspaceContext::SESSION_KEY => (int) $runTenant->workspace_id])
->get(\App\Support\OperationRunLinks::tenantlessView($run))
->assertOk()
->assertSee('Current environment context differs from this operation')
->assertSee('Current environment context: Current ManagedEnvironment.')
->assertSee('Operation environment: Run ManagedEnvironment.')
->assertSee('canonical workspace view');
->assertSee('Canonical workspace view')
->assertDontSee('Current environment context differs from this operation')
->assertDontSee('Current environment context: Current ManagedEnvironment.')
->assertSee('Operation environment: Run ManagedEnvironment.');
}
public function test_frames_tenantless_runs_as_workspace_level_even_when_tenant_context_is_selected(): void
@ -71,7 +71,8 @@ public function test_frames_tenantless_runs_as_workspace_level_even_when_tenant_
->get(\App\Support\OperationRunLinks::tenantlessView($run))
->assertOk()
->assertSee('Workspace-level operation')
->assertSee('This canonical workspace view is not tied to the current environment context (Selected ManagedEnvironment).');
->assertSee('This canonical workspace view is not tied to any environment.')
->assertDontSee('This canonical workspace view is not tied to the current environment context (Selected ManagedEnvironment).');
}
public function test_keeps_onboarding_tenant_runs_viewable_with_lifecycle_aware_context(): void

View File

@ -2,10 +2,10 @@
declare(strict_types=1);
use App\Models\OperationRun;
use App\Models\ManagedEnvironment;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Models\OperationRun;
use App\Support\ManagedEnvironmentLinks;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\OperationRunLinks;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
@ -51,7 +51,8 @@ public function test_trusts_canonical_run_links_opened_from_a_tenant_surface_aft
->assertOk()
->assertSee('Back to tenant')
->assertSee(ManagedEnvironmentLinks::viewUrl($runTenant), false)
->assertSee('Current environment context differs from this operation');
->assertSee('Canonical workspace view')
->assertDontSee('Current environment context differs from this operation');
}
public function test_trusts_notification_style_run_links_with_no_selected_tenant_context(): void
@ -99,7 +100,7 @@ public function test_uses_canonical_collection_link_for_default_back_and_show_al
->get(OperationRunLinks::tenantlessView($run))
->assertOk()
->assertSee('Back to Operations')
->assertSee('Show all operations')
->assertDontSee('Show all operations')
->assertSee(OperationRunLinks::index(), false);
}

View File

@ -7,14 +7,14 @@
use App\Filament\Resources\FindingResource;
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\OperationRun;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Services\Intune\ManagedEnvironmentRequiredPermissionsViewModelBuilder;
use App\Support\EnvironmentDashboard\EnvironmentDashboardSummaryBuilder;
use App\Support\Links\RequiredPermissionsLinks;
use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\EnvironmentDashboard\EnvironmentDashboardSummaryBuilder;
use function Pest\Laravel\mock;
@ -42,7 +42,7 @@ function mockEnvironmentDashboardActionPermissions(array $overview = []): void
*/
function tenantDashboardButtonClassesForXPath(string $content, string $xpathExpression): array
{
$dom = new \DOMDocument();
$dom = new \DOMDocument;
libxml_use_internal_errors(true);
$dom->loadHTML($content);
@ -69,7 +69,7 @@ function tenantDashboardButtonClassesForXPath(string $content, string $xpathExpr
expect(OperationRunLinks::index($tenant, activeTab: 'active'))
->toBe(route('admin.operations.index', [
'workspace' => $tenant->workspace,
'managed_environment_id' => (int) $tenant->getKey(),
'environment_id' => (int) $tenant->getKey(),
'activeTab' => 'active',
]))
->and(OperationRunLinks::index(
@ -79,7 +79,7 @@ function tenantDashboardButtonClassesForXPath(string $content, string $xpathExpr
))
->toBe(route('admin.operations.index', [
'workspace' => $tenant->workspace,
'managed_environment_id' => (int) $tenant->getKey(),
'environment_id' => (int) $tenant->getKey(),
'activeTab' => OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
'problemClass' => OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
]));

View File

@ -2,17 +2,17 @@
declare(strict_types=1);
use App\Filament\Resources\FindingResource;
use App\Filament\Pages\Governance\GovernanceInbox;
use App\Filament\Pages\EnvironmentDashboard;
use App\Filament\Pages\Governance\GovernanceInbox;
use App\Filament\Resources\FindingResource;
use App\Models\Finding;
use App\Models\ManagedEnvironment;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\Intune\ManagedEnvironmentRequiredPermissionsViewModelBuilder;
use App\Support\Auth\Capabilities;
use App\Support\Links\RequiredPermissionsLinks;
use App\Support\EnvironmentDashboard\EnvironmentDashboardSummaryBuilder;
use App\Support\Links\RequiredPermissionsLinks;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Illuminate\Support\Facades\Gate;
@ -26,7 +26,7 @@
*/
function tenantDashboardGovernanceStatusRows(string $content): array
{
$dom = new \DOMDocument();
$dom = new \DOMDocument;
libxml_use_internal_errors(true);
$dom->loadHTML($content);
@ -194,7 +194,7 @@ function tenantDashboardProductizationHeaderMoreActionNames(Testable $component)
->toBeInstanceOf(Action::class)
->and($primaryAction->getLabel())->toBe('Open governance inbox')
->and($primaryAction->getUrl())->toBe(GovernanceInbox::getUrl(panel: 'admin').'?'.http_build_query([
'managed_environment_id' => (int) $tenant->getKey(),
'environment_id' => (int) $tenant->getKey(),
]));
});

View File

@ -47,7 +47,7 @@
->assertSee('Resolve the review blockers before publication');
});
it('prefilters the review register from a tenant query parameter and accepts external tenant identifiers', function (): void {
it('prefilters the review register from an explicit environment_id query parameter', function (): void {
$tenantA = ManagedEnvironment::factory()->create(['name' => 'Alpha ManagedEnvironment']);
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
@ -76,7 +76,7 @@
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
Livewire::withHeaders(['referer' => ReviewRegister::getUrl(panel: 'admin')])
->withQueryParams(['tenant' => (string) $tenantA->external_id])
->withQueryParams(['environment_id' => (int) $tenantA->getKey()])
->test(ReviewRegister::class)
->assertSet('tableFilters.managed_environment_id.value', (string) $tenantA->getKey())
->assertCanSeeTableRecords([$reviewA])

View File

@ -13,7 +13,7 @@
uses(BuildsGovernanceArtifactTruthFixtures::class);
it('lists only entitled environment reviews in the canonical review register and filters by tenant', function (): void {
it('lists only entitled environment reviews in the canonical review register and filters by environment', function (): void {
$tenantA = ManagedEnvironment::factory()->create(['name' => 'Alpha ManagedEnvironment']);
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
@ -69,7 +69,7 @@
->assertSee('Clear filters');
});
it('clears only the page-level tenant filter from the review register', function (): void {
it('clears only the page-level environment filter from the review register', function (): void {
$tenantA = ManagedEnvironment::factory()->create(['name' => 'Alpha ManagedEnvironment']);
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
@ -90,7 +90,7 @@
]);
$component = Livewire::withHeaders(['referer' => ReviewRegister::getUrl(panel: 'admin')])
->withQueryParams(['tenant' => (string) $tenantA->external_id])
->withQueryParams(['environment_id' => (int) $tenantA->getKey()])
->test(ReviewRegister::class)
->assertSet('tableFilters.managed_environment_id.value', (string) $tenantA->getKey())
->assertActionVisible('clear_filters')

View File

@ -92,7 +92,7 @@
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id])
->get(route('admin.evidence.overview', ['managed_environment_id' => (int) $tenantB->getKey()]))
->get(route('admin.evidence.overview', ['environment_id' => (int) $tenantB->getKey()]))
->assertOk()
->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshots[(int) $tenantB->getKey()]], tenant: $tenantB, panel: 'admin'), false)
->assertDontSee(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshots[(int) $tenantA->getKey()]], tenant: $tenantA, panel: 'admin'), false);
@ -158,7 +158,7 @@
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
$component = Livewire::withQueryParams([
'managed_environment_id' => (string) $tenantB->getKey(),
'environment_id' => (string) $tenantB->getKey(),
'search' => $tenantB->name,
])->test(EvidenceOverview::class);

View File

@ -13,7 +13,7 @@
function canonicalAdminTenantRequest(): Request
{
$request = Request::create('/admin');
$request = Request::create('/admin/non-hub-test');
$request->setLaravelSession(app('session.store'));
return $request;

View File

@ -4,16 +4,16 @@
use App\Filament\Resources\BackupScheduleResource\Pages\ListBackupSchedules;
use App\Filament\Resources\BackupSetResource\Pages\ListBackupSets;
use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections;
use App\Filament\Resources\ReviewPackResource\Pages\ListReviewPacks;
use App\Filament\Resources\RestoreRunResource\Pages\ListRestoreRuns;
use App\Filament\Resources\ManagedEnvironmentResource\Pages\ListManagedEnvironments;
use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections;
use App\Filament\Resources\RestoreRunResource\Pages\ListRestoreRuns;
use App\Filament\Resources\ReviewPackResource\Pages\ListReviewPacks;
use App\Filament\Resources\Workspaces\Pages\ListWorkspaces;
use App\Models\BackupSchedule;
use App\Models\BackupSet;
use App\Models\ProviderConnection;
use App\Models\ReviewPack;
use App\Models\RestoreRun;
use App\Models\ReviewPack;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
@ -453,7 +453,8 @@ function getPlacementEmptyStateAction(Testable $component, string $name): ?Actio
'workspace_id' => (int) $tenant->workspace_id,
]);
$component = Livewire::test(ListProviderConnections::class);
$component = Livewire::withQueryParams(['environment_id' => (int) $tenant->getKey()])
->test(ListProviderConnections::class);
$headerCreate = getHeaderAction($component, 'create');
expect($headerCreate)->not->toBeNull();

View File

@ -54,11 +54,11 @@
dataset('workspace surface paths with environment query hints', [
'/admin/workspaces/workspace-alpha/operations?tenant=environment-alpha',
'/admin/reviews/workspace?tenant=environment-alpha',
'/admin/governance/decisions?managed_environment_id=environment-alpha',
'/admin/governance/inbox?managed_environment_id=environment-alpha',
'/admin/evidence/overview?managed_environment_id=environment-alpha',
'/admin/governance/decisions?environment_id=123',
'/admin/governance/inbox?environment_id=123',
'/admin/evidence/overview?environment_id=123',
'/admin/audit-log?tenant=environment-alpha',
'/admin/provider-connections?managed_environment_id=environment-alpha',
'/admin/provider-connections?environment_id=123',
'/admin/alerts?tenant=environment-alpha',
'/admin/workspaces/workspace-alpha/overview?tenant=environment-alpha',
]);

View File

@ -42,5 +42,5 @@
$response->assertOk()
->assertSee('nav%5Bsource_surface%5D=finding.detail_section', false)
->assertSee('nav%5Bmanaged_environment_id%5D='.(int) $tenant->getKey(), false);
->assertSee('nav%5Benvironment_id%5D='.(int) $tenant->getKey(), false);
});

View File

@ -93,7 +93,7 @@
(string) $workspaceId => (int) $rememberedTenant->getKey(),
],
])
->get(route('admin.operations.index', ['workspace' => $workspaceId, 'managed_environment_id' => (int) $hintedTenant->getKey()]))
->get(route('admin.operations.index', ['workspace' => $workspaceId, 'environment_id' => (int) $hintedTenant->getKey()]))
->assertOk()
->assertSee(__('localization.shell.no_environment_selected'))
->assertSee(__('localization.shell.all_environments'))

View File

@ -2,9 +2,9 @@
declare(strict_types=1);
use App\Models\User;
use App\Filament\Pages\EnvironmentDashboard;
use App\Models\ManagedEnvironment;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Auth\CapabilityResolver;
@ -55,7 +55,7 @@
expect($overview['attention_items'])->toBe([])
->and($overview['calmness']['is_calm'])->toBeFalse()
->and($overview['calmness']['next_action']['kind'])->toBe('operations_index')
->and($overview['calmness']['next_action']['url'])->toContain('tenant_scope=all')
->and($overview['calmness']['next_action']['url'])->not->toContain('tenant_scope=')
->and($overview['calmness']['next_action']['url'])->toContain('activeTab=active');
});

View File

@ -2,21 +2,21 @@
declare(strict_types=1);
use App\Filament\Resources\ManagedEnvironmentResource\Pages\ListManagedEnvironments;
use App\Filament\Resources\EvidenceSnapshotResource;
use App\Filament\Resources\EnvironmentReviewResource;
use App\Filament\Resources\EvidenceSnapshotResource;
use App\Filament\Resources\ManagedEnvironmentResource\Pages\ListManagedEnvironments;
use App\Filament\Widgets\Dashboard\NeedsAttention as TenantNeedsAttention;
use App\Filament\Widgets\Workspace\WorkspaceNeedsAttention;
use App\Models\AlertDelivery;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\Tenants\TenantRecoveryTriagePresentation;
use App\Support\Workspaces\WorkspaceOverviewBuilder;
use App\Support\Workspaces\WorkspaceContext;
use App\Support\Workspaces\WorkspaceOverviewBuilder;
use Carbon\CarbonImmutable;
use Filament\Facades\Filament;
use Livewire\Livewire;
@ -129,7 +129,7 @@
->and($items->get('tenant_operations_terminal_follow_up')['destination']['kind'])->toBe('operations_index')
->and($items->get('tenant_operations_terminal_follow_up')['destination']['url'])->toContain('activeTab=terminal_follow_up')
->and($items->get('tenant_operations_terminal_follow_up')['destination']['url'])->toContain('problemClass=terminal_follow_up')
->and($items->get('tenant_operations_terminal_follow_up')['destination']['url'])->toContain('managed_environment_id='.(string) $tenantOperations->getKey())
->and($items->get('tenant_operations_terminal_follow_up')['destination']['url'])->toContain('environment_id='.(string) $tenantOperations->getKey())
->and($items->get('tenant_alert_delivery_failures')['destination']['kind'])->toBe('alerts_overview')
->and($items->get('tenant_alert_delivery_failures')['destination']['url'])->toContain('nav%5Bback_url%5D=');
});

View File

@ -100,7 +100,7 @@
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $visibleTenant->workspace_id])
->get(DecisionRegister::getUrl(panel: 'admin').'?managed_environment_id='.(string) $hiddenTenant->getKey())
->get(DecisionRegister::getUrl(panel: 'admin').'?environment_id='.(string) $hiddenTenant->getKey())
->assertNotFound();
});

View File

@ -124,7 +124,7 @@
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $alphaTenant->workspace_id])
->get(DecisionRegister::getUrl(panel: 'admin').'?managed_environment_id='.(string) $alphaTenant->getKey())
->get(DecisionRegister::getUrl(panel: 'admin').'?environment_id='.(string) $alphaTenant->getKey())
->assertOk()
->assertSee('This environment filter is hiding other visible decision follow-through')
->assertSee('Clear environment filter');

View File

@ -94,6 +94,6 @@
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $visibleTenant->workspace_id])
->get(GovernanceInbox::getUrl(panel: 'admin').'?managed_environment_id='.(string) $hiddenTenant->getKey())
->get(GovernanceInbox::getUrl(panel: 'admin').'?environment_id='.(string) $hiddenTenant->getKey())
->assertNotFound();
});
});

View File

@ -40,14 +40,14 @@
$response = $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(GovernanceInbox::getUrl(panel: 'admin').'?managed_environment_id='.(string) $tenant->getKey().'&family=finding_exceptions');
->get(GovernanceInbox::getUrl(panel: 'admin').'?environment_id='.(string) $tenant->getKey().'&family=finding_exceptions');
$response->assertOk()
->assertSee('Finding exceptions')
->assertSee('Open finding exceptions')
->assertSee('Governance convergence request')
->assertSee('nav%5Bfamily_key%5D=finding_exceptions', false)
->assertSee('nav%5Bback_url%5D='.urlencode(GovernanceInbox::getUrl(panel: 'admin').'?managed_environment_id='.(string) $tenant->getKey().'&family=finding_exceptions'), false)
->assertSee('nav%5Bback_url%5D='.urlencode(GovernanceInbox::getUrl(panel: 'admin').'?environment_id='.(string) $tenant->getKey().'&family=finding_exceptions'), false)
->assertSee('exception='.(string) $exception->getKey(), false)
->assertDontSee('Open my findings')
->assertDontSee('Open findings intake');

View File

@ -6,14 +6,14 @@
use App\Models\AlertDelivery;
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\OperationRun;
use App\Models\ManagedEnvironment;
use App\Models\ManagedEnvironmentTriageReview;
use App\Models\OperationRun;
use App\Support\BackupHealth\TenantBackupHealthResolver;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
use App\Support\PortfolioTriage\ManagedEnvironmentTriageReviewFingerprint;
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
use App\Support\Workspaces\WorkspaceContext;
it('renders visible governance attention sections on the governance inbox page', function (): void {
@ -153,14 +153,14 @@
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $alphaTenant->workspace_id])
->get(GovernanceInbox::getUrl(panel: 'admin').'?managed_environment_id='.(string) $alphaTenant->getKey())
->get(GovernanceInbox::getUrl(panel: 'admin').'?environment_id='.(string) $alphaTenant->getKey())
->assertOk()
->assertSee('This environment filter is hiding other visible attention')
->assertSee('Clear environment filter');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $alphaTenant->workspace_id])
->get(GovernanceInbox::getUrl(panel: 'admin').'?managed_environment_id='.(string) $alphaTenant->getKey().'&family=alert_delivery_failures')
->get(GovernanceInbox::getUrl(panel: 'admin').'?environment_id='.(string) $alphaTenant->getKey().'&family=alert_delivery_failures')
->assertOk()
->assertSee('Alert delivery failures')
->assertSee('No failed alert deliveries match this environment filter right now.')

View File

@ -47,7 +47,7 @@
->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(ManagedEnvironmentLinks::providerConnectionsUrl($tenant))->toContain('/admin/provider-connections?environment_id='.(int) $tenant->getKey())
->and(OperationRunLinks::index($tenant))->toContain('/admin/workspaces/')
->and(OperationRunLinks::tenantlessView($run))->toContain('/admin/workspaces/');
});
@ -60,4 +60,3 @@
expect(ManagedEnvironmentResource::getGlobalSearchResults((string) $tenant->name))->toHaveCount(0);
});

View File

@ -53,13 +53,13 @@
tenantId: (int) $tenant->getKey(),
familyKey: 'finding_exceptions',
backLinkUrl: GovernanceInbox::getUrl(panel: 'admin', parameters: [
'managed_environment_id' => (string) $tenant->getKey(),
'environment_id' => (string) $tenant->getKey(),
'family' => 'finding_exceptions',
]),
);
$component = Livewire::withQueryParams(array_replace($context->toQuery(), [
'tenant' => (string) $tenant->external_id,
'environment_id' => (int) $tenant->getKey(),
'exception' => (int) $exception->getKey(),
]))
->actingAs($user)
@ -76,10 +76,10 @@
expect($component->instance()->selectedExceptionUrl())
->toContain('nav%5Bsource_surface%5D=governance.inbox')
->toContain('nav%5Bfamily_key%5D=finding_exceptions')
->toContain('nav%5Bmanaged_environment_id%5D='.(string) $tenant->getKey());
->toContain('nav%5Benvironment_id%5D='.(string) $tenant->getKey());
expect($component->instance()->selectedFindingUrl())
->toContain('nav%5Bsource_surface%5D=governance.inbox')
->toContain('nav%5Bfamily_key%5D=finding_exceptions')
->toContain('nav%5Bmanaged_environment_id%5D='.(string) $tenant->getKey());
->toContain('nav%5Benvironment_id%5D='.(string) $tenant->getKey());
});

View File

@ -40,7 +40,7 @@ function monitoringPageStateFieldSummary(array $contract): array
[
'surfaceKey' => 'operations',
'surfaceType' => 'simple_monitoring',
'shareableStateKeys' => ['managed_environment_id', 'tenant_scope', 'problemClass', 'activeTab'],
'shareableStateKeys' => ['environment_id', 'problemClass', 'activeTab'],
'localOnlyStateKeys' => [],
'inspectContract' => [
'primaryModel' => 'none',
@ -49,8 +49,7 @@ function monitoringPageStateFieldSummary(array $contract): array
'shareable' => false,
],
'stateFields' => [
'managed_environment_id' => ['stateClass' => 'contextual_prefilter', 'queryRole' => 'durable_restorable', 'shareable' => true, 'restorableOnRefresh' => true],
'tenant_scope' => ['stateClass' => 'contextual_prefilter', 'queryRole' => 'durable_restorable', 'shareable' => true, 'restorableOnRefresh' => true],
'environment_id' => ['stateClass' => 'contextual_prefilter', 'queryRole' => 'durable_restorable', 'shareable' => true, 'restorableOnRefresh' => true],
'problemClass' => ['stateClass' => 'contextual_prefilter', 'queryRole' => 'scoped_deeplink', 'shareable' => true, 'restorableOnRefresh' => true],
'activeTab' => ['stateClass' => 'active', 'queryRole' => 'durable_restorable', 'shareable' => true, 'restorableOnRefresh' => true],
'tableFilters' => ['stateClass' => 'shareable_restorable', 'queryRole' => 'unsupported', 'shareable' => false, 'restorableOnRefresh' => true],
@ -83,7 +82,7 @@ function monitoringPageStateFieldSummary(array $contract): array
[
'surfaceKey' => 'finding_exceptions_queue',
'surfaceType' => 'selected_record_monitoring',
'shareableStateKeys' => ['tenant', 'exception'],
'shareableStateKeys' => ['environment_id', 'exception'],
'localOnlyStateKeys' => [],
'inspectContract' => [
'primaryModel' => FindingException::class,
@ -93,7 +92,7 @@ function monitoringPageStateFieldSummary(array $contract): array
],
'stateFields' => [
'exception' => ['stateClass' => 'inspect', 'queryRole' => 'durable_restorable', 'shareable' => true, 'restorableOnRefresh' => true],
'tenant' => ['stateClass' => 'contextual_prefilter', 'queryRole' => 'durable_restorable', 'shareable' => true, 'restorableOnRefresh' => true],
'environment_id' => ['stateClass' => 'contextual_prefilter', 'queryRole' => 'durable_restorable', 'shareable' => true, 'restorableOnRefresh' => true],
'tableFilters' => ['stateClass' => 'shareable_restorable', 'queryRole' => 'unsupported', 'shareable' => false, 'restorableOnRefresh' => true],
'tableSearch' => ['stateClass' => 'shareable_restorable', 'queryRole' => 'unsupported', 'shareable' => false, 'restorableOnRefresh' => true],
],
@ -104,7 +103,7 @@ function monitoringPageStateFieldSummary(array $contract): array
[
'surfaceKey' => 'evidence_overview',
'surfaceType' => 'simple_monitoring',
'shareableStateKeys' => ['managed_environment_id', 'search'],
'shareableStateKeys' => ['environment_id', 'search'],
'localOnlyStateKeys' => [],
'inspectContract' => [
'primaryModel' => 'none',
@ -113,7 +112,7 @@ function monitoringPageStateFieldSummary(array $contract): array
'shareable' => false,
],
'stateFields' => [
'managed_environment_id' => ['stateClass' => 'contextual_prefilter', 'queryRole' => 'durable_restorable', 'shareable' => true, 'restorableOnRefresh' => true],
'environment_id' => ['stateClass' => 'contextual_prefilter', 'queryRole' => 'durable_restorable', 'shareable' => true, 'restorableOnRefresh' => true],
'search' => ['stateClass' => 'contextual_prefilter', 'queryRole' => 'durable_restorable', 'shareable' => true, 'restorableOnRefresh' => true],
'tableFilters' => ['stateClass' => 'shareable_restorable', 'queryRole' => 'unsupported', 'shareable' => false, 'restorableOnRefresh' => true],
'tableSort' => ['stateClass' => 'shareable_restorable', 'queryRole' => 'unsupported', 'shareable' => false, 'restorableOnRefresh' => true],

View File

@ -31,7 +31,7 @@
run: $run,
reasonCode: ProviderReasonCodes::ProviderCredentialMissing,
nextSteps: [
['label' => 'Update Credentials', 'url' => '/admin/provider-connections?managed_environment_id=demo'],
['label' => 'Update Credentials', 'url' => '/admin/provider-connections?environment_id=123'],
['label' => '', 'url' => '/invalid'],
],
message: 'client_secret=super-secret',
@ -45,7 +45,7 @@
->and(data_get($finalized->context, 'reason_translation.operator_label'))->toBe('Credentials missing')
->and(data_get($finalized->context, 'reason_translation.short_explanation'))->toContain('credentials required to authenticate')
->and($finalized->context['next_steps'] ?? [])->toBe([
['label' => 'Update Credentials', 'url' => '/admin/provider-connections?managed_environment_id=demo'],
['label' => 'Update Credentials', 'url' => '/admin/provider-connections?environment_id=123'],
])
->and($finalized->failure_summary[0]['reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderCredentialMissing)
->and((string) ($finalized->failure_summary[0]['message'] ?? ''))->not->toContain('secret');

View File

@ -3,8 +3,8 @@
declare(strict_types=1);
use App\Filament\Pages\Monitoring\Operations;
use App\Models\OperationRun;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\OperationRunLinks;
use App\Support\Workspaces\WorkspaceContext;
@ -172,7 +172,7 @@
->assertSet('tableFilters.managed_environment_id.value', null);
Livewire::withHeaders(['referer' => route('admin.operations.index', ['workspace' => $tenantA->workspace])])
->withQueryParams(['managed_environment_id' => (string) $tenantA->getKey()])
->withQueryParams(['environment_id' => (int) $tenantA->getKey()])
->test(Operations::class)
->assertCanSeeTableRecords([$runA])
->assertCanNotSeeTableRecords([$runB])

View File

@ -3,12 +3,12 @@
declare(strict_types=1);
use App\Filament\Pages\Monitoring\Operations;
use App\Models\OperationRun;
use App\Models\ManagedEnvironment;
use App\Support\OpsUx\OperationRunUrl;
use App\Models\OperationRun;
use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OpsUx\OperationRunUrl;
use App\Support\Workspaces\WorkspaceContext;
use Livewire\Livewire;
@ -273,7 +273,7 @@
expect(OperationRunLinks::index($tenant, activeTab: 'active'))
->toBe(route('admin.operations.index', [
'workspace' => $tenant->workspace,
'managed_environment_id' => (int) $tenant->getKey(),
'environment_id' => (int) $tenant->getKey(),
'activeTab' => 'active',
]))
->and(OperationRunLinks::index(
@ -283,7 +283,7 @@
))
->toBe(route('admin.operations.index', [
'workspace' => $tenant->workspace,
'managed_environment_id' => (int) $tenant->getKey(),
'environment_id' => (int) $tenant->getKey(),
'activeTab' => OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
'problemClass' => OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
]))
@ -295,7 +295,6 @@
))
->toBe(route('admin.operations.index', [
'workspace' => $tenant->workspace,
'tenant_scope' => 'all',
'activeTab' => OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
'problemClass' => OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
]))

View File

@ -1,8 +1,8 @@
<?php
use App\Filament\Pages\Monitoring\Operations;
use App\Models\OperationRun;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
@ -43,7 +43,7 @@
Livewire::withHeaders(['referer' => route('admin.operations.index', ['workspace' => $tenantA->workspace])])
->actingAs($user)
->withQueryParams(['managed_environment_id' => (string) $tenantA->getKey()])
->withQueryParams(['environment_id' => (int) $tenantA->getKey()])
->test(Operations::class)
->assertCanSeeTableRecords([$runA])
->assertCanNotSeeTableRecords([$runB])

View File

@ -0,0 +1,294 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Governance\DecisionRegister;
use App\Filament\Pages\Governance\GovernanceInbox;
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
use App\Filament\Pages\Monitoring\Operations;
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Pages\Reviews\ReviewRegister;
use App\Filament\Resources\EvidenceSnapshotResource;
use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections;
use App\Models\EvidenceSnapshot;
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\FindingExceptionDecision;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\User;
use App\Support\EnvironmentReviewStatus;
use App\Support\Evidence\EvidenceSnapshotStatus;
use App\Support\ManagedEnvironmentLinks;
use App\Support\OperationRunLinks;
use App\Support\Workspaces\WorkspaceContext;
use Livewire\Livewire;
it('Spec315 environment owned workspace hub URLs use environment_id only', function (): void {
$environment = ManagedEnvironment::factory()->active()->create();
$urls = [
OperationRunLinks::index($environment),
ManagedEnvironmentLinks::operationsUrl($environment),
ManagedEnvironmentLinks::providerConnectionsUrl($environment),
CustomerReviewWorkspace::tenantPrefilterUrl($environment),
GovernanceInbox::getUrl(panel: 'admin', parameters: ['environment_id' => (int) $environment->getKey()]),
DecisionRegister::getUrl(panel: 'admin', parameters: ['environment_id' => (int) $environment->getKey()]),
FindingExceptionsQueue::getUrl(panel: 'admin', parameters: ['environment_id' => (int) $environment->getKey()]),
route('admin.evidence.overview', ['environment_id' => (int) $environment->getKey()]),
ReviewRegister::getUrl(panel: 'admin', parameters: ['environment_id' => (int) $environment->getKey()]),
];
foreach ($urls as $url) {
expect($url)
->toContain('environment_id='.(int) $environment->getKey())
->not->toContain('managed_environment_id=')
->not->toContain('tenant_scope=')
->not->toContain('tenant=')
->not->toContain('tableFilters');
}
});
it('Spec315 critical workspace hubs accept environment_id as visible explicit filter', function (): void {
[$user, $environmentA, $environmentB, $records] = spec315SeedEnvironmentFilterWorkspace();
$this->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $environmentA->workspace_id);
Livewire::withQueryParams(['environment_id' => (int) $environmentA->getKey()])
->actingAs($user)
->test(Operations::class)
->assertSet('tableFilters.managed_environment_id.value', (string) $environmentA->getKey())
->assertSee('Environment filter:')
->assertSee($environmentA->name)
->assertCanSeeTableRecords([$records['runA']])
->assertCanNotSeeTableRecords([$records['runB']]);
Livewire::withQueryParams(['environment_id' => (int) $environmentA->getKey()])
->actingAs($user)
->test(FindingExceptionsQueue::class)
->assertSet('tableFilters.managed_environment_id.value', (string) $environmentA->getKey())
->assertSee('Environment filter:')
->assertCanSeeTableRecords([$records['exceptionA']])
->assertCanNotSeeTableRecords([$records['exceptionB']]);
Livewire::withQueryParams(['environment_id' => (int) $environmentA->getKey()])
->actingAs($user)
->test(ListProviderConnections::class)
->assertSee('Environment filter:')
->assertSee($environmentA->name)
->assertCanSeeTableRecords([$records['connectionA']])
->assertCanNotSeeTableRecords([$records['connectionB']]);
Livewire::withQueryParams(['environment_id' => (int) $environmentA->getKey()])
->actingAs($user)
->test(ReviewRegister::class)
->assertSet('tableFilters.managed_environment_id.value', (string) $environmentA->getKey())
->assertSee('Environment filter:')
->assertCanSeeTableRecords([$records['reviewA']->fresh()])
->assertCanNotSeeTableRecords([$records['reviewB']->fresh()]);
Livewire::withQueryParams(['environment_id' => (int) $environmentA->getKey()])
->actingAs($user)
->test(CustomerReviewWorkspace::class)
->assertSet('tableFilters.managed_environment_id.value', (string) $environmentA->getKey())
->assertSee('Environment filter:')
->assertCanSeeTableRecords([$environmentA->fresh()])
->assertCanNotSeeTableRecords([$environmentB->fresh()]);
$this->get(GovernanceInbox::getUrl(panel: 'admin', parameters: ['environment_id' => (int) $environmentA->getKey()]))
->assertOk()
->assertSee('Environment filter:')
->assertSee($environmentA->name)
->assertDontSee('Spec315 Governance B');
$this->get(DecisionRegister::getUrl(panel: 'admin', parameters: ['environment_id' => (int) $environmentA->getKey()]))
->assertOk()
->assertSee('Environment filter:')
->assertSee($environmentA->name)
->assertDontSee('Spec315 Decision B');
$this->get(route('admin.evidence.overview', ['environment_id' => (int) $environmentA->getKey()]))
->assertOk()
->assertSee('Environment filter:')
->assertSee($environmentA->name)
->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $records['snapshotA']], tenant: $environmentA, panel: 'admin'), false)
->assertDontSee(EvidenceSnapshotResource::getUrl('view', ['record' => $records['snapshotB']], tenant: $environmentB, panel: 'admin'), false);
});
it('Spec315 legacy environment query params do not apply workspace hub filters', function (): void {
[$user, $environmentA, $environmentB, $records] = spec315SeedEnvironmentFilterWorkspace();
$this->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $environmentA->workspace_id);
Livewire::withQueryParams(['managed_environment_id' => (int) $environmentA->getKey()])
->actingAs($user)
->test(Operations::class)
->assertSet('tableFilters.managed_environment_id.value', null)
->assertDontSee('Environment filter:')
->assertCanSeeTableRecords([$records['runA'], $records['runB']]);
Livewire::withQueryParams(['tenant_id' => (int) $environmentA->getKey()])
->actingAs($user)
->test(Operations::class)
->assertSet('tableFilters.managed_environment_id.value', null)
->assertDontSee('Environment filter:')
->assertCanSeeTableRecords([$records['runA'], $records['runB']]);
Livewire::withQueryParams(['tenant' => (string) $environmentA->getKey()])
->actingAs($user)
->test(ReviewRegister::class)
->assertSet('tableFilters.managed_environment_id.value', null)
->assertDontSee('Environment filter:')
->assertCanSeeTableRecords([$records['reviewA']->fresh(), $records['reviewB']->fresh()]);
Livewire::withQueryParams(['environment' => (string) $environmentA->getKey()])
->actingAs($user)
->test(Operations::class)
->assertSet('tableFilters.managed_environment_id.value', null)
->assertDontSee('Environment filter:')
->assertCanSeeTableRecords([$records['runA'], $records['runB']]);
Livewire::withQueryParams(['tenant_scope' => 'all'])
->actingAs($user)
->test(FindingExceptionsQueue::class)
->assertSet('tableFilters.managed_environment_id.value', null)
->assertDontSee('Environment filter:')
->assertCanSeeTableRecords([$records['exceptionA'], $records['exceptionB']]);
Livewire::withQueryParams(['tableFilters' => [
'managed_environment_id' => ['value' => (string) $environmentA->getKey()],
]])
->actingAs($user)
->test(Operations::class)
->assertSet('tableFilters.managed_environment_id.value', null)
->assertDontSee('Environment filter:')
->assertCanSeeTableRecords([$records['runA'], $records['runB']]);
});
it('Spec315 environment filter must belong to the current workspace', function (): void {
$environmentA = ManagedEnvironment::factory()->active()->create();
[$user, $environmentA] = createUserWithTenant(tenant: $environmentA, role: 'owner');
$foreignEnvironment = ManagedEnvironment::factory()->active()->create();
createUserWithTenant(tenant: $foreignEnvironment, user: $user, role: 'owner');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $environmentA->workspace_id])
->get(route('admin.operations.index', [
'workspace' => $environmentA->workspace,
'environment_id' => (int) $foreignEnvironment->getKey(),
]))
->assertNotFound();
});
function spec315SeedEnvironmentFilterWorkspace(): array
{
$environmentA = ManagedEnvironment::factory()->active()->create(['name' => 'Spec315 Environment A']);
[$user, $environmentA] = createUserWithTenant(tenant: $environmentA, role: 'owner', workspaceRole: 'manager');
$environmentB = ManagedEnvironment::factory()->active()->create([
'workspace_id' => (int) $environmentA->workspace_id,
'name' => 'Spec315 Environment B',
]);
createUserWithTenant(tenant: $environmentB, user: $user, role: 'owner', workspaceRole: 'manager');
$runA = OperationRun::factory()->forTenant($environmentA)->create(['type' => 'policy.sync']);
$runB = OperationRun::factory()->forTenant($environmentB)->create(['type' => 'inventory_sync']);
$exceptionA = spec315FindingException($environmentA, $user, 'Spec315 Governance A', 'Spec315 Decision A');
$exceptionB = spec315FindingException($environmentB, $user, 'Spec315 Governance B', 'Spec315 Decision B');
$connectionA = ProviderConnection::factory()->create([
'workspace_id' => (int) $environmentA->workspace_id,
'managed_environment_id' => (int) $environmentA->getKey(),
'display_name' => 'Spec315 Provider A',
]);
$connectionB = ProviderConnection::factory()->create([
'workspace_id' => (int) $environmentB->workspace_id,
'managed_environment_id' => (int) $environmentB->getKey(),
'display_name' => 'Spec315 Provider B',
]);
$snapshotA = EvidenceSnapshot::query()->create([
'managed_environment_id' => (int) $environmentA->getKey(),
'workspace_id' => (int) $environmentA->workspace_id,
'status' => EvidenceSnapshotStatus::Active->value,
'summary' => ['missing_dimensions' => 0, 'stale_dimensions' => 0],
'generated_at' => now(),
]);
$snapshotB = EvidenceSnapshot::query()->create([
'managed_environment_id' => (int) $environmentB->getKey(),
'workspace_id' => (int) $environmentB->workspace_id,
'status' => EvidenceSnapshotStatus::Active->value,
'summary' => ['missing_dimensions' => 0, 'stale_dimensions' => 0],
'generated_at' => now(),
]);
$reviewA = composeEnvironmentReviewForTest($environmentA, $user, $snapshotA);
$reviewA->forceFill([
'status' => EnvironmentReviewStatus::Published->value,
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
$reviewB = composeEnvironmentReviewForTest($environmentB, $user, $snapshotB);
$reviewB->forceFill([
'status' => EnvironmentReviewStatus::Published->value,
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
return [$user, $environmentA, $environmentB, compact(
'runA',
'runB',
'exceptionA',
'exceptionB',
'connectionA',
'connectionB',
'snapshotA',
'snapshotB',
'reviewA',
'reviewB',
)];
}
function spec315FindingException(ManagedEnvironment $environment, User $actor, string $requestReason, string $decisionReason): FindingException
{
$finding = Finding::factory()->for($environment)->riskAccepted()->create([
'workspace_id' => (int) $environment->workspace_id,
'subject_external_id' => str()->slug($requestReason),
]);
$exception = FindingException::query()->create([
'workspace_id' => (int) $environment->workspace_id,
'managed_environment_id' => (int) $environment->getKey(),
'finding_id' => (int) $finding->getKey(),
'requested_by_user_id' => (int) $actor->getKey(),
'owner_user_id' => (int) $actor->getKey(),
'status' => FindingException::STATUS_PENDING,
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
'request_reason' => $requestReason,
'requested_at' => now()->subDay(),
'review_due_at' => now()->addDay(),
'evidence_summary' => ['reference_count' => 0],
]);
$decision = $exception->decisions()->create([
'workspace_id' => (int) $environment->workspace_id,
'managed_environment_id' => (int) $environment->getKey(),
'actor_user_id' => (int) $actor->getKey(),
'decision_type' => FindingExceptionDecision::TYPE_REQUESTED,
'reason' => $decisionReason,
'metadata' => [],
'decided_at' => now()->subDay(),
]);
$exception->forceFill(['current_decision_id' => (int) $decision->getKey()])->save();
return $exception->fresh(['currentDecision']);
}

View File

@ -47,7 +47,7 @@
it('Spec314 owns forbidden query keys and environment-like filter keys', function (): void {
expect(WorkspaceHubRegistry::forbiddenQueryKeys())
->toBe(['tenant', 'tenant_id', 'managed_environment_id', 'environment_id', 'tenant_scope', 'tableFilters'])
->toBe(['tenant', 'tenant_id', 'managed_environment_id', 'environment_id', 'environment', 'tenant_scope', 'tableFilters'])
->and(WorkspaceHubRegistry::environmentLikeFilterKeys())
->toBe(['tenant', 'tenant_id', 'managed_environment_id', 'environment_id', 'environment', 'tenant_scope']);
});

View File

@ -4,10 +4,10 @@
use App\Filament\Pages\Workspaces\ManagedEnvironmentOnboardingWizard;
use App\Filament\Resources\ProviderConnectionResource;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\ManagedEnvironment;
use App\Models\ManagedEnvironmentOnboardingSession;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
@ -328,7 +328,7 @@
'label' => 'Review platform connection',
'url' => ProviderConnectionResource::getUrl(
'edit',
['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()],
['tenant' => $tenant, 'record' => (int) $connection->getKey()],
panel: 'admin',
),
],
@ -376,7 +376,7 @@
->assertDontSee('href="https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/grant-admin-consent"', false)
->assertDontSee(ProviderConnectionResource::getUrl(
'edit',
['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()],
['tenant' => $tenant, 'record' => (int) $connection->getKey()],
panel: 'admin',
), false);
});

View File

@ -9,8 +9,8 @@
use App\Filament\Resources\AlertRuleResource;
use App\Filament\Resources\RestoreRunResource;
use App\Models\AuditLog;
use App\Models\OperationRun;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
@ -78,7 +78,7 @@
Bus::assertNothingDispatched();
})->group('ops-ux');
it('shows back to tenant on run detail when tenant context is active and entitled', function (): void {
it('keeps run detail return affordance workspace-scoped when tenant context is active and entitled', function (): void {
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$run = OperationRun::factory()->create([
@ -101,9 +101,9 @@
$response
->assertOk()
->assertSee('← Back to '.$tenant->name)
->assertSee(EnvironmentDashboard::getUrl(tenant: $tenant), false)
->assertDontSee('Back to Operations');
->assertSee('Back to Operations')
->assertDontSee('← Back to '.$tenant->name)
->assertDontSee(EnvironmentDashboard::getUrl(tenant: $tenant), false);
$this->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
@ -112,10 +112,10 @@
->assertOk()
->assertDontSee('Back to Operations');
expect(substr_count(html_entity_decode((string) $response->getContent(), ENT_QUOTES | ENT_HTML5), '← Back to '.$tenant->name))->toBe(1);
expect(substr_count(html_entity_decode((string) $response->getContent(), ENT_QUOTES | ENT_HTML5), 'Back to Operations'))->toBeGreaterThanOrEqual(1);
})->group('ops-ux');
it('shows back to tenant when filament tenant is absent but last tenant memory exists', function (): void {
it('keeps run detail return affordance workspace-scoped when only last tenant memory exists', function (): void {
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$run = OperationRun::factory()->create([
@ -142,10 +142,9 @@
$response
->assertOk()
->assertSee('← Back to '.$tenant->name)
->assertSee(EnvironmentDashboard::getUrl(tenant: $tenant), false)
->assertSee('Show all operations')
->assertDontSee('Back to Operations');
->assertSee('Back to Operations')
->assertDontSee('← Back to '.$tenant->name)
->assertDontSee(EnvironmentDashboard::getUrl(tenant: $tenant), false);
})->group('ops-ux');
it('shows no tenant return affordance when active and last tenant contexts are not entitled', function (): void {
@ -203,7 +202,7 @@
->assertOk()
->assertSee('Monitoring landing')
->assertSee('Scope context')
->assertSee('Scope reset');
->assertDontSee('Scope reset');
$this->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
@ -301,7 +300,7 @@
expect($resolved?->is($tenantB))->toBeTrue();
})->group('ops-ux');
it('prefers the current filament tenant over remembered tenant state on canonical run routes', function (): void {
it('keeps canonical run routes tenantless even when filament and remembered tenant state exist', function (): void {
$runTenant = ManagedEnvironment::factory()->create([
'workspace_id' => null,
]);
@ -340,10 +339,10 @@
$resolved = app(OperateHubShell::class)->activeEntitledTenant($request);
expect($resolved?->is($currentTenant))->toBeTrue();
expect($resolved)->toBeNull();
})->group('ops-ux');
it('keeps an administratively discoverable current tenant context on canonical run routes even when it is selector-ineligible', function (): void {
it('keeps canonical run routes tenantless even when current tenant context is selector-ineligible', function (): void {
$runTenant = ManagedEnvironment::factory()->active()->create([
'name' => 'Canonical Run ManagedEnvironment',
]);
@ -389,7 +388,7 @@
$resolved = app(OperateHubShell::class)->activeEntitledTenant($request);
expect($resolved?->is($currentTenant))->toBeTrue();
expect($resolved)->toBeNull();
})->group('ops-ux');
it('clears stale remembered tenant ids when the remembered tenant is no longer entitled', function (): void {
@ -495,7 +494,7 @@
expect($resolved?->is($routedTenant))->toBeTrue();
})->group('ops-ux');
it('shows tenant filter label when tenant context is active', function (): void {
it('shows all-environments shell label on workspace operations even when tenant context is active', function (): void {
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$this->actingAs($user);
@ -505,10 +504,10 @@
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
])->get(route('admin.operations.index', ['workspace' => (int) $tenant->workspace_id]))
->assertOk()
->assertSee(__('localization.shell.environment_scope').': '.$tenant->name)
->assertSee(__('localization.shell.all_environments'))
->assertDontSee('Scope: ManagedEnvironment')
->assertDontSee('Scope: Workspace')
->assertDontSee(__('localization.shell.all_environments'));
->assertDontSee(__('localization.shell.environment_scope').': '.$tenant->name);
})->group('ops-ux');
it('does not create audit entries when viewing operate hub pages', function (): void {

View File

@ -5,8 +5,8 @@
use App\Filament\Pages\CrossEnvironmentComparePage;
use App\Filament\Resources\ManagedEnvironmentResource;
use App\Jobs\Operations\CrossEnvironmentPromotionExecutionJob;
use App\Models\OperationRun;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Auth\Capabilities;
@ -57,7 +57,7 @@ function crossEnvironmentCompareLaunchQuery(string $url): array
'target_environment_id' => (string) $targetEnvironment->getKey(),
])
->and(data_get($query, 'nav.source_surface'))->toBe('tenant_registry')
->and(data_get($query, 'nav.managed_environment_id'))->toBe((string) $targetEnvironment->getKey())
->and(data_get($query, 'nav.environment_id'))->toBe((string) $targetEnvironment->getKey())
->and(data_get($query, 'nav.back_label'))->toBe(__('localization.shell.back_to_environment_registry'))
->and($backUrl)->toContain('backup_posture[0]='.TenantBackupHealthAssessment::POSTURE_STALE)
->and($backUrl)->toContain('recovery_evidence[0]='.TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED)

View File

@ -2,8 +2,8 @@
declare(strict_types=1);
use App\Models\ProviderConnection;
use App\Models\ManagedEnvironment;
use App\Models\ProviderConnection;
use App\Models\User;
it('enforces 404 for non-members and 403 for missing manage capability on mutations', function (): void {
@ -25,6 +25,6 @@
[$readonly] = createUserWithTenant(tenant: $tenant, role: 'readonly');
$this->actingAs($readonly)
->get('/admin/provider-connections/create?managed_environment_id='.(string) $tenant->external_id)
->get('/admin/provider-connections/create?environment_id='.(int) $tenant->getKey())
->assertForbidden();
});

View File

@ -14,7 +14,7 @@
]);
$this->actingAs($user)
->get('/admin/provider-connections/create?managed_environment_id='.(string) $tenant->external_id)
->get('/admin/provider-connections/create?environment_id='.(int) $tenant->getKey())
->assertForbidden();
$this->actingAs($user)

View File

@ -21,14 +21,14 @@
]);
$this->actingAs($user)
->get(ProviderConnectionResource::getUrl('create', ['managed_environment_id' => $tenant->external_id], panel: 'admin'))
->get(ProviderConnectionResource::getUrl('create', ['environment_id' => (int) $tenant->getKey()], panel: 'admin'))
->assertOk()
->assertSee('Target scope ID')
->assertSee('Target scope')
->assertDontSee('Entra tenant ID');
$this->actingAs($user)
->get(ProviderConnectionResource::getUrl('edit', ['record' => $connection, 'managed_environment_id' => $tenant->external_id], panel: 'admin'))
->get(ProviderConnectionResource::getUrl('edit', ['record' => $connection, 'environment_id' => (int) $tenant->getKey()], panel: 'admin'))
->assertOk()
->assertSee('Target scope ID')
->assertSee('Target scope')
@ -63,7 +63,7 @@
->and($table->getColumn('entra_tenant_id')?->getLabel())->toBe('Microsoft tenant ID');
$this->actingAs($user)
->get(ProviderConnectionResource::getUrl('view', ['record' => $connection, 'managed_environment_id' => $tenant->external_id], panel: 'admin'))
->get(ProviderConnectionResource::getUrl('view', ['record' => $connection, 'environment_id' => (int) $tenant->getKey()], panel: 'admin'))
->assertOk()
->assertSee('Target scope')
->assertSee('Provider context')

View File

@ -3,8 +3,8 @@
declare(strict_types=1);
use App\Filament\Resources\ProviderConnectionResource;
use App\Models\ProviderConnection;
use App\Models\ManagedEnvironmentPermission;
use App\Models\ProviderConnection;
use App\Support\Links\RequiredPermissionsLinks;
use Illuminate\Support\Facades\Bus;
@ -31,11 +31,11 @@
Bus::fake();
assertNoOutboundHttp(function () use ($tenant, $connection): void {
$this->get(ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'))
$this->get(ProviderConnectionResource::getUrl('index', ['tenant' => $tenant], panel: 'admin'))
->assertOk()
->assertSee('Spec081 Connection');
$this->get(ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => $connection], panel: 'admin'))
$this->get(ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant, 'record' => $connection], panel: 'admin'))
->assertOk()
->assertSee('Spec081 Connection')
->assertSee('Target scope')

View File

@ -62,10 +62,12 @@
[$user, $environment] = createUserWithTenant(tenant: $environment, role: 'owner');
$url = ProviderConnectionResource::getUrl('index', [
'managed_environment_id' => (string) $environment->external_id,
'environment_id' => (int) $environment->getKey(),
], panel: 'admin');
expect($url)->toContain('managed_environment_id=provider-explicit-environment');
expect($url)
->toContain('environment_id='.(int) $environment->getKey())
->not->toContain('managed_environment_id=');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $environment->workspace_id])

View File

@ -3,11 +3,11 @@
declare(strict_types=1);
use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections;
use App\Models\ProviderConnection;
use App\Models\ManagedEnvironment;
use App\Models\ProviderConnection;
use Livewire\Livewire;
it('uses managed_environment_id query override for authorized tenants', function (): void {
it('uses environment_id query override for authorized environments', function (): void {
$tenantA = ManagedEnvironment::factory()->create();
$tenantB = ManagedEnvironment::factory()->create([
'workspace_id' => (int) $tenantA->workspace_id,
@ -31,13 +31,13 @@
]);
$this->actingAs($user)
->get('/admin/provider-connections?managed_environment_id='.(string) $tenantB->external_id)
->get('/admin/provider-connections?environment_id='.(int) $tenantB->getKey())
->assertOk()
->assertSee('B Connection')
->assertDontSee('A Connection');
});
it('returns empty list for unauthorized managed_environment_id query override', function (): void {
it('returns not found for unauthorized environment_id query override', function (): void {
$tenantA = ManagedEnvironment::factory()->create();
$tenantB = ManagedEnvironment::factory()->create([
'workspace_id' => (int) $tenantA->workspace_id,
@ -60,10 +60,8 @@
]);
$this->actingAs($user)
->get('/admin/provider-connections?managed_environment_id='.(string) $tenantB->external_id)
->assertOk()
->assertDontSee('A Connection')
->assertDontSee('B Connection');
->get('/admin/provider-connections?environment_id='.(int) $tenantB->getKey())
->assertNotFound();
});
it('keeps composed list filters inside the authorized tenant override scope', function (): void {
@ -105,7 +103,7 @@
$this->actingAs($user);
Livewire::withQueryParams([
'managed_environment_id' => (string) $tenantB->external_id,
'environment_id' => (int) $tenantB->getKey(),
])->test(ListProviderConnections::class)
->filterTable('verification_status', 'healthy')
->assertCanSeeTableRecords([$tenantBConnected])

View File

@ -13,7 +13,8 @@
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(ListProviderConnections::class)
Livewire::withQueryParams(['environment_id' => (int) $tenant->getKey()])
->test(ListProviderConnections::class)
->assertActionVisible('create')
->assertActionDisabled('create')
->assertActionExists('create', function (Action $action): bool {
@ -28,7 +29,8 @@
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(ListProviderConnections::class)
Livewire::withQueryParams(['environment_id' => (int) $tenant->getKey()])
->test(ListProviderConnections::class)
->assertActionVisible('create')
->assertActionEnabled('create');
});
@ -40,7 +42,8 @@
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$component = Livewire::test(ListProviderConnections::class)
$component = Livewire::withQueryParams(['environment_id' => (int) $tenant->getKey()])
->test(ListProviderConnections::class)
->assertActionVisible('create')
->assertActionEnabled('create');

View File

@ -73,6 +73,6 @@
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantAllowed->workspace_id])
->get(CustomerReviewWorkspace::getUrl(panel: 'admin').'?tenant='.(string) $tenantDenied->getKey())
->get(CustomerReviewWorkspace::getUrl(panel: 'admin').'?environment_id='.(string) $tenantDenied->getKey())
->assertNotFound();
});

View File

@ -6,8 +6,8 @@
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Resources\EnvironmentReviewResource;
use App\Models\ManagedEnvironment;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\EnvironmentReviewStatus;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Livewire\Livewire;
@ -40,13 +40,13 @@
tenantId: (int) $tenant->getKey(),
familyKey: 'review_follow_up',
backLinkUrl: GovernanceInbox::getUrl(panel: 'admin', parameters: [
'managed_environment_id' => (string) $tenant->getKey(),
'environment_id' => (string) $tenant->getKey(),
'family' => 'review_follow_up',
]),
);
Livewire::withQueryParams(array_replace($context->toQuery(), [
'tenant' => (string) $tenant->external_id,
'environment_id' => (int) $tenant->getKey(),
]))
->actingAs($user)
->test(CustomerReviewWorkspace::class)

View File

@ -8,8 +8,8 @@
use App\Models\FindingException;
use App\Models\ManagedEnvironment;
use App\Models\User;
use App\Support\Governance\Controls\ComplianceEvidenceMappingV1;
use App\Support\EnvironmentReviewStatus;
use App\Support\Governance\Controls\ComplianceEvidenceMappingV1;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
@ -231,11 +231,11 @@
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantPublished->workspace_id);
Livewire::withQueryParams(['managed_environment_id' => (string) $tenantWithoutPublished->getKey()])
Livewire::withQueryParams(['environment_id' => (int) $tenantWithoutPublished->getKey()])
->actingAs($user)
->test(CustomerReviewWorkspace::class)
->assertSet('tableFilters.managed_environment_id.value', (string) $tenantWithoutPublished->getKey())
->assertSee('Filtered by environment: Filtered ManagedEnvironment')
->assertSee('Environment filter:')
->assertSee('No released customer reviews match the active environment filter.')
->assertSee('Clear the environment filter to view other released reviews in this workspace.')
->assertCanNotSeeTableRecords([$tenantPublished->fresh(), $tenantWithoutPublished->fresh()])
@ -426,7 +426,7 @@
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
Livewire::withQueryParams(['tenant' => (string) $tenantA->external_id])
Livewire::withQueryParams(['environment_id' => (int) $tenantA->getKey()])
->test(CustomerReviewWorkspace::class)
->assertSet('tableFilters.managed_environment_id.value', (string) $tenantA->getKey())
->filterTable('managed_environment_id', (string) $tenantA->getKey())

View File

@ -5,9 +5,9 @@
use App\Filament\Pages\Monitoring\Operations;
use App\Filament\Resources\RestoreRunResource\Pages\ListRestoreRuns;
use App\Models\BackupSet;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Models\RestoreRun;
use App\Models\ManagedEnvironment;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
@ -17,7 +17,7 @@
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
test('operation runs default to the active tenant when tenant context is set', function (): void {
test('operation runs remain workspace-wide unless an explicit environment filter is set', function (): void {
$tenantA = ManagedEnvironment::factory()->create();
[$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner');
@ -55,9 +55,8 @@
Livewire::actingAs($user)
->test(Operations::class)
->assertCanSeeTableRecords([$runA])
->assertCanNotSeeTableRecords([$runB])
->assertSet('tableFilters.managed_environment_id.value', (string) $tenantA->getKey());
->assertCanSeeTableRecords([$runA, $runB])
->assertSet('tableFilters.managed_environment_id.value', null);
});
test('operation run view is not accessible cross-workspace', function (): void {

View File

@ -3,8 +3,8 @@
declare(strict_types=1);
use App\Models\AuditLog;
use App\Models\ProviderConnection;
use App\Models\ManagedEnvironment;
use App\Models\ProviderConnection;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
@ -59,7 +59,7 @@
->assertOk();
expect(ManagedEnvironmentLinks::providerConnectionsUrl($tenant))
->toContain('/admin/provider-connections?managed_environment_id='.$tenant->external_id)
->toContain('/admin/provider-connections?environment_id='.(int) $tenant->getKey())
->not->toContain('/admin/tenants')
->not->toContain('/admin/t/');
});
@ -166,13 +166,13 @@
$this->followingRedirects()
->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get('/admin/provider-connections?managed_environment_id='.$tenant->external_id)
->get('/admin/provider-connections?environment_id='.(int) $tenant->getKey())
->assertOk();
$this->followingRedirects()
->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get('/admin/provider-connections/'.$connection->getKey().'/edit?managed_environment_id='.$tenant->external_id)
->get('/admin/provider-connections/'.$connection->getKey().'/edit?environment_id='.(int) $tenant->getKey())
->assertOk();
});
@ -182,13 +182,13 @@
$this->followingRedirects()
->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get('/admin/provider-connections?managed_environment_id='.$tenant->external_id)
->get('/admin/provider-connections?environment_id='.(int) $tenant->getKey())
->assertOk();
$this->followingRedirects()
->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get('/admin/provider-connections/create?managed_environment_id='.$tenant->external_id)
->get('/admin/provider-connections/create?environment_id='.(int) $tenant->getKey())
->assertForbidden();
});

View File

@ -2,8 +2,8 @@
declare(strict_types=1);
use App\Models\OperationRun;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Workspaces\WorkspaceContext;
@ -17,7 +17,7 @@
Http::preventStrayRequests();
});
it('shows back-to-tenant and show-all-operations when tenant context is active and entitled', function (): void {
it('shows only back-to-operations when tenant context is active and entitled', function (): void {
$tenant = ManagedEnvironment::factory()->create();
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
@ -35,9 +35,8 @@
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(route('admin.operations.view', ['workspace' => $tenant->workspace, 'run' => (int) $run->getKey()]))
->assertOk()
->assertSee('← Back to '.$tenant->name)
->assertSee('Show all operations')
->assertDontSee('Back to Operations');
->assertSee('Back to Operations')
->assertDontSee('← Back to '.$tenant->name);
});
it('shows only back-to-operations when no tenant context is active', function (): void {

View File

@ -15,7 +15,7 @@
'reason_code' => 'missing_configuration',
'message' => 'Missing default provider connection.',
'next_steps' => [
['label' => 'Manage Provider Connections', 'url' => '/admin/provider-connections?managed_environment_id=example'],
['label' => 'Manage Provider Connections', 'url' => '/admin/provider-connections?environment_id=123'],
['label' => '', 'url' => '/admin/invalid'],
['label' => 'Missing URL'],
],
@ -25,7 +25,7 @@
expect($report['checks'][0]['next_steps'] ?? [])->toBe([
[
'label' => 'Manage Provider Connections',
'url' => '/admin/provider-connections?managed_environment_id=example',
'url' => '/admin/provider-connections?environment_id=123',
],
]);
});

View File

@ -4,6 +4,7 @@
use App\Filament\Resources\ProviderConnectionResource;
use App\Models\ManagedEnvironment;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
@ -13,6 +14,7 @@
$tenant = ManagedEnvironment::factory()->create([
'external_id' => 'b0091e5d-944f-4a34-bcd9-12cbfb7b75cf',
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
$updateUri = '/'.collect(app('router')->getRoutes()->getRoutes())
->first(fn ($route): bool => str_contains((string) $route->getName(), 'livewire.update'))
@ -22,7 +24,7 @@
$request = Request::create($updateUri, 'POST');
$request->headers->set('x-livewire', '1');
$request->headers->set('referer', "http://localhost/admin/provider-connections/1/edit?managed_environment_id={$tenant->external_id}");
$request->headers->set('referer', "http://localhost/admin/provider-connections/1/edit?environment_id={$tenant->getKey()}");
app()->instance('request', $request);
expect(ManagedEnvironment::query()->where('slug', $tenant->external_id)->exists())->toBeTrue();
@ -34,8 +36,8 @@
expect($resolvedTenant)->toBeInstanceOf(ManagedEnvironment::class);
expect($resolvedTenant->is($tenant))->toBeTrue();
$url = ProviderConnectionResource::getUrl('index');
$url = ProviderConnectionResource::getUrl('create');
expect($url)->toContain('/admin/provider-connections');
expect($url)->toContain('managed_environment_id='.(string) $tenant->external_id);
expect($url)->toContain('environment_id='.(string) $tenant->getKey());
});

View File

@ -27,7 +27,7 @@
'nav' => [
'source_surface' => 'finding.detail_section',
'canonical_route_name' => 'admin.operations.view',
'managed_environment_id' => 44,
'environment_id' => 44,
'back_label' => 'Back to finding',
'back_url' => '/admin/findings/12',
],
@ -39,7 +39,7 @@
'nav' => [
'source_surface' => 'backup_set.detail_section',
'canonical_route_name' => 'admin.operations.view',
'managed_environment_id' => 22,
'environment_id' => 22,
'back_label' => 'Back to backup set',
'back_url' => '/admin/backup-sets/8',
],
@ -58,7 +58,7 @@
canonicalRouteName: 'filament.admin.pages.governance.inbox',
tenantId: 12,
familyKey: 'finding_exceptions',
backLinkUrl: '/admin/governance/inbox?managed_environment_id=12&family=finding_exceptions',
backLinkUrl: '/admin/governance/inbox?environment_id=12&family=finding_exceptions',
);
$roundTrip = CanonicalNavigationContext::fromRequest(Request::create('/admin/finding-exceptions/queue', 'GET', $context->toQuery()));
@ -66,12 +66,12 @@
expect($context->toQuery()['nav'])
->toMatchArray([
'source_surface' => 'governance.inbox',
'managed_environment_id' => 12,
'environment_id' => 12,
'family_key' => 'finding_exceptions',
'back_label' => 'Back to governance inbox',
])
->and($roundTrip?->sourceSurface)->toBe('governance.inbox')
->and($roundTrip?->tenantId)->toBe(12)
->and($roundTrip?->familyKey)->toBe('finding_exceptions')
->and($roundTrip?->backLinkUrl)->toBe('/admin/governance/inbox?managed_environment_id=12&family=finding_exceptions');
->and($roundTrip?->backLinkUrl)->toBe('/admin/governance/inbox?environment_id=12&family=finding_exceptions');
});

View File

@ -12,7 +12,7 @@
uses(RefreshDatabase::class);
it('prefers a valid tenant query hint over remembered tenant state on workspace-scoped admin routes', function (): void {
it('keeps workspace hub shell tenantless when an explicit environment filter is present', function (): void {
$rememberedTenant = ManagedEnvironment::factory()->active()->create(['name' => 'Remembered ManagedEnvironment']);
[$user, $rememberedTenant] = createUserWithTenant(tenant: $rememberedTenant, role: 'owner');
@ -35,7 +35,7 @@
$request = Request::create(route('admin.operations.index', [
'workspace' => $workspaceId,
'tenant' => $hintedTenant->external_id,
'environment_id' => (int) $hintedTenant->getKey(),
]));
$request->setLaravelSession(app('session.store'));
$request->setUserResolver(static fn () => $user);
@ -46,12 +46,12 @@
$resolved = app(OperateHubShell::class)->resolvedContext($request);
expect($resolved->workspace?->getKey())->toBe($workspaceId)
->and($resolved->tenant?->is($hintedTenant))->toBeTrue()
->and($resolved->tenantSource)->toBe('query_hint')
->and($resolved->state)->toBe('tenant_scoped');
->and($resolved->tenant)->toBeNull()
->and($resolved->tenantSource)->toBe('none')
->and($resolved->state)->toBe('tenantless_workspace');
});
it('falls back to a tenantless workspace state when a tenant query hint targets another workspace', function (): void {
it('does not resolve cross-workspace environment filters as shell tenant context on workspace hubs', function (): void {
$workspaceTenant = ManagedEnvironment::factory()->active()->create(['name' => 'Current Workspace ManagedEnvironment']);
[$user, $workspaceTenant] = createUserWithTenant(tenant: $workspaceTenant, role: 'owner');
@ -67,7 +67,7 @@
$request = Request::create(route('admin.operations.index', [
'workspace' => $workspaceId,
'tenant' => $foreignTenant->external_id,
'environment_id' => (int) $foreignTenant->getKey(),
]));
$request->setLaravelSession(app('session.store'));
$request->setUserResolver(static fn () => $user);
@ -80,7 +80,7 @@
expect($resolved->workspace?->getKey())->toBe($workspaceId)
->and($resolved->tenant)->toBeNull()
->and($resolved->state)->toBe('tenantless_workspace')
->and($resolved->recoveryReason)->toBe('mismatched_workspace');
->and($resolved->recoveryReason)->toBeNull();
});
it('uses the routed tenant workspace when the tenant panel is entered without a selected workspace session', function (): void {

View File

@ -10,7 +10,7 @@
it('classifies in-scope admin routes into canonical page categories', function (string $path, TenantPageCategory $expected): void {
expect(TenantPageCategory::fromPath($path))->toBe($expected);
})->with([
'workspace overview' => ['/admin', TenantPageCategory::WorkspaceScoped],
'workspace overview' => ['/admin', TenantPageCategory::WorkspaceWideSurface],
'workspace chooser exception' => ['/admin/choose-workspace', TenantPageCategory::WorkspaceChooserException],
'tenant chooser' => ['/admin/choose-environment', TenantPageCategory::WorkspaceScoped],
'retired tenant resource detail' => ['/admin/tenants/tenant-123', TenantPageCategory::WorkspaceScoped],

View File

@ -3,8 +3,8 @@
declare(strict_types=1);
use App\Filament\Resources\ProviderConnectionResource;
use App\Models\ProviderConnection;
use App\Models\ManagedEnvironment;
use App\Models\ProviderConnection;
use App\Support\Links\RequiredPermissionsLinks;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Verification\VerificationLinkBehavior;
@ -60,7 +60,7 @@
label: 'Manage Provider Connections',
url: ProviderConnectionResource::getUrl(
'edit',
['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()],
['tenant' => $tenant, 'record' => (int) $connection->getKey()],
panel: 'admin',
),
);

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

View File

@ -0,0 +1,71 @@
# Requirements Checklist: Environment CTA Explicit Filter Contract
**Spec**: [spec.md](/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/315-environment-cta-explicit-filter-contract/spec.md)
**Plan**: [plan.md](/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/315-environment-cta-explicit-filter-contract/plan.md)
**Generated**: 2026-05-16
## Candidate Selection Gate
- [x] Explicit user-provided Spec 315 request was selected as the source of truth for this preparation pass.
- [x] Completed-spec guardrail checked that no existing `specs/315-*` artifact was present before generation.
- [x] Spec 313 and Spec 314 were treated as historical baseline context, not rewritten.
- [x] Close alternatives were identified as follow-up specs 316, 317, and 318 rather than merged into this spec.
## Spec Readiness
- [x] Problem statement is operator-visible and tied to current Environment CTA scope drift.
- [x] Hard-cutover policy is explicit.
- [x] `environment_id` is named as the only canonical Environment CTA filter key.
- [x] Legacy keys are explicitly rejected for Environment CTA filter behavior.
- [x] Workspace-scoped Managed Environment resolution is specified.
- [x] Cross-workspace Environment ID behavior is specified as 404 or safe no-access.
- [x] Shell context remains workspace-scoped.
- [x] Visible filter chip and clear-link requirements are specified.
- [x] Clean sidebar/global regression from Spec 314 is specified.
- [x] Follow-up boundaries for Specs 316, 317, and 318 are explicit.
## Plan Readiness
- [x] Laravel, Filament, Livewire, Pest, and PostgreSQL context is recorded.
- [x] No migration, seeder, package, env var, queue, scheduler, or storage change is planned.
- [x] Shared resolver/helper proportionality is justified.
- [x] Shared visible chip proportionality is justified.
- [x] Provider/platform boundary is classified.
- [x] OperationRun impact is limited to link semantics.
- [x] Browser verification scope is defined.
- [x] Deployment impact is assessed.
## Task Readiness
- [x] Tasks are ordered from guardrails and tests through runtime changes and validation.
- [x] Tests are required before or alongside runtime work.
- [x] Critical hubs are named in tasks.
- [x] Conditional hubs are classified instead of assumed.
- [x] Legacy alias rejection has explicit tasks.
- [x] Sidebar/global clean-entry regression has explicit tasks.
- [x] Browser verification flows have explicit tasks.
- [x] Non-tasks prevent scope creep into Specs 316, 317, and 318.
## Constitution / Guardrail Coverage
- [x] Workspace isolation is covered.
- [x] RBAC and no-access behavior are covered.
- [x] No Graph write/read integration change is introduced.
- [x] No destructive action behavior is introduced.
- [x] No persisted truth is introduced.
- [x] No status/reason family is introduced.
- [x] Filament v5 / Livewire v4 compliance is recorded.
- [x] Native/shared UI primitive preference is recorded.
- [x] Test governance classification and lanes are recorded.
## Open Questions
- [x] No blocking requirements questions remain for preparation.
- [x] Runtime implementation must still classify Audit Log, Alerts, Reports/Stored Reports, and Support Requests based on actual workspace ownership and Environment-filterability.
- [x] Runtime implementation must document any clear-filter limitations deferred to Spec 316.
## Review Outcome
- [x] Spec artifacts are ready for `/speckit.implement` or an equivalent implementation pass.
- [x] No application implementation was performed during preparation.

View File

@ -0,0 +1,347 @@
# Implementation Plan: Environment CTA Explicit Filter Contract
**Branch**: `315-environment-cta-explicit-filter-contract` | **Date**: 2026-05-16 | **Spec**: [spec.md](/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/315-environment-cta-explicit-filter-contract/spec.md)
**Input**: Feature specification from `/specs/315-environment-cta-explicit-filter-contract/spec.md`
**Preparation status**: Specification artifacts only. No runtime implementation has been performed by this preparation step.
## Summary
Spec 315 hard-cuts Environment-owned CTA links into workspace hubs to one canonical explicit filter contract:
```text
Environment Dashboard / Environment-owned CTA -> Workspace Hub ?environment_id={managed_environment_id}
```
Workspace hubs remain workspace-scoped. `environment_id` is resolved only inside the selected Workspace, represented as a visible page-level Environment filter chip, applied to data where valid, and removable through a clean hub URL. Legacy query keys such as `tenant`, `tenant_id`, `managed_environment_id`, `tenant_scope`, `environment`, and `tableFilters` are not accepted as valid Environment CTA filter state.
## Technical Context
**Language/Version**: PHP 8.4.15, Laravel 12.52.0
**Primary Dependencies**: Filament 5.2.1, Livewire 4.1.4, Laravel Sail, Laravel Socialite, Laravel MCP
**Storage**: PostgreSQL; no schema changes for this spec
**Testing**: Pest 4.3.1 / PHPUnit 12.4.4; focused browser smoke where applicable
**Validation Lanes**: fast-feedback, confidence for existing related coverage, browser for focused rendered-state verification
**Target Platform**: Laravel admin application under `apps/platform`, local development through Sail, staging/production through Dokploy
**Project Type**: Web application, Laravel/Filament admin panel
**Performance Goals**: No material performance change. Environment filter resolution is one workspace-scoped Managed Environment lookup per relevant request/page mount and normal query narrowing thereafter.
**Constraints**: No migrations, seeders, new packages, env vars, queues, scheduler, or storage changes. No backward compatibility layer or dual-param support.
**Scale/Scope**: Cross-cutting runtime contract across critical workspace hubs and shared Environment-owned CTA/link helpers.
## UI / Surface Guardrail Plan
- **Guardrail scope**: Changed operator-facing surfaces for visible Environment filter state and CTA query contracts.
- **Native vs custom classification summary**: Native Filament/Livewire pages/resources with a shared Blade partial or existing shared primitive for a small chip. No redesign.
- **Shared-family relevance**: Navigation entry points, dashboard CTAs, scope signals, filter summaries, and clean clear links.
- **State layers in scope**: URL query, page state, table/list query state, rendered page header/filter chip, shell context. Full persisted/session/deferred clear semantics are deferred to Spec 316.
- **Audience modes in scope**: Operator-MSP and support-platform. Customer-read-only applies only where Customer Review Workspace currently exposes a customer-safe workspace surface.
- **Decision/diagnostic/raw hierarchy plan**: Filter truth is default-visible; existing diagnostics/details remain on demand.
- **Raw/support gating plan**: No raw evidence exposure changes.
- **One-primary-action / duplicate-truth control**: The visible Environment filter chip is the single page-level truth for explicit Environment filter state; shell remains Workspace.
- **Handling modes by drift class or surface**: Hard-stop for legacy alias acceptance in Environment CTA filter behavior; review-mandatory for any page-specific exception.
- **Repository-signal treatment**: Contract tests and browser screenshots are required evidence.
- **Special surface test profiles**: global-context-shell, monitoring-state-page, standard-native-filament.
- **Required tests or manual smoke**: Functional-core URL/filter tests, state-contract tests, sidebar regression tests, and focused browser smoke.
- **Exception path and spread control**: Any page discovered not to be workspace-owned or Environment-filterable must be documented as excluded instead of receiving a partial local exception.
- **Active feature PR close-out entry**: Guardrail and Smoke Coverage.
## Shared Pattern & System Fit
- **Cross-cutting feature marker**: yes.
- **Systems touched**: `WorkspaceHubRegistry`, `WorkspaceSidebarNavigation`, `ManagedEnvironmentLinks`, `OperationRunLinks`, `CanonicalAdminTenantFilterState` only if immediate clean-entry correctness requires it, Filament pages/resources for critical hubs, shared views under `resources/views/filament`, and related tests.
- **Shared abstractions reused**: `WorkspaceHubRegistry` for known hub paths and clean hub URL generation; existing page/table query patterns; existing workspace and Managed Environment models/factories.
- **New abstraction introduced? why?**: A narrow `WorkspaceHubEnvironmentFilter` resolver/helper is expected because multiple pages currently parse different keys and identifier types.
- **Why the existing abstraction was sufficient or insufficient**: Spec 314 registry is sufficient for clean sidebar/global entry but intentionally treats Environment-like query params as forbidden for that clean-entry contract. Spec 315 needs a separate valid explicit CTA filter path.
- **Bounded deviation / spread control**: The resolver reads only `environment_id`, validates only inside current Workspace, and does not become a shell/context manager or compatibility adapter.
## OperationRun UX Impact
- **Touches OperationRun start/completion/link UX?**: yes, link semantics only.
- **Central contract reused**: Existing `OperationRunLinks` URL helper.
- **Delegated UX behaviors**: Tenant/workspace-safe URL resolution for OperationRun links into workspace hubs.
- **Surface-owned behavior kept local**: OperationRun start, completion, status, notifications, and artifact behavior remain unchanged.
- **Queued DB-notification policy**: N/A.
- **Terminal notification path**: N/A.
- **Exception path**: none.
## Provider Boundary & Portability Fit
- **Shared provider/platform boundary touched?**: yes.
- **Provider-owned seams**: Existing provider connection data and provider external tenant IDs stay provider-adjacent model data.
- **Platform-core seams**: Workspace hub query contract, visible scope wording, Environment filter resolver, and CTA URL generation.
- **Neutral platform terms / contracts preserved**: `Workspace`, `Environment`, `Environment filter`, `environment_id`.
- **Retained provider-specific semantics and why**: Existing Microsoft/Intune provider connection internals remain as-is. They must not become the CTA filter identifier.
- **Bounded extraction or follow-up path**: Broader tenant/environment naming cleanup is deferred to Spec 317.
## Constitution Check
*GATE: Must pass before implementation. Re-check after runtime changes.*
- Inventory-first: no Graph inventory semantics change.
- Read/write separation: no Graph writes or destructive operations are added.
- Graph contract path: no Graph calls are introduced.
- Deterministic capabilities: existing capability checks remain; new tests must assert workspace/environment access boundaries.
- RBAC-UX: workspace hub authorization remains workspace/capability-based. Cross-workspace Environment IDs are 404/safe no-access; member-missing-capability remains existing 403 behavior.
- Workspace isolation: Environment filter resolution is explicitly scoped to current Workspace and must not switch workspace.
- Tenant isolation: no cross-Workspace/Environment leakage; no provider external tenant ID lookup for CTA filter state.
- Run observability: no new OperationRun lifecycle behavior. URL helpers only.
- Test governance (TEST-GOV-001): lane, fixture cost, heavy-family browser coverage, and reviewer handoff are explicit in this plan and tasks.
- Proportionality (PROP-001): new resolver/helper is justified by current-release cross-page drift and at least two concrete surfaces.
- No premature abstraction (ABSTR-001): the resolver is bounded to one query key and does not become a generalized context framework.
- Persisted truth (PERSIST-001): no persisted truth is added.
- Behavioral state (STATE-001): no new state/status family is added.
- UI semantics (UI-SEM-001): visible chip is direct domain-to-UI mapping, not a new taxonomy.
- Shared pattern first (XCUT-001): extend Spec 314 hub registry and existing visible filter summaries before local patterns.
- Provider boundary (PROV-001): platform CTA filter uses Managed Environment database ID, not provider external ID.
- V1 explicitness / few layers: direct hard cutover, small helper, page-local data query application where appropriate.
- Spec discipline / bloat check: explicit non-goals, follow-up specs 316/317/318, and no compatibility seam.
- Filament-native UI (UI-FIL-001): use native Filament/Livewire and shared partials; no ad-hoc styling system.
- UI/UX scope, truth, and naming: scope signals must distinguish Workspace shell from explicit Environment page filter.
- UI naming: user-facing wording uses `Environment`, not `Tenant`, for the filter chip.
## Test Governance Check
- **Test purpose / classification by changed surface**: Unit/Feature for resolver and page contracts; Browser for integrated shell/header/chip URL flows.
- **Affected validation lanes**: fast-feedback, confidence for existing related regressions, browser for focused smoke.
- **Why this lane mix is the narrowest sufficient proof**: Unit/feature tests prove URL key, resolver, data scope, and clear-link behavior. Browser tests are required because the defect is also visible UI/shell truth.
- **Narrowest proving command(s)**:
- `cd apps/platform && ./vendor/bin/sail artisan test --filter=WorkspaceHubEnvironmentFilter`
- `cd apps/platform && ./vendor/bin/sail artisan test --filter=EnvironmentCta`
- `cd apps/platform && ./vendor/bin/sail artisan test --filter=WorkspaceHub`
- focused browser smoke command or manual browser verification documented in close-out
- **Fixture / helper / factory / seed / context cost risks**: Existing Workspace, ManagedEnvironment, membership/capability, operation/review/evidence/finding/provider connection factories may need small focused setup. Avoid broad global seeders.
- **Expensive defaults or shared helper growth introduced?**: no; browser smoke remains explicit.
- **Heavy-family additions, promotions, or visibility changes**: Focused Spec 315 browser smoke only.
- **Surface-class relief / special coverage rule**: Native Filament page/resource tests for pages/resources; browser smoke for rendered shell/context.
- **Closing validation and reviewer handoff**: Reviewers must verify test commands, screenshot artifacts, no legacy alias acceptance, no compatibility adapter, and no scope drift from Spec 314.
- **Budget / baseline / trend follow-up**: none expected.
- **Review-stop questions**: Does any page still parse `tenant` or `managed_environment_id` as CTA filter? Does any CTA emit a legacy key? Does any filtered hub set shell Environment ownership? Does any clean sidebar link retain filter state?
- **Escalation path**: Follow-up Spec 316 only if implementation reveals broader persisted clear-state behavior is required for correctness beyond clean URL entry.
- **Active feature PR close-out entry**: Guardrail and Smoke Coverage.
- **Why no dedicated follow-up spec is needed**: The canonical CTA filter contract is the dedicated follow-up to Spec 314. Clear internals, legacy cleanup, and durable browser guard infrastructure already have follow-up specs 316, 317, and 318.
## Project Structure
### Documentation (this feature)
```text
specs/315-environment-cta-explicit-filter-contract/
|-- spec.md
|-- plan.md
|-- tasks.md
|-- checklists/
| `-- requirements.md
`-- artifacts/
`-- screenshots/ # created during implementation/browser verification if useful
```
No `research.md`, `data-model.md`, `quickstart.md`, or `contracts/` artifact is required for preparation because this feature introduces no data model, external API contract, or new workflow API. Runtime implementation may add notes only if a classification/exclusion needs durable documentation inside this spec folder.
### Source Code (repository root)
Likely runtime files to inspect or update during implementation:
```text
apps/platform/app/Support/Navigation/WorkspaceHubRegistry.php
apps/platform/app/Support/Navigation/WorkspaceHubEnvironmentFilter.php
apps/platform/app/Support/Navigation/WorkspaceSidebarNavigation.php
apps/platform/app/Support/ManagedEnvironmentLinks.php
apps/platform/app/Support/Operations/OperationRunLinks.php
apps/platform/app/Support/Filament/CanonicalAdminTenantFilterState.php
apps/platform/app/Filament/Pages/Monitoring/Operations.php
apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php
apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php
apps/platform/app/Filament/Pages/Governance/DecisionRegister.php
apps/platform/app/Filament/Pages/Governance/FindingExceptionsQueue.php
apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php
apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php
apps/platform/app/Filament/Resources/ProviderConnectionResource.php
apps/platform/resources/views/filament/
apps/platform/tests/Feature/
apps/platform/tests/Browser/
```
Potential classification-only inspection areas:
```text
apps/platform/app/Filament/Pages/Monitoring/AuditLog.php
apps/platform/app/Filament/Resources/Alert*
apps/platform/app/Filament/Pages or Resources for Reports / Stored Reports
apps/platform/app/Filament/Pages or Resources for Support Requests
```
**Structure Decision**: Laravel/Filament platform app under `apps/platform`; new source files, if any, stay in existing `app/Support` and `resources/views/filament` locations. Tests stay in existing Pest feature/browser test directories.
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|---|---|---|
| New resolver/helper abstraction | Environment CTA filtering spans many workspace hubs and currently has divergent key/identifier semantics | Page-local parsing or dual-param adapters would preserve drift and violate the hard-cutover policy |
| Shared visible chip partial or primitive | Filter state must be visibly consistent across hubs | Duplicated per-page chip markup would increase scope wording drift |
## Phase 0: Discovery Completed During Preparation
Relevant repository facts discovered before authoring this plan:
- Spec 313 is an audit-only baseline and explicitly recommends Spec 315 for Environment CTA explicit filter standardization.
- Spec 314 has completed tasks and latest local history indicates the workspace hub navigation context contract was merged before this branch.
- `WorkspaceHubRegistry` currently centralizes hub paths and clean URL behavior from Spec 314.
- `ManagedEnvironmentLinks` and `OperationRunLinks` currently have Environment-owned link helpers that use legacy `managed_environment_id` semantics in some cases.
- Operations currently has `managed_environment_id`, `tenant_scope`, and table filter state related to Environment prefiltering.
- Governance Inbox and Decision Register currently parse `managed_environment_id` and `tenant` and render a visible ManagedEnvironment filter summary.
- Finding Exceptions Queue currently uses `tenant` query state and `managed_environment_id` table filter naming.
- Evidence Overview and Customer Review Workspace currently have Environment-like query/filter behavior that must hard-cut to `environment_id`.
- Provider Connections currently uses `managed_environment_id` query behavior around provider/external identifiers; implementation must avoid provider external tenant ID as canonical CTA filter key.
## Technical Approach
### 1. Canonical resolver
Add or reuse a narrow helper such as:
```text
apps/platform/app/Support/Navigation/WorkspaceHubEnvironmentFilter.php
```
Responsibilities:
- read only `environment_id`
- validate scalar/integer-like value safely
- resolve `ManagedEnvironment` by primary key inside the current Workspace
- return null/no filter when `environment_id` is absent
- abort with 404 or existing safe no-access when `environment_id` is present but invalid or cross-workspace
- expose display name, ID, clear URL, and query application helpers
- never read legacy params, remembered Environment, provider external tenant ID, slug, or `Filament::getTenant()`
### 2. Workspace hub registry alignment
Keep Spec 314 clean-entry behavior. Add a safe way for Environment-owned CTA URL builders and pages to generate or consume an explicit `environment_id` filter without weakening sidebar/global clean URL checks.
Review point:
- `environment_id` may remain forbidden for clean sidebar/global entry while being allowed for explicit CTA filter entry through the resolver. The implementation must keep those two modes separate.
### 3. CTA URL hard cutover
Update Environment-owned link helpers and CTAs:
- Environment Dashboard cards/header actions
- Environment Governance Overview cards
- Provider readiness/onboarding links
- Required permissions and permission posture links
- Recent Operations and OperationRun links
- ManagedEnvironment link helpers
- Review, evidence, support, report, provider connection helpers where they target workspace hubs
Output must use:
```text
?environment_id={managed_environment_id}
```
and must not output:
```text
tenant
tenant_id
managed_environment_id
tenant_scope
environment
tableFilters
```
### 4. Visible filter chip
Create or reuse a shared partial/primitive such as:
```text
apps/platform/resources/views/filament/partials/workspace-hub-environment-filter-chip.blade.php
```
Required behavior:
- render only for a valid `environment_id` filter
- show `Environment filter: {environment display name}` or equivalent
- include `Clear filter`
- point clear link to the clean workspace hub URL
- use calm enterprise styling consistent with existing Filament/Blade patterns
- avoid `Tenant`, `current tenant`, or active shell Environment wording
### 5. Apply per workspace hub
For each critical hub:
- read only the shared resolver
- apply filter only if valid
- render shared chip
- update header/scope text to avoid misleading `All environments`
- keep clean URL workspace-wide
- keep shell Workspace-scoped
- add/update tests proving canonical behavior and legacy param rejection
### 6. Classify conditional hubs
Audit Log, Alerts, Reports/Stored Reports, and Support Requests must be classified:
- if workspace-owned and Environment-filterable: implement the same contract
- if not Environment-filterable: do not pass Environment filters through CTAs and document exclusion in implementation close-out
## Data Model
No data model changes.
No migrations, seeders, backfills, stored URL migrations, compatibility transforms, retention changes, queues, scheduler, or storage volume changes.
## Security and RBAC
- Existing page authorization remains the access gate.
- Environment filter narrows visible data; it never grants access.
- Cross-workspace Environment IDs must return 404 or safe no-access.
- No shell context switch or implicit workspace switch is allowed.
- No provider external tenant ID lookup is allowed for this CTA filter.
- No secrets or Graph payload changes.
## Deployment / Operations
- No new environment variables.
- No migrations.
- No queues or scheduler changes.
- No storage persistence or volume changes.
- No package changes.
- No Dokploy-specific deployment changes expected.
- If implementation registers Filament assets, deployment must include `cd apps/platform && php artisan filament:assets`; no registered assets are expected for the planned shared chip.
## Browser Verification Plan
Use the in-app browser or existing browser test tooling after runtime changes.
Critical hubs:
- Operations
- Governance Inbox
- Decision Register
- Finding Exceptions Queue
- Provider Connections
- Evidence
- Reviews
- Customer Reviews
Flows:
1. Environment Dashboard CTA -> filtered workspace hub: URL has `environment_id`, no legacy params, workspace shell, visible chip, no misleading primary `All environments`, clear target is clean.
2. Clean sidebar/global regression: URL has no `environment_id` or legacy params, no chip, workspace-wide state.
3. Clear link smoke: clicking clear navigates to clean URL, chip disappears, workspace-wide state is visible.
Screenshots may be saved under:
```text
specs/315-environment-cta-explicit-filter-contract/artifacts/screenshots/
```
## Implementation Notes
- Use direct hard cuts.
- Do not add compatibility middleware or adapter layers.
- Do not update tests by broad rebaseline; only change tests that asserted broken legacy behavior.
- Avoid deep clear-filter work unless immediate clean URL correctness breaks; move deeper clearing to Spec 316.
- Document any intentional page exclusions in the final implementation report.

View File

@ -0,0 +1,461 @@
# Feature Specification: Environment CTA Explicit Filter Contract
**Feature Branch**: `315-environment-cta-explicit-filter-contract`
**Created**: 2026-05-16
**Status**: Draft
**Input**: User supplied Spec 315 draft for a hard cutover from legacy Environment CTA query aliases to canonical `environment_id` filters on workspace hubs.
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
- **Problem**: Environment-owned CTAs into workspace hubs can currently pass Environment focus through inconsistent query keys, hidden table state, or shell context, so operators cannot reliably tell whether a workspace hub is showing all environments or one explicit Environment filter.
- **Today's failure**: A user can enter a workspace hub from an Environment Dashboard and see data filtered by one Environment while the URL, header, shell, or filter UI implies workspace-wide scope. Legacy keys such as `tenant`, `managed_environment_id`, `tenant_scope`, and table filter structures also allow future drift.
- **User-visible improvement**: Environment Dashboard and Environment-owned CTA entry to workspace hubs uses one visible, explicit Environment filter. The URL, header/filter chip, data scope, and clear link all agree.
- **Smallest enterprise-capable version**: Introduce one request-scoped resolver for `environment_id`, one shared visible chip pattern, and update the in-scope workspace hubs and Environment-owned CTA URL builders to use that contract.
- **Explicit non-goals**: No universal clear-filter internals, no broad legacy tenant naming cleanup, no compatibility aliases, no migrations, no new runtime feature, no redesign, and no conversion of workspace hubs into Environment pages.
- **Permanent complexity imported**: One narrow Environment filter resolver/helper, one shared filter chip partial or existing shared primitive usage, focused feature/browser tests, and updated CTA URL helpers. No persisted entity, enum, status family, queue, scheduler, package, or environment variable is introduced.
- **Why now**: Spec 314 completed the sidebar/global clean-entry half of the navigation contract. This spec closes the complementary Environment CTA path before more workspace hubs and CTAs accumulate incompatible scope semantics.
- **Why not local**: The behavior spans Operations, Governance Inbox, Decision Register, Finding Exceptions, Provider Connections, Evidence, Reviews, Customer Reviews, and shared link helpers. Page-local fixes would recreate the exact drift this spec is intended to stop.
- **Approval class**: Core Enterprise.
- **Red flags triggered**: Cross-cutting navigation/filter behavior and a new resolver/helper. Defense: the helper is bounded to one canonical query key and at least two concrete current surfaces already require identical semantics.
- **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**: Workspace hub routes for Operations, Governance Inbox, Decision Register, Finding Exceptions Queue, Provider Connections / Integrations, Evidence Overview, Reviews / Review Register, Customer Review Workspace, and any Audit Log, Alerts, Reports, Stored Reports, or Support Requests workspace hub route that is confirmed Environment-filterable during implementation.
- **Data Ownership**: Workspace hubs remain workspace-owned pages. Managed Environment is a workspace-owned secondary scope used only as an explicit filter. Existing rows that belong to `managed_environment_id` or equivalent Environment scope are narrowed only when a valid `environment_id` filter is present.
- **RBAC**: Existing workspace membership and page capability checks remain authoritative. Environment filter resolution must confirm the selected Environment belongs to the current Workspace and that the user is allowed to access it. Cross-workspace Environment IDs return 404 or the existing safe no-access convention without leaking existence.
For canonical-view specs:
- **Default filter behavior when tenant-context is active**: Clean sidebar/global workspace hub entry is workspace-wide as defined by Spec 314. Active shell Environment, remembered Environment, Filament tenant fallback, session filters, or legacy query keys must not create an Environment filter on clean workspace hub entry.
- **Explicit entitlement checks preventing cross-tenant leakage**: `environment_id` is resolved as `ManagedEnvironment::query()->whereKey($environment_id)->where('workspace_id', $currentWorkspace->id)`. No lookup by provider external tenant ID, slug, remembered Environment, or `Filament::getTenant()` is valid for this contract.
## Cross-Cutting / Shared Pattern Reuse *(mandatory)*
- **Cross-cutting feature?**: yes.
- **Interaction class(es)**: navigation entry points, dashboard CTA links, header/scope wording, list/table filters, visible filter chips, clean clear links, and browser smoke verification.
- **Systems touched**: `WorkspaceHubRegistry`, workspace sidebar/global navigation contracts from Spec 314, `ManagedEnvironmentLinks`, `OperationRunLinks`, workspace hub Filament pages/resources, page Blade views, and relevant tests.
- **Existing pattern(s) to extend**: Spec 314 clean workspace hub registry and URL-cleaning behavior; current page-level visible filter summaries in Governance Inbox, Decision Register, and Customer Review Workspace; existing Filament page/table query patterns.
- **Shared contract / presenter / builder / renderer to reuse**: Reuse `WorkspaceHubRegistry` for clean hub URL generation and add or reuse a narrow `WorkspaceHubEnvironmentFilter` resolver plus one shared visible filter chip partial/primitive.
- **Why the existing shared path is sufficient or insufficient**: Spec 314 defines clean sidebar/global entry and forbidden query detection. It intentionally does not define valid Environment CTA filter resolution or visible filtered-state rendering, so a small complementary resolver is needed.
- **Allowed deviation and why**: Page-specific data filtering may stay local where each hub uses different query/table/in-memory data sources, but every page must consume the same canonical filter state and shared visible chip wording.
- **Consistency impact**: Query key, identifier type, workspace validation, chip wording, clear link target, shell behavior, and legacy key rejection must stay aligned across all in-scope hubs.
- **Review focus**: Verify no page adds dual-param support, no CTA emits legacy params, and no workspace hub uses remembered Environment or Filament tenant state as data scope.
## OperationRun UX Impact *(mandatory)*
- **Touches OperationRun start/completion/link UX?**: yes, link semantics only.
- **Shared OperationRun UX contract/layer reused**: Existing `OperationRunLinks` where it links Environment-owned surfaces to Operations or related workspace hubs.
- **Delegated start/completion UX behaviors**: Tenant/workspace-safe URL resolution only. No queued toast, run start, completion, terminal notification, or artifact behavior changes.
- **Local surface-owned behavior that remains**: Operation initiation inputs and run lifecycle behavior remain unchanged.
- **Queued DB-notification policy**: N/A.
- **Terminal notification path**: N/A.
- **Exception required?**: none.
## Provider Boundary / Platform Core Check *(mandatory)*
- **Shared provider/platform boundary touched?**: yes.
- **Boundary classification**: mixed.
- **Seams affected**: Provider Connections filter query keys, provider-adjacent Environment identifiers, workspace hub URL generation, and operator-facing scope wording.
- **Neutral platform terms preserved or introduced**: `Workspace`, `Environment`, `Workspace hub`, `Environment filter`, `Clear filter`, `environment_id`.
- **Provider-specific semantics retained and why**: Existing provider connection records and Managed Environment backing data stay unchanged. Provider external tenant IDs remain model data but are not valid CTA filter identifiers.
- **Why this does not deepen provider coupling accidentally**: The canonical CTA filter uses the platform Managed Environment database identifier and workspace ownership, not Microsoft tenant IDs or provider-specific slugs.
- **Follow-up path**: Spec 317 will handle broader legacy Tenant / Environment cleanup.
## 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 |
|---|---|---|---|---|---|---|
| Environment Dashboard and Environment-owned CTA links | yes | Existing Filament/Blade actions and link helpers | navigation, dashboard CTAs | URL-query | no | Link target query key changes to `environment_id` |
| Workspace hub header/filter area | yes | Native Filament page plus shared Blade partial or existing primitive | scope signals, filter summary | page, URL-query, table/list state | no | Adds visible Environment filter chip when active |
| Operations | yes | Existing Filament page/table | monitoring/state page | URL-query, page state, table query | no | Filter chip and canonical query support only |
| Governance Inbox | yes | Existing Filament page/table | governance queue | URL-query, page state, table query | no | Hard-cut existing visible filter to `environment_id` |
| Decision Register | yes | Existing Filament page/table | governance decision register | URL-query, page state, table query | no | Clean and filtered URLs both supported |
| Finding Exceptions Queue | yes | Existing Filament page/table | exception queue | URL-query, page state, table query | no | Legacy `tenant` query no longer accepted |
| Provider Connections / Integrations | yes | Existing Filament resource/list | provider connections | URL-query, resource/list query | no | No provider external tenant ID as CTA filter key |
| Evidence Overview | yes | Existing Filament page/list | evidence viewer | URL-query, page state, in-memory/list filtering | no | Document if rows remain in-memory filtered |
| Reviews / Review Register | yes | Existing Filament page/table | review workflow | URL-query, table query | no | Explicit Environment chip and clean clear link |
| Customer Review Workspace | yes | Existing Filament page/table | customer-safe review workspace | URL-query, table query | no | Remains workspace-scoped, not Environment-owned |
| Audit Log / Alerts / Reports / Support Requests classification | maybe | Existing pages/resources | audit/alerts/reports/support | URL-query where supported | no | Only retrofit if confirmed workspace-owned and Environment-filterable |
## 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 |
|---|---|---|---|---|---|---|---|
| Workspace hubs opened from Environment CTA | Secondary Context | Decide whether the listed workspace data is intentionally narrowed to one Environment | Workspace name, page title, Environment filter chip, clear filter link, filtered records | Existing record details, table filters, and row actions | The hub remains the decision/workspace surface; the chip makes the inherited Environment focus truthful | Aligns Environment Dashboard drilldowns with workspace hub workflows | Removes hidden scope reconstruction from URL/table/session state |
| Clean sidebar/global workspace hub entry | Secondary Context | Decide from workspace-wide data after navigating globally | Workspace-wide header/scope, no Environment chip, clean URL | Existing filters and details | This is the neutral workspace-wide entry from Spec 314 | Keeps sidebar/global behavior stable | Prevents stale Environment context from surprising operators |
## 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 |
|---|---|---|---|---|---|---|---|
| Workspace hub Environment filter chip | operator-MSP, support-platform, customer-read-only where Customer Review Workspace applies | `Environment filter: {display name}` and `Clear filter` | Existing table/list filters and row details | Existing support/raw views, if any | Clear filter or inspect rows | Raw query/table/session internals | The chip is the single visible truth for explicit Environment filter state |
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Operations | List / Table / Bulk | Monitoring/state page | Inspect operation or clear Environment filter | Existing operations inspect pattern | existing | Existing table/page actions | Existing placement; no destructive changes | `/admin/workspaces/{workspace}/operations` | existing | Workspace plus Environment filter chip when active | Operations / Operation | Whether results are workspace-wide or Environment-filtered | none |
| Governance Inbox | List / Table / Bulk | Queue | Inspect governance item or clear Environment filter | Existing inbox inspect pattern | existing | Existing table/page actions | Existing placement; no destructive changes | existing workspace hub URL | existing | Workspace plus Environment filter chip when active | Governance Inbox | Filtered Environment state | none |
| Decision Register | List / Table / Bulk | Register | Inspect decision or clear Environment filter | Existing decision inspect pattern | existing | Existing table/page actions | Existing placement; no destructive changes | existing workspace hub URL | existing | Workspace plus Environment filter chip when active | Decision Register | Filtered Environment state | none |
| Finding Exceptions Queue | List / Table / Bulk | Queue | Inspect exception or clear Environment filter | Existing exception inspect pattern | existing | Existing table/page actions | Existing placement; no destructive changes | existing workspace hub URL | existing | Workspace plus Environment filter chip when active | Finding Exceptions | Filtered Environment state | none |
| Provider Connections | List / Table / Bulk | Registry | Inspect connection or clear Environment filter | Existing provider connection inspect pattern | existing | Existing table/resource actions | Existing placement; no destructive changes | existing workspace hub URL | existing | Workspace plus Environment filter chip when active | Provider Connections | Filtered Environment state | none |
| Evidence Overview | List / Table / Bulk | Evidence viewer | Inspect evidence or clear Environment filter | Existing evidence inspect pattern | existing | Existing page actions | Existing placement; no destructive changes | existing workspace hub URL | existing | Workspace plus Environment filter chip when active | Evidence | Filtered Environment state | none |
| Reviews / Customer Reviews | List / Table / Bulk | Review workspace | Inspect review or clear Environment filter | Existing review inspect pattern | existing | Existing page/table actions | Existing placement; no destructive changes | existing workspace hub URL | existing | Workspace plus Environment filter chip when active | Reviews | Filtered Environment state | none |
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|
| Environment-filtered workspace hub | TenantPilot operator | Confirm whether the workspace hub is intentionally narrowed to one Environment, inspect rows, or clear the filter | Existing list/table/page | Am I looking at workspace-wide data or one Environment? | Workspace context, Environment filter chip, clear link, filtered rows | Existing detailed diagnostics on each page | Existing page-specific dimensions only | TenantPilot UI navigation/filter state only | Clear filter, inspect rows | None added or changed |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: no.
- **New persisted entity/table/artifact?**: no.
- **New abstraction?**: yes, a narrow request-scoped Environment filter resolver/helper is expected.
- **New enum/state/reason family?**: no.
- **New cross-domain UI framework/taxonomy?**: no.
- **Current operator problem**: Operators cannot trust visible workspace hub scope after Environment-owned CTA entry because data filters, shell context, URL params, and visible labels can diverge.
- **Existing structure is insufficient because**: `WorkspaceHubRegistry` protects clean sidebar/global entry, but it deliberately treats Environment-like query params as forbidden for clean entry and does not resolve a valid explicit Environment CTA filter. Page-local parsers currently disagree on key names, identifier type, and visible state.
- **Narrowest correct implementation**: One resolver reads only `environment_id`, validates it inside the current Workspace, and exposes display/apply/clear-url helpers. One shared chip or existing shared primitive renders the visible filter. Page-specific query application remains local.
- **Ownership cost**: Tests must cover resolver behavior, CTA URL generation, legacy param rejection, cross-workspace rejection, shell context, clear-link targets, and browser smoke flows. Reviewers must check every touched hub stays aligned.
- **Alternative intentionally rejected**: Keeping page-local parsing and only renaming a few CTAs would preserve hidden drift. A compatibility adapter accepting old and new keys is explicitly rejected because this is a hard cutover.
- **Release truth**: Current-release truth. This fixes observed system behavior after Spec 313/314, not speculative future architecture.
### Compatibility posture
This feature assumes a pre-production environment.
Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope. Canonical replacement is required.
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
- **Test purpose / classification**: Unit/Feature for resolver, URL helper, and page contracts; Browser for focused critical hub smoke flows.
- **Validation lane(s)**: fast-feedback for focused Pest tests; browser for Environment CTA, clean sidebar, and clear-link smoke verification.
- **Why this classification and these lanes are sufficient**: The risk is request/URL state, workspace isolation, visible scope truth, and cross-page navigation. Focused feature tests prove deterministic contracts, while browser smoke verifies Filament/Livewire rendered state and shell behavior.
- **New or expanded test families**: Add Spec 315 contract tests for CTA URL generation, workspace hub `environment_id` acceptance, legacy param rejection, cross-workspace rejection, shell context, primary-scope wording, clear links, sidebar regression, and Decision Register clean/filtered access.
- **Fixture / helper cost impact**: Uses existing Workspace, Managed Environment, membership/capability, and page factories/helpers. New helpers must be opt-in and local to this feature.
- **Heavy-family visibility / justification**: Browser smoke is required because the defect includes rendered shell/header/chip truth, not only query filtering. Keep it focused to the critical hub flows named in this spec.
- **Special surface test profile**: global-context-shell and monitoring-state-page.
- **Standard-native relief or required special coverage**: Standard Filament feature tests are sufficient for page loading, query state, filter chip text, and clear links; browser smoke handles integrated UI truth.
- **Reviewer handoff**: Reviewers should verify the exact commands in `tasks.md`, ensure no legacy param acceptance remains for the explicit CTA filter contract, and confirm browser screenshots for critical hubs.
- **Budget / baseline / trend impact**: No expected long-term budget increase beyond focused Spec 315 tests.
- **Escalation needed**: none unless implementation discovers a hub requires broad persisted filter clearing; that belongs in Spec 316.
- **Active feature PR close-out entry**: Guardrail and Smoke Coverage.
- **Planned validation commands**: Focused Pest commands for Spec 315 feature tests, existing Spec 314 regression tests, and a focused browser smoke command or manual browser verification if browser automation cannot be made deterministic.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Environment CTA opens a visible filtered workspace hub (Priority: P1)
An operator starts on an Environment Dashboard and opens a workspace hub CTA. The destination remains a workspace hub, but the Environment focus is explicit in the URL, visible on the page, and applied to the data.
**Why this priority**: This is the primary drift that Spec 315 fixes.
**Independent Test**: Open each critical workspace hub with `?environment_id={environment.id}` from an Environment-owned link and assert the page loads, shows the Environment filter chip, filters data to that Environment, and offers a clean clear link.
**Acceptance Scenarios**:
1. **Given** Workspace A has Environment A and the user can access Workspace A, **When** the user clicks an Environment Dashboard CTA to Operations, **Then** the URL contains `environment_id={Environment A database id}`, the page shows `Environment filter: {Environment A display name}`, and operations are filtered to Environment A.
2. **Given** a filtered workspace hub, **When** the user reviews the shell/context bar, **Then** the shell remains workspace-scoped and does not treat the hub as Environment-owned.
3. **Given** a filtered workspace hub, **When** the user sees page scope text, **Then** the primary scope does not misleadingly claim `All environments`.
---
### User Story 2 - Legacy Environment CTA params are ignored (Priority: P1)
An operator or stale link opens a workspace hub with legacy query keys. The page must not treat those keys as a valid Environment CTA filter.
**Why this priority**: The spec is a hard cutover. Accepting aliases would preserve hidden ambiguity.
**Independent Test**: For each critical hub, open URLs with `tenant`, `tenant_id`, `managed_environment_id`, `tenant_scope`, `environment`, and `tableFilters` without `environment_id`; assert no valid Environment chip appears and data remains workspace-wide or the app safely normalizes according to existing conventions.
**Acceptance Scenarios**:
1. **Given** a workspace hub URL includes `managed_environment_id=123` but no `environment_id`, **When** the page loads, **Then** no Environment filter chip is shown and the page does not filter as a valid Environment CTA filter.
2. **Given** a workspace hub URL includes `tenant=123` but no `environment_id`, **When** the page loads, **Then** the page remains workspace-wide or safely normalizes without using the legacy key.
---
### User Story 3 - Cross-workspace Environment IDs are rejected (Priority: P1)
An operator opens Workspace A hub with an Environment ID from Workspace B. The app must not leak data or switch workspace context.
**Why this priority**: Workspace isolation is a core constitution requirement.
**Independent Test**: Seed Workspace A, Workspace B, Environment B, and accessible user context for Workspace A. Open Workspace A hub with `?environment_id={Environment B id}` and assert 404 or the existing safe no-access behavior.
**Acceptance Scenarios**:
1. **Given** Environment B belongs to Workspace B, **When** the user opens Workspace A Operations with `environment_id={Environment B id}`, **Then** the app returns 404 or safe no-access, does not show Environment B, and does not switch workspace.
---
### User Story 4 - Clean sidebar/global entry remains workspace-wide (Priority: P2)
An operator moves from an Environment-owned surface to a workspace hub via sidebar or global navigation. The destination remains clean and workspace-wide.
**Why this priority**: This preserves Spec 314 while introducing Environment CTA filters.
**Independent Test**: After visiting an Environment Dashboard or filtered hub, open each critical hub through sidebar/global navigation and assert no `environment_id` or legacy Environment-like query params are present, no filter chip is visible, and workspace-wide data appears.
**Acceptance Scenarios**:
1. **Given** the user recently opened Operations with `environment_id`, **When** the user clicks the sidebar Operations entry, **Then** the URL is the clean Operations hub URL and no Environment filter chip is visible.
---
### User Story 5 - Clear filter returns to the clean hub URL (Priority: P2)
An operator on a filtered workspace hub can remove the Environment filter through a visible clear action.
**Why this priority**: Operators need an obvious exit from filtered state. Full persisted-state clear internals are intentionally deferred to Spec 316.
**Independent Test**: Open each critical hub with `environment_id`, assert the clear link exists, inspect the link target, click it in browser smoke, and assert the URL no longer contains `environment_id` or legacy params.
**Acceptance Scenarios**:
1. **Given** Governance Inbox is filtered by Environment A, **When** the user clicks `Clear filter`, **Then** the browser navigates to the clean Governance Inbox URL without `environment_id`.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: The only accepted Environment CTA filter query key for workspace hubs MUST be `environment_id`.
- **FR-002**: Workspace hubs MUST NOT treat `tenant`, `tenant_id`, `managed_environment_id`, `tenant_scope`, `environment`, or `tableFilters` as valid Environment CTA filter state.
- **FR-003**: `environment_id` MUST be the Managed Environment database identifier used by the current application model, not provider external tenant ID, slug, or display name.
- **FR-004**: Environment filter resolution MUST query the current Workspace only and reject Environment IDs outside that Workspace with 404 or the existing safe no-access behavior.
- **FR-005**: Environment filter resolution MUST NOT use `Filament::getTenant()`, remembered Environment state, session fallback, provider external tenant ID lookup, or workspace switching.
- **FR-006**: Environment-owned CTAs that intentionally open workspace hubs with Environment focus MUST emit exactly `environment_id={managed_environment_id}` for that filter and MUST NOT emit legacy Environment filter params.
- **FR-007**: A shared resolver/helper MUST expose the valid Environment filter state to pages in a way that supports `hasFilter()`, `environment()`, `environmentId()`, display name access, clear URL generation, and query application.
- **FR-008**: Each in-scope workspace hub that accepts `environment_id` MUST render a visible Environment filter chip or summary when the filter is valid.
- **FR-009**: The visible filter state MUST include the Environment display name and a clear link/action to the clean workspace hub URL.
- **FR-010**: Filtered workspace hubs MUST NOT primarily show `All environments` wording while the explicit Environment filter is active.
- **FR-011**: Filtered workspace hubs MUST keep shell/context ownership at the Workspace level and MUST NOT set active shell Environment ownership.
- **FR-012**: If `environment_id` is valid, in-scope workspace hub data MUST be filtered to that Environment.
- **FR-013**: If `environment_id` is absent, in-scope workspace hubs MUST be workspace-wide and MUST NOT apply remembered Environment state as a filter.
- **FR-014**: Clean sidebar/global workspace hub URLs from Spec 314 MUST remain clean, workspace-wide, and free of Environment query params.
- **FR-015**: The clear link target MUST remove `environment_id`, MUST emit no legacy Environment filter params, and MUST point to the clean workspace hub URL.
- **FR-016**: Operations MUST support the canonical `environment_id` filter, visible chip, filtered data, clean clear link, and clean sidebar regression.
- **FR-017**: Governance Inbox MUST hard-cut its existing Environment filter behavior to `environment_id` only.
- **FR-018**: Decision Register MUST support both clean workspace URL and `environment_id` filtered URL without requiring the filter for access.
- **FR-019**: Finding Exceptions Queue MUST replace legacy Environment CTA query behavior with `environment_id` only.
- **FR-020**: Provider Connections / Integrations MUST use `environment_id` for explicit Environment CTA filters and MUST NOT use provider external tenant ID as the filter key.
- **FR-021**: Evidence Overview MUST support canonical Environment filtering and document whether filtering is query-backed or in-memory.
- **FR-022**: Reviews and Customer Review Workspace MUST support canonical Environment filtering while remaining workspace-scoped.
- **FR-023**: Audit Log, Alerts, Reports, Stored Reports, and Support Requests MUST be classified during implementation; if Environment-filterable workspace hubs, they must follow this contract, otherwise Environment-owned CTAs must not pass an Environment filter to them.
- **FR-024**: No compatibility layer, dual-param support, legacy alias support, stored URL migration, seeders, migrations, packages, environment variables, queues, scheduler changes, or storage changes may be introduced for this spec.
### Non-Functional Requirements
- **NFR-001**: The implementation MUST preserve workspace isolation and avoid leaking whether cross-workspace Environment IDs exist.
- **NFR-002**: The implementation MUST use native Filament v5 / Livewire v4 patterns and existing shared primitives before local Blade/Tailwind customization.
- **NFR-003**: Any new shared helper MUST stay narrow and replace duplicated page-local query parsing rather than becoming a broader context framework.
- **NFR-004**: Browser verification MUST include Environment CTA flow, clean sidebar regression, and clear-link smoke for the critical hubs.
## Key Entities *(include if feature involves data)*
- **Workspace**: Primary operating scope and owner of workspace hubs.
- **ManagedEnvironment**: Secondary operational scope inside a Workspace. Used by this spec only as an explicit filter when a valid `environment_id` is present.
- **WorkspaceHubEnvironmentFilter**: Proposed request-scoped helper/resolver. It is not persisted and is not a new source of truth.
- **WorkspaceHubRegistry**: Existing workspace hub registry/URL helper from Spec 314. It remains responsible for clean hub URL generation and must align with the explicit filter path.
## In Scope
- Canonical query key `environment_id`.
- Workspace-scoped Managed Environment resolution.
- Environment-owned CTA URL generation to workspace hubs.
- Visible Environment filter chip/summary and clean clear link.
- Data filtering for in-scope hubs when the filter is valid.
- Legacy param rejection for Environment CTA filter behavior.
- Regression coverage that sidebar/global entry remains clean and workspace-wide.
- Focused browser verification and screenshots where useful.
## Out of Scope
- Full universal clear-filter internals across Livewire properties, Filament table filters, deferred filters, persisted/session filters, and back/forward state. This belongs to Spec 316.
- Broad removal of all legacy `tenant` naming or old query handling unrelated to Environment CTA filters. This belongs to Spec 317.
- Durable browser regression guard infrastructure. This belongs to Spec 318.
- Redesigning workspace hubs.
- Changing page product scope.
- Making workspace hubs Environment-owned.
- Making the sidebar dynamic based on active Environment.
- New migrations, seeders, data backfills, packages, environment variables, queues, scheduler tasks, storage volumes, or production compatibility transforms.
## Page-Specific Requirements
### Operations
- Accept only `environment_id` for explicit Environment CTA filter.
- Show visible Environment filter chip.
- Filter operations to the selected Environment.
- Avoid `managed_environment_id`, `tenant_scope`, and misleading `All environments` wording while filtered.
- Clear link points to the clean Operations URL.
### Governance Inbox
- Hard-cut existing Environment filtering to `environment_id`.
- Remove `tenant` and `managed_environment_id` acceptance for this CTA filter contract.
- Keep clean URL workspace-wide.
### Decision Register
- Keep clean workspace URL accessible.
- Support filtered URL with visible chip.
- Do not return 403 merely because the filter is absent.
- Do not accept legacy Environment query aliases.
### Finding Exceptions Queue
- Accept only `environment_id`.
- Do not use remembered Environment as data scope.
- Show visible chip and clean clear link.
- Keep clean URL workspace-wide.
### Provider Connections / Integrations
- Accept only `environment_id` for explicit Environment CTA filters.
- Do not use provider external tenant ID as the CTA filter key.
- Do not treat explicit Environment filtering as shell Environment ownership.
- Keep sidebar/global behavior workspace-wide.
### Evidence Overview
- Accept only `environment_id`.
- Show visible chip and clean clear link.
- Filter rows/search/list consistently with the chip, whether query-backed or in-memory.
### Reviews and Customer Review Workspace
- Accept only `environment_id`.
- Show visible chip and clear link.
- Keep the customer-safe workspace experience workspace-scoped.
- Do not convert Customer Review Workspace into an Environment page.
### Audit Log / Alerts / Reports / Support Requests
- Classify each during implementation.
- If workspace-owned and Environment-filterable, implement `environment_id`, visible chip, and filtered data.
- If not Environment-filterable, do not send Environment CTA filters to those pages and document the exclusion in the implementation close-out.
## Required Tests
- **CTA URL Contract**: Environment-owned CTAs to critical workspace hubs generate `environment_id` and never legacy keys.
- **Workspace Hub Accepts Canonical Filter**: Critical hubs load with `environment_id`, show chip, filter data where seeded rows prove it, and expose clean clear link.
- **Legacy Params Do Not Apply Filter**: Critical hubs ignore `tenant`, `tenant_id`, `managed_environment_id`, `tenant_scope`, `environment`, and `tableFilters` as explicit Environment filter state.
- **Invalid Environment ID Is Workspace-Scoped**: Cross-workspace IDs return 404 or safe no-access without leakage or workspace switch.
- **Shell Does Not Become Environment-Owned**: Filtered hubs keep Workspace shell context and show Environment only as page-level filter.
- **Filtered Header Does Not Say All Environments**: At least Operations, Provider Connections, Customer Reviews, and Finding Exceptions Queue avoid misleading primary scope wording.
- **Clear Link Points To Clean Hub URL**: Critical hubs link back to clean workspace hub URLs without `environment_id` or legacy params.
- **Sidebar Contract Still Holds**: Spec 314 clean sidebar/global entry remains clean and workspace-wide.
- **Decision Register Filter Contract**: Clean and filtered Decision Register URLs both work, with no legacy query accepted.
## Browser Verification Required
Critical pages:
- Operations
- Governance Inbox
- Decision Register
- Finding Exceptions Queue
- Provider Connections
- Evidence
- Reviews
- Customer Reviews
Required flows:
1. **Environment Dashboard CTA**: Open Environment Dashboard, click CTA, verify `environment_id`, no legacy params, workspace shell, visible chip, no misleading `All environments`, clean clear target, and capture screenshot.
2. **Clean Sidebar Regression**: From Environment context, click sidebar/global entry to the same hub, verify clean URL, no chip, workspace-wide state, and capture screenshot where useful.
3. **Clear Link Smoke**: From filtered page, click clear link, verify clean URL, chip disappears, and page is workspace-wide. Do not assert full persisted-state cleanup beyond immediate visible correctness.
Suggested screenshots path:
```text
specs/315-environment-cta-explicit-filter-contract/artifacts/screenshots/
```
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: Every in-scope Environment-owned CTA to a workspace hub emits `environment_id` and no legacy Environment filter query params.
- **SC-002**: Every critical workspace hub accepts valid `environment_id`, shows a visible Environment filter chip, filters data to that Environment, and exposes a clean clear link.
- **SC-003**: Every critical workspace hub ignores legacy Environment CTA params as filter state.
- **SC-004**: Cross-workspace Environment IDs are rejected without data leakage.
- **SC-005**: Clean sidebar/global workspace hub entry remains clean and workspace-wide after visiting filtered hubs.
- **SC-006**: Required focused Pest tests and browser verification pass.
## Assumptions
- Spec 314 is complete and its clean sidebar/global workspace hub behavior is the baseline.
- There is no production data or production environment to preserve, so hard cutover is acceptable.
- Existing Workspace and Managed Environment models/factories can support the required tests without new persistence.
- Existing authorization conventions already distinguish non-member/not-found and member-missing-capability behavior.
## Risks
- **Persisted Filament filter state can reapply after clear**: Spec 315 only requires clean hub URL behavior and immediate visible correctness; full clear semantics belong to Spec 316.
- **Provider Connections currently mixes external IDs and Managed Environment identifiers**: Implementation must be explicit that CTA filtering uses database `environment_id`.
- **Large surface area**: Tests and shared resolver/chip are mandatory to prevent another page-local drift cycle.
## Required Final Implementation Report
When implementation is complete, report:
```text
Spec 315 completed.
Changed behavior:
...
Canonical filter:
environment_id
Files changed:
...
Tests:
- command:
- result:
Browser verification:
...
Remaining follow-ups:
- 316:
- 317:
- 318:
No migrations were created.
No seeders were changed.
No packages, env vars, queues, scheduler, or storage changes were made.
No backwards compatibility layer was introduced.
No legacy query alias support was preserved for Environment CTA filtering.
```
Also include:
- list of hubs supporting `environment_id`
- list of CTAs updated
- list of legacy params removed or ignored
- pages intentionally excluded
- clear-filter limitations deferred to Spec 316
## Follow-Up Specs
- **316 - Workspace Hub Clear Filter Contract**: Full clearing across URL query, Livewire state, Filament table filters, deferred filters, persisted/session state, visible chips, reload safety, and back/forward stale state.
- **317 - Legacy Tenant / Environment Context Cleanup**: Remove/quarantine old `tenant` aliases, old `managed_environment_id` query handling where no longer valid, workspace hub `Filament::getTenant()` usage, remembered Environment as data boundary, stale tenant naming, and compatibility seams.
- **318 - Browser Regression Coverage / No-Drift Guard**: Durable browser/regression coverage for sidebar/global versus Environment CTA behavior.
## Filament v5 Output Contract
1. **Livewire v4.0+ compliance**: This spec targets the current app stack: Filament v5 with Livewire v4. No Livewire v3 APIs or assumptions are allowed.
2. **Provider registration location**: No new Filament panel provider is expected. If implementation discovers provider registration work, Laravel 12 requires panel providers in `apps/platform/bootstrap/providers.php`, not `bootstrap/app.php`.
3. **Global search**: No resource is made globally searchable by this spec. Existing globally searchable resources must still have Edit/View pages; Provider Connections currently remains disabled for global search unless a separate spec changes it.
4. **Destructive actions**: No destructive actions are added or changed. If any implementation path touches a destructive action, it must use `->action(...)`, `->requiresConfirmation()`, and existing authorization policy checks.
5. **Asset strategy**: No new heavy assets are expected. If a shared Blade partial uses existing CSS/classes only, no asset deployment change is needed. If Filament assets are registered, deployment must include `cd apps/platform && php artisan filament:assets`.
6. **Testing plan**: Cover Filament pages/resources as Livewire components or feature routes following current Pest conventions. Mutating actions are not part of this spec; URL, filter, shell, and visible-chip contracts are the required test surface.

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