Compare commits
1 Commits
dev
...
147-tenant
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de0679cd8b |
3
.github/agents/copilot-instructions.md
vendored
3
.github/agents/copilot-instructions.md
vendored
@ -82,6 +82,7 @@ ## Active Technologies
|
||||
- PHP 8.4.15 with Laravel 12, Filament v5, Livewire v4.0+ + Filament Actions/Tables/Infolists, Laravel Gates/Policies, `UiEnforcement`, `WorkspaceUiEnforcement`, `ActionSurfaceDeclaration`, `BadgeCatalog`, `TenantOperabilityService`, `OnboardingLifecycleService` (145-tenant-action-taxonomy-lifecycle-safe-visibility)
|
||||
- PostgreSQL for tenants, onboarding sessions, audit logs, operation runs, and workspace membership data (145-tenant-action-taxonomy-lifecycle-safe-visibility)
|
||||
- PostgreSQL (existing tenant and operation records only; no schema changes planned) (146-central-tenant-status-presentation)
|
||||
- PostgreSQL plus existing session-backed workspace and remembered-tenant context; no schema change planned (147-tenant-selector-remembered-context-enforcement)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -101,8 +102,8 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 147-tenant-selector-remembered-context-enforcement: Added PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Tailwind CSS 4
|
||||
- 146-central-tenant-status-presentation: Added PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Tailwind CSS 4
|
||||
- 145-tenant-action-taxonomy-lifecycle-safe-visibility: Added PHP 8.4.15 with Laravel 12, Filament v5, Livewire v4.0+ + Filament Actions/Tables/Infolists, Laravel Gates/Policies, `UiEnforcement`, `WorkspaceUiEnforcement`, `ActionSurfaceDeclaration`, `BadgeCatalog`, `TenantOperabilityService`, `OnboardingLifecycleService`
|
||||
- 144-canonical-operation-viewer-context-decoupling: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Gates and Policies, `OperateHubShell`, `OperationRunLinks`
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
<!-- MANUAL ADDITIONS END -->
|
||||
|
||||
@ -68,7 +68,16 @@ public function selectTenant(int $tenantId): void
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
if ($workspaceId === null) {
|
||||
$this->redirect(route('filament.admin.pages.choose-workspace'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$tenant = Tenant::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->whereKey($tenantId)
|
||||
->first();
|
||||
|
||||
@ -86,7 +95,9 @@ public function selectTenant(int $tenantId): void
|
||||
|
||||
$this->persistLastTenant($user, $tenant);
|
||||
|
||||
app(WorkspaceContext::class)->rememberTenantContext($tenant, request());
|
||||
if (! app(WorkspaceContext::class)->rememberTenantContext($tenant, request())) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$this->redirect(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
|
||||
}
|
||||
|
||||
@ -5,7 +5,6 @@
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Resources\FindingResource\Pages;
|
||||
use App\Models\Finding;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantMembership;
|
||||
@ -174,17 +173,7 @@ public static function infolist(Schema $schema): Schema
|
||||
TextEntry::make('subject_display_name')
|
||||
->label('Subject')
|
||||
->placeholder('—')
|
||||
->state(function (Finding $record): ?string {
|
||||
$state = $record->subject_display_name;
|
||||
if (is_string($state) && trim($state) !== '') {
|
||||
return $state;
|
||||
}
|
||||
|
||||
$fallback = Arr::get($record->evidence_jsonb ?? [], 'display_name');
|
||||
$fallback = is_string($fallback) ? trim($fallback) : null;
|
||||
|
||||
return $fallback !== '' ? $fallback : null;
|
||||
}),
|
||||
->state(fn (Finding $record): ?string => $record->resolvedSubjectDisplayName()),
|
||||
TextEntry::make('subject_type')
|
||||
->label('Subject type')
|
||||
->formatStateUsing(fn (mixed $state, Finding $record): string => static::subjectTypeLabel($record, $state)),
|
||||
@ -603,16 +592,7 @@ public static function table(Table $table): Table
|
||||
->label('Subject')
|
||||
->placeholder('—')
|
||||
->searchable()
|
||||
->formatStateUsing(function (?string $state, Finding $record): ?string {
|
||||
if (is_string($state) && trim($state) !== '') {
|
||||
return $state;
|
||||
}
|
||||
|
||||
$fallback = Arr::get($record->evidence_jsonb ?? [], 'display_name');
|
||||
$fallback = is_string($fallback) ? trim($fallback) : null;
|
||||
|
||||
return $fallback !== '' ? $fallback : null;
|
||||
})
|
||||
->state(fn (Finding $record): ?string => $record->resolvedSubjectDisplayName())
|
||||
->description(fn (Finding $record): ?string => static::driftContextDescription($record)),
|
||||
Tables\Columns\TextColumn::make('subject_type')
|
||||
->label('Subject type')
|
||||
@ -1113,13 +1093,7 @@ public static function getEloquentQuery(): Builder
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
->with(['assigneeUser', 'ownerUser', 'closedByUser'])
|
||||
->addSelect([
|
||||
'subject_display_name' => InventoryItem::query()
|
||||
->select('display_name')
|
||||
->whereColumn('inventory_items.tenant_id', 'findings.tenant_id')
|
||||
->whereColumn('inventory_items.external_id', 'findings.subject_external_id')
|
||||
->limit(1),
|
||||
])
|
||||
->withSubjectDisplayName()
|
||||
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId));
|
||||
}
|
||||
|
||||
|
||||
@ -224,6 +224,10 @@ public static function getEloquentQuery(): Builder
|
||||
|
||||
public static function getGlobalSearchEloquentQuery(): Builder
|
||||
{
|
||||
if (app(WorkspaceContext::class)->currentWorkspaceId(request()) === null) {
|
||||
return static::getEloquentQuery()->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
return static::tenantOperability()->applySelectableScope(
|
||||
static::getEloquentQuery(),
|
||||
(new Tenant)->getTable(),
|
||||
|
||||
@ -6,7 +6,6 @@
|
||||
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Models\Finding;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
@ -42,16 +41,7 @@ public function table(Table $table): Table
|
||||
->label('Subject')
|
||||
->placeholder('—')
|
||||
->limit(40)
|
||||
->formatStateUsing(function (?string $state, Finding $record): ?string {
|
||||
if (is_string($state) && trim($state) !== '') {
|
||||
return $state;
|
||||
}
|
||||
|
||||
$fallback = Arr::get($record->evidence_jsonb ?? [], 'display_name');
|
||||
$fallback = is_string($fallback) ? trim($fallback) : null;
|
||||
|
||||
return $fallback !== '' ? $fallback : null;
|
||||
})
|
||||
->state(fn (Finding $record): ?string => $record->resolvedSubjectDisplayName())
|
||||
->description(function (Finding $record): ?string {
|
||||
if (Arr::get($record->evidence_jsonb ?? [], 'summary.kind') !== 'rbac_role_definition') {
|
||||
return null;
|
||||
@ -59,17 +49,7 @@ public function table(Table $table): Table
|
||||
|
||||
return __('findings.drift.rbac_role_definition');
|
||||
})
|
||||
->tooltip(function (Finding $record): ?string {
|
||||
$displayName = $record->subject_display_name;
|
||||
|
||||
if (is_string($displayName) && trim($displayName) !== '') {
|
||||
return $displayName;
|
||||
}
|
||||
|
||||
$fallback = Arr::get($record->evidence_jsonb ?? [], 'display_name');
|
||||
|
||||
return is_string($fallback) && trim($fallback) !== '' ? trim($fallback) : null;
|
||||
}),
|
||||
->tooltip(fn (Finding $record): ?string => $record->resolvedSubjectDisplayName()),
|
||||
TextColumn::make('severity')
|
||||
->badge()
|
||||
->sortable()
|
||||
@ -106,13 +86,7 @@ private function getQuery(): Builder
|
||||
$tenantId = $tenant instanceof Tenant ? $tenant->getKey() : null;
|
||||
|
||||
return Finding::query()
|
||||
->addSelect([
|
||||
'subject_display_name' => InventoryItem::query()
|
||||
->select('display_name')
|
||||
->whereColumn('inventory_items.tenant_id', 'findings.tenant_id')
|
||||
->whereColumn('inventory_items.external_id', 'findings.subject_external_id')
|
||||
->limit(1),
|
||||
])
|
||||
->withSubjectDisplayName()
|
||||
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||
->latest('created_at');
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Support\Tenants\TenantPageCategory;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
@ -15,14 +16,31 @@ public function __invoke(Request $request): RedirectResponse
|
||||
{
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
app(WorkspaceContext::class)->clearLastTenantId($request);
|
||||
$workspaceContext = app(WorkspaceContext::class);
|
||||
|
||||
$workspaceContext->clearRememberedTenantContext($request);
|
||||
|
||||
$previousUrl = url()->previous();
|
||||
|
||||
$previousHost = parse_url((string) $previousUrl, PHP_URL_HOST);
|
||||
$previousPath = (string) (parse_url((string) $previousUrl, PHP_URL_PATH) ?? '');
|
||||
|
||||
if ($previousHost !== null && $previousHost !== $request->getHost()) {
|
||||
return redirect()->to('/admin/operations');
|
||||
return redirect()->route('admin.operations.index');
|
||||
}
|
||||
|
||||
if (TenantPageCategory::fromPath($previousPath) === TenantPageCategory::TenantBound) {
|
||||
$workspace = $workspaceContext->currentWorkspace($request);
|
||||
|
||||
if ($workspace !== null) {
|
||||
return redirect()->route('admin.workspace.managed-tenants.index', ['workspace' => $workspace]);
|
||||
}
|
||||
|
||||
return redirect()->route('admin.home');
|
||||
}
|
||||
|
||||
if ($previousPath === '' || $previousPath === '/admin/clear-tenant-context') {
|
||||
return redirect()->route('admin.operations.index');
|
||||
}
|
||||
|
||||
return redirect()->to((string) $previousUrl);
|
||||
|
||||
@ -53,7 +53,9 @@ public function __invoke(Request $request): RedirectResponse
|
||||
|
||||
$this->persistLastTenant($user, $tenant);
|
||||
|
||||
app(WorkspaceContext::class)->rememberTenantContext($tenant, $request);
|
||||
if (! app(WorkspaceContext::class)->rememberTenantContext($tenant, $request)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return redirect()->to(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use App\Support\Workspaces\WorkspaceIntendedUrl;
|
||||
use App\Support\Workspaces\WorkspaceRedirectResolver;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
@ -47,6 +48,8 @@ public function __invoke(Request $request): RedirectResponse
|
||||
$prevWorkspaceId = $context->currentWorkspaceId($request);
|
||||
|
||||
$context->setCurrentWorkspace($workspace, $user, $request);
|
||||
$context->rememberedTenant($request);
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
/** @var WorkspaceAuditLogger $auditLogger */
|
||||
$auditLogger = app(WorkspaceAuditLogger::class);
|
||||
|
||||
@ -3,9 +3,11 @@
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class Finding extends Model
|
||||
{
|
||||
@ -195,4 +197,29 @@ public function reopen(array $evidence): void
|
||||
$this->evidence_jsonb = $evidence;
|
||||
$this->save();
|
||||
}
|
||||
|
||||
public function resolvedSubjectDisplayName(): ?string
|
||||
{
|
||||
$displayName = $this->getAttribute('subject_display_name');
|
||||
|
||||
if (is_string($displayName) && trim($displayName) !== '') {
|
||||
return trim($displayName);
|
||||
}
|
||||
|
||||
$fallback = Arr::get($this->evidence_jsonb ?? [], 'display_name');
|
||||
$fallback = is_string($fallback) ? trim($fallback) : null;
|
||||
|
||||
return $fallback !== '' ? $fallback : null;
|
||||
}
|
||||
|
||||
public function scopeWithSubjectDisplayName(Builder $query): Builder
|
||||
{
|
||||
return $query->addSelect([
|
||||
'subject_display_name' => InventoryItem::query()
|
||||
->select('display_name')
|
||||
->whereColumn('inventory_items.tenant_id', 'findings.tenant_id')
|
||||
->whereColumn('inventory_items.external_id', 'findings.subject_external_id')
|
||||
->limit(1),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -171,9 +171,16 @@ public function getDefaultTenant(Panel $panel): ?Model
|
||||
return null;
|
||||
}
|
||||
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||
$workspaceContext = app(WorkspaceContext::class);
|
||||
$workspaceId = $workspaceContext->currentWorkspaceId();
|
||||
$operability = app(TenantOperabilityService::class);
|
||||
|
||||
$rememberedTenant = $workspaceContext->rememberedTenant(request());
|
||||
|
||||
if ($rememberedTenant instanceof Tenant && $this->canAccessTenant($rememberedTenant)) {
|
||||
return $rememberedTenant;
|
||||
}
|
||||
|
||||
$tenantId = null;
|
||||
|
||||
if ($this->tenantPreferencesTableExists()) {
|
||||
|
||||
@ -77,6 +77,12 @@ public function handle(Request $request, Closure $next): Response
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if ($path === '/admin/operations/'.$request->route('run')) {
|
||||
$this->configureNavigationForRequest($panel);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$tenantParameter = null;
|
||||
if ($request->route()?->hasParameter('tenant')) {
|
||||
$tenantParameter = $request->route()->parameter('tenant');
|
||||
|
||||
@ -89,22 +89,18 @@ private function resolveActiveTenant(?Request $request = null): ?Tenant
|
||||
$pageCategory = $this->pageCategory($request);
|
||||
$routeTenant = $this->resolveRouteTenant($request, $pageCategory);
|
||||
|
||||
if ($request?->route()?->hasParameter('tenant')) {
|
||||
return $routeTenant;
|
||||
}
|
||||
|
||||
if ($routeTenant instanceof Tenant) {
|
||||
return $routeTenant;
|
||||
}
|
||||
|
||||
$tenant = Filament::getTenant();
|
||||
$tenant = $this->resolveValidatedFilamentTenant($request, $pageCategory);
|
||||
|
||||
if ($tenant instanceof Tenant && $this->isEntitled($tenant, $request, $pageCategory)) {
|
||||
if ($tenant instanceof Tenant) {
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
if ($tenant instanceof Tenant && ! $this->tenantOperabilityService->canSelectAsContext($tenant)) {
|
||||
Filament::setTenant(null, true);
|
||||
if ($pageCategory === TenantPageCategory::TenantBound) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$rememberedTenant = $this->workspaceContext->rememberedTenant($request);
|
||||
@ -113,8 +109,8 @@ private function resolveActiveTenant(?Request $request = null): ?Tenant
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $this->isEntitled($rememberedTenant, $request, $pageCategory)) {
|
||||
$this->workspaceContext->clearLastTenantId($request);
|
||||
if (! $this->isEntitled($rememberedTenant, $request, TenantPageCategory::WorkspaceScoped)) {
|
||||
$this->workspaceContext->clearRememberedTenantContext($request);
|
||||
|
||||
return null;
|
||||
}
|
||||
@ -122,6 +118,25 @@ private function resolveActiveTenant(?Request $request = null): ?Tenant
|
||||
return $rememberedTenant;
|
||||
}
|
||||
|
||||
private function resolveValidatedFilamentTenant(?Request $request = null, ?TenantPageCategory $pageCategory = null): ?Tenant
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$pageCategory ??= $this->pageCategory($request);
|
||||
|
||||
if ($this->isEntitled($tenant, $request, $pageCategory)) {
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function resolveRouteTenant(?Request $request = null, ?TenantPageCategory $pageCategory = null): ?Tenant
|
||||
{
|
||||
$route = $request?->route();
|
||||
|
||||
@ -88,6 +88,12 @@ public function rememberTenantContext(Tenant $tenant, ?Request $request = null):
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $this->userCanAccessTenant($tenant, $request)) {
|
||||
$this->clearRememberedTenantContext($request);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->rememberLastTenantId($workspaceId, (int) $tenant->getKey(), $request);
|
||||
|
||||
return true;
|
||||
@ -129,6 +135,11 @@ public function clearLastTenantId(?Request $request = null): void
|
||||
$session->put(self::LAST_TENANT_IDS_SESSION_KEY, $map);
|
||||
}
|
||||
|
||||
public function clearRememberedTenantContext(?Request $request = null): void
|
||||
{
|
||||
$this->clearLastTenantId($request);
|
||||
}
|
||||
|
||||
public function rememberedTenant(?Request $request = null): ?Tenant
|
||||
{
|
||||
$workspaceId = $this->currentWorkspaceId($request);
|
||||
@ -149,19 +160,25 @@ public function rememberedTenant(?Request $request = null): ?Tenant
|
||||
->first();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
$this->clearLastTenantId($request);
|
||||
$this->clearRememberedTenantContext($request);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if ((int) $tenant->workspace_id !== $workspaceId) {
|
||||
$this->clearLastTenantId($request);
|
||||
$this->clearRememberedTenantContext($request);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $this->userCanAccessTenant($tenant, $request)) {
|
||||
$this->clearRememberedTenantContext($request);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $this->tenantOperabilityService->canSelectAsContext($tenant)) {
|
||||
$this->clearLastTenantId($request);
|
||||
$this->clearRememberedTenantContext($request);
|
||||
|
||||
return null;
|
||||
}
|
||||
@ -242,4 +259,15 @@ private function isWorkspaceSelectable(Workspace $workspace): bool
|
||||
{
|
||||
return empty($workspace->archived_at);
|
||||
}
|
||||
|
||||
private function userCanAccessTenant(Tenant $tenant, ?Request $request = null): bool
|
||||
{
|
||||
$user = $request?->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
$user = auth()->user();
|
||||
}
|
||||
|
||||
return $user instanceof User && $user->canAccessTenant($tenant);
|
||||
}
|
||||
}
|
||||
|
||||
@ -24,7 +24,7 @@ class="h-7 w-7 text-primary-500 dark:text-primary-400"
|
||||
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-white">No active tenants available</h3>
|
||||
<p class="mx-auto mt-2 max-w-xs text-sm text-gray-500 dark:text-gray-400">
|
||||
There are no selectable Active tenants in this workspace. View managed tenants to inspect Onboarding or Archived records, or switch to a different workspace.
|
||||
There are no selectable active tenants for the normal operating context in this workspace. Workspace-level pages still work with no tenant selected, and you can inspect onboarding or archived records through managed tenants.
|
||||
</p>
|
||||
|
||||
<div class="mt-6 flex flex-col items-center gap-3">
|
||||
@ -63,7 +63,8 @@ class="inline-flex items-center gap-1.5 text-sm text-gray-500 transition-colors
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mb-4 text-sm text-gray-500 dark:text-gray-400">Select a tenant to continue.</p>
|
||||
<p class="mb-2 text-sm text-gray-500 dark:text-gray-400">Select the tenant for your normal active operating context.</p>
|
||||
<p class="mb-4 text-xs text-gray-500 dark:text-gray-400">No tenant selected is still a valid workspace state on workspace-wide pages such as operations and managed tenants.</p>
|
||||
|
||||
{{-- Tenant cards --}}
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-{{ min($tenants->count(), 3) }}">
|
||||
|
||||
@ -3,9 +3,6 @@
|
||||
use App\Filament\Pages\ChooseWorkspace;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Auth\WorkspaceRoleCapabilityMap;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use App\Support\Tenants\TenantPageCategory;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
@ -18,32 +15,12 @@
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
$canSeeAllWorkspaceTenants = false;
|
||||
if ($user instanceof User && $workspace) {
|
||||
$roles = WorkspaceRoleCapabilityMap::rolesWithCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE);
|
||||
|
||||
$canSeeAllWorkspaceTenants = WorkspaceMembership::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->where('user_id', (int) $user->getKey())
|
||||
->whereIn('role', $roles)
|
||||
->exists();
|
||||
}
|
||||
|
||||
$tenants = collect();
|
||||
if ($user instanceof User && $workspace) {
|
||||
if ($canSeeAllWorkspaceTenants) {
|
||||
$tenants = Tenant::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->where('status', Tenant::STATUS_ACTIVE)
|
||||
->orderBy('name')
|
||||
->orderBy('environment')
|
||||
->get();
|
||||
} else {
|
||||
$tenants = collect($user->getTenants(Filament::getCurrentOrDefaultPanel()))
|
||||
->filter(fn ($tenant): bool => $tenant instanceof Tenant && (int) $tenant->workspace_id === (int) $workspace->getKey())
|
||||
->values();
|
||||
}
|
||||
}
|
||||
|
||||
$operateHubShell = app(OperateHubShell::class);
|
||||
$currentTenant = $operateHubShell->activeEntitledTenant(request());
|
||||
@ -69,10 +46,13 @@
|
||||
$tenantLabel = $currentTenantName ?? 'All tenants';
|
||||
$workspaceLabel = $workspace?->name ?? 'Select workspace';
|
||||
$hasActiveTenant = $currentTenantName !== null;
|
||||
$managedTenantsUrl = $workspace
|
||||
? route('admin.workspace.managed-tenants.index', ['workspace' => $workspace])
|
||||
: route('admin.onboarding');
|
||||
$workspaceUrl = $workspace
|
||||
? route('admin.home')
|
||||
: ChooseWorkspace::getUrl(panel: 'admin');
|
||||
$tenantTriggerLabel = $workspace ? $tenantLabel : 'Select tenant';
|
||||
$tenantTriggerLabel = $workspace ? ($hasActiveTenant ? $tenantLabel : 'No tenant selected') : 'Select tenant';
|
||||
@endphp
|
||||
|
||||
<div class="inline-flex items-center gap-0 rounded-lg border border-gray-200 bg-white text-sm dark:border-white/10 dark:bg-white/5">
|
||||
@ -144,12 +124,8 @@ class="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-gray-700 transi
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
||||
Tenant scope
|
||||
Selected tenant
|
||||
</div>
|
||||
|
||||
@if ($canSeeAllWorkspaceTenants)
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500">all visible</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($isTenantScopedRoute)
|
||||
@ -165,10 +141,20 @@ class="ml-auto text-xs font-medium text-primary-600 hover:text-primary-500 dark:
|
||||
</div>
|
||||
@else
|
||||
@if ($tenants->isEmpty())
|
||||
<div class="rounded-lg border border-dashed border-gray-300 px-3 py-3 text-center text-xs text-gray-500 dark:border-gray-600 dark:text-gray-400">
|
||||
{{ $canSeeAllWorkspaceTenants ? 'No tenants in this workspace.' : 'No accessible tenants.' }}
|
||||
<div class="space-y-2 rounded-lg border border-dashed border-gray-300 px-3 py-3 text-center text-xs text-gray-500 dark:border-gray-600 dark:text-gray-400">
|
||||
<div>No active tenants are available for the standard operating context in this workspace.</div>
|
||||
<a href="{{ $managedTenantsUrl }}" class="inline-flex items-center gap-1 text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300">
|
||||
<x-filament::icon icon="heroicon-o-arrow-top-right-on-square" class="h-3.5 w-3.5" />
|
||||
View managed tenants
|
||||
</a>
|
||||
</div>
|
||||
@else
|
||||
@if (! $hasActiveTenant)
|
||||
<div class="rounded-lg bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:bg-white/5 dark:text-gray-400">
|
||||
No tenant selected. Workspace-wide pages remain available, and choosing a tenant only sets the normal active operating context.
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<input
|
||||
type="text"
|
||||
class="fi-input fi-text-input w-full"
|
||||
@ -209,7 +195,7 @@ class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition {{
|
||||
@csrf
|
||||
|
||||
<button type="submit" class="w-full rounded-lg px-3 py-1.5 text-left text-xs font-medium text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-200">
|
||||
Clear tenant scope
|
||||
Clear selected tenant
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
|
||||
@ -136,7 +136,6 @@
|
||||
DispatchServingFilamentEvent::class,
|
||||
FilamentAuthenticate::class,
|
||||
'ensure-workspace-selected',
|
||||
'ensure-filament-tenant-selected',
|
||||
])
|
||||
->get('/admin/operations', \App\Filament\Pages\Monitoring\Operations::class)
|
||||
->name('admin.operations.index');
|
||||
@ -195,7 +194,6 @@
|
||||
DispatchServingFilamentEvent::class,
|
||||
FilamentAuthenticate::class,
|
||||
'ensure-workspace-selected',
|
||||
'ensure-filament-tenant-selected',
|
||||
])
|
||||
->get('/admin/operations/{run}', \App\Filament\Pages\Operations\TenantlessOperationRunViewer::class)
|
||||
->name('admin.operations.view');
|
||||
|
||||
@ -0,0 +1,36 @@
|
||||
# Specification Quality Checklist: Tenant Selector and Remembered Context Enforcement
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-03-16
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Validated against the completed spec on 2026-03-16.
|
||||
- No unresolved clarification markers remain.
|
||||
- The spec stays aligned with Specs 143, 144, and 146 by treating workspace as primary, remembered tenant as a revalidated preference, and canonical routes as record-authoritative.
|
||||
@ -0,0 +1,211 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Tenant Context Enforcement Contract
|
||||
version: 0.1.0
|
||||
description: >-
|
||||
Internal route and state contract for Spec 147. This feature does not introduce
|
||||
new public APIs; the contract documents the expected behavior of the existing
|
||||
workspace and tenant context routes plus the shared context-resolution object.
|
||||
paths:
|
||||
/admin/choose-tenant:
|
||||
get:
|
||||
summary: Render the standard active-lane tenant selector
|
||||
operationId: showChooseTenant
|
||||
responses:
|
||||
'200':
|
||||
description: Choose-tenant page rendered for the current workspace
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ChooseTenantSurfaceContract'
|
||||
/admin/select-tenant:
|
||||
post:
|
||||
summary: Persist active-lane tenant context for the current workspace
|
||||
operationId: selectTenantContext
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- tenant_id
|
||||
properties:
|
||||
tenant_id:
|
||||
type: integer
|
||||
responses:
|
||||
'302':
|
||||
description: Tenant context accepted and redirect issued to the tenant-lane destination
|
||||
'404':
|
||||
description: Tenant missing, outside workspace, not entitled, or not eligible for standard active-lane selection
|
||||
/admin/clear-tenant-context:
|
||||
post:
|
||||
summary: Clear remembered tenant context for the current workspace
|
||||
operationId: clearTenantContext
|
||||
responses:
|
||||
'302':
|
||||
description: Tenant context cleared and redirect issued to a workspace-safe destination
|
||||
/admin/switch-workspace:
|
||||
post:
|
||||
summary: Switch the current workspace and re-evaluate tenant context
|
||||
operationId: switchWorkspace
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- workspace_id
|
||||
properties:
|
||||
workspace_id:
|
||||
type: integer
|
||||
responses:
|
||||
'302':
|
||||
description: Workspace switched and tenant context cleared or re-evaluated for the new workspace
|
||||
'404':
|
||||
description: Workspace missing, archived, or actor is not a member
|
||||
/admin/tenants/{tenant}:
|
||||
get:
|
||||
summary: Resolve tenant-bound route subject independently from selected tenant context
|
||||
operationId: viewTenantRouteSubject
|
||||
parameters:
|
||||
- in: path
|
||||
name: tenant
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Authorized tenant-bound page renders from route subject authority
|
||||
'404':
|
||||
description: Tenant missing or actor not entitled to tenant scope
|
||||
'403':
|
||||
description: Actor is a tenant member but lacks a required capability for the page or action
|
||||
/admin/operations/{run}:
|
||||
get:
|
||||
summary: Resolve canonical workspace record viewer independently from selected tenant context
|
||||
operationId: viewCanonicalOperationRun
|
||||
parameters:
|
||||
- in: path
|
||||
name: run
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Authorized canonical run viewer renders even when selected tenant differs or is absent
|
||||
'404':
|
||||
description: Run missing, workspace membership missing, or tenant entitlement missing for a referenced tenant
|
||||
'403':
|
||||
description: Actor is otherwise in scope but lacks the required capability for the run type
|
||||
components:
|
||||
schemas:
|
||||
ActiveSelectorOption:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- tenantId
|
||||
- workspaceId
|
||||
- lifecycle
|
||||
- isEligible
|
||||
properties:
|
||||
tenantId:
|
||||
type: integer
|
||||
workspaceId:
|
||||
type: integer
|
||||
lifecycle:
|
||||
type: string
|
||||
enum:
|
||||
- draft
|
||||
- onboarding
|
||||
- active
|
||||
- archived
|
||||
isEligible:
|
||||
type: boolean
|
||||
description: Must be true only for tenants eligible for standard active-lane selection.
|
||||
selectorLabel:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
RememberedTenantContextState:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- workspaceId
|
||||
- status
|
||||
properties:
|
||||
workspaceId:
|
||||
type: integer
|
||||
tenantId:
|
||||
type:
|
||||
- integer
|
||||
- 'null'
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- no_selected_tenant
|
||||
- remembered_active
|
||||
- route_authoritative_tenant
|
||||
- stale_context_cleared
|
||||
invalidationReason:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
enum:
|
||||
- workspace_mismatch
|
||||
- tenant_missing
|
||||
- tenant_not_entitled
|
||||
- tenant_not_selector_eligible
|
||||
- explicit_clear
|
||||
- null
|
||||
ChooseTenantSurfaceContract:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- workspaceId
|
||||
- selectorMeaning
|
||||
- options
|
||||
properties:
|
||||
workspaceId:
|
||||
type: integer
|
||||
selectorMeaning:
|
||||
type: string
|
||||
const: normal_active_operating_context
|
||||
options:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ActiveSelectorOption'
|
||||
emptyStateBehavior:
|
||||
type: string
|
||||
enum:
|
||||
- no_active_tenants_available
|
||||
- workspace_selection_required
|
||||
RouteLegitimacyContract:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- routeType
|
||||
- authority
|
||||
- selectedTenantInfluence
|
||||
properties:
|
||||
routeType:
|
||||
type: string
|
||||
enum:
|
||||
- workspace_level
|
||||
- tenant_bound
|
||||
- canonical_workspace_record
|
||||
authority:
|
||||
type: string
|
||||
enum:
|
||||
- workspace
|
||||
- route_tenant
|
||||
- route_record
|
||||
selectedTenantInfluence:
|
||||
type: string
|
||||
enum:
|
||||
- none
|
||||
- informational_only
|
||||
- filter_only
|
||||
@ -0,0 +1,161 @@
|
||||
# Data Model: Tenant Selector and Remembered Context Enforcement
|
||||
|
||||
## Overview
|
||||
|
||||
This feature does not introduce a new persistence model. It formalizes how existing workspace, tenant, route, and remembered-context concepts interact at the shell and routing layers.
|
||||
|
||||
## Entities
|
||||
|
||||
### 1. Workspace Context
|
||||
|
||||
**Represents**: The primary operating boundary for the current admin session.
|
||||
|
||||
**Key fields**:
|
||||
- `workspace_id`
|
||||
- `workspace.slug`
|
||||
- `workspace.archived_at`
|
||||
|
||||
**Relationships**:
|
||||
- Owns many tenants
|
||||
- Owns many canonical workspace records such as operation runs
|
||||
- Owns one session-scoped remembered tenant preference map entry per user session
|
||||
|
||||
**Validation rules**:
|
||||
- Must exist
|
||||
- Must not be archived for active selection
|
||||
- Actor must be a workspace member or the request resolves as deny-as-not-found
|
||||
|
||||
### 2. Tenant
|
||||
|
||||
**Represents**: A workspace-owned tenant record that may be active, onboarding, draft, or archived.
|
||||
|
||||
**Key fields**:
|
||||
- `id`
|
||||
- `workspace_id`
|
||||
- `external_id`
|
||||
- `status`
|
||||
- `deleted_at`
|
||||
- operator-facing identity fields such as `name`, `domain`, `environment`
|
||||
|
||||
**Relationships**:
|
||||
- Belongs to one workspace
|
||||
- May be a route subject on tenant-bound pages
|
||||
- May be referenced by canonical workspace records such as operation runs
|
||||
- May be persisted as remembered active-lane context only when selector-eligible
|
||||
|
||||
**Validation rules**:
|
||||
- For standard active selector use, tenant must belong to current workspace, remain entitled to the actor, exist, and satisfy active-lane eligibility
|
||||
- For tenant-bound route validity, tenant must satisfy workspace and entitlement checks; selector eligibility is not required
|
||||
|
||||
### 3. Remembered Tenant Context
|
||||
|
||||
**Represents**: A workspace-scoped user/session preference for the last active-lane tenant.
|
||||
|
||||
**Key fields**:
|
||||
- `workspace_last_tenant_ids[workspace_id]` session entry
|
||||
- optional persisted recency signals through `users.last_tenant_id` or `user_tenant_preferences.last_used_at`
|
||||
|
||||
**Relationships**:
|
||||
- Belongs logically to one workspace context
|
||||
- References one tenant candidate for active-lane convenience
|
||||
|
||||
**Validation rules**:
|
||||
- Must only be read inside an established workspace context
|
||||
- Must resolve to an existing tenant in the current workspace
|
||||
- Must satisfy entitlement-sensitive access checks
|
||||
- Must satisfy active-lane eligibility checks
|
||||
- Invalid values must be cleared or ignored deterministically
|
||||
|
||||
### 4. Active Selector Option
|
||||
|
||||
**Represents**: A tenant that is eligible to appear in the standard active tenant selector.
|
||||
|
||||
**Derived from**:
|
||||
- `TenantOperabilityDecision.canSelectAsContext`
|
||||
|
||||
**Required attributes**:
|
||||
- tenant identity
|
||||
- lifecycle presentation
|
||||
- selector-safe label and helper copy
|
||||
|
||||
**Validation rules**:
|
||||
- Must not include `draft`, `onboarding`, or `archived` tenants under the current lifecycle model
|
||||
- Must not include tenants outside the current workspace
|
||||
- Must not include tenants the actor cannot access
|
||||
|
||||
### 5. Route Subject
|
||||
|
||||
**Represents**: The record made authoritative by the current route.
|
||||
|
||||
**Variants**:
|
||||
- tenant-bound route subject: `Tenant`
|
||||
- canonical workspace record subject: `OperationRun` or other workspace-owned records
|
||||
|
||||
**Validation rules**:
|
||||
- Route subject legitimacy is resolved from route record identity plus policy checks
|
||||
- Remembered tenant context may influence convenience UI only and must not replace route authority
|
||||
|
||||
## Derived Domain Objects
|
||||
|
||||
### Tenant Operability Decision
|
||||
|
||||
**Represents**: The existing lifecycle-aware rule set produced by `TenantOperabilityService`.
|
||||
|
||||
**Relevant flags for this feature**:
|
||||
- `canSelectAsContext`
|
||||
- `canViewTenantSurface`
|
||||
- `canReferenceInWorkspaceMonitoring`
|
||||
|
||||
### Shell Context Resolution Result
|
||||
|
||||
**Represents**: The runtime decision for what tenant, if any, the shell should treat as active convenience context.
|
||||
|
||||
**Possible states**:
|
||||
- `route_authoritative_tenant`
|
||||
- `validated_selected_tenant`
|
||||
- `no_selected_tenant`
|
||||
- `stale_context_cleared`
|
||||
|
||||
## State Transitions
|
||||
|
||||
### Remembered Tenant Context Lifecycle
|
||||
|
||||
1. `unset`
|
||||
- No remembered tenant exists for the current workspace.
|
||||
2. `remembered_active`
|
||||
- An active-lane-eligible tenant is selected and stored for the current workspace.
|
||||
3. `revalidated_active`
|
||||
- A later request reads the remembered tenant and confirms workspace match, entitlement, existence, and selector eligibility.
|
||||
4. `invalidated_cleared`
|
||||
- A later request detects stale or ineligible remembered tenant context and clears or ignores it.
|
||||
5. `no_selected_tenant`
|
||||
- The shell falls back to a legitimate workspace-level no-tenant state.
|
||||
|
||||
**Invalidation triggers**:
|
||||
- workspace switch
|
||||
- tenant no longer exists
|
||||
- tenant no longer belongs to current workspace
|
||||
- tenant no longer satisfies active-lane eligibility
|
||||
- actor no longer has tenant entitlement where required
|
||||
|
||||
### Page Semantics By Category
|
||||
|
||||
#### Workspace-level page
|
||||
- Accepts `remembered_active`, `invalidated_cleared`, or `no_selected_tenant`
|
||||
- Selected tenant may become a filter only
|
||||
|
||||
#### Tenant-bound page
|
||||
- Route tenant is authoritative
|
||||
- Selected tenant may match, differ, or be absent
|
||||
|
||||
#### Canonical workspace record viewer
|
||||
- Route record is authoritative
|
||||
- Referenced tenant may differ from selected tenant without invalidating the page
|
||||
|
||||
## Invariants
|
||||
|
||||
- Workspace is always the primary context boundary.
|
||||
- Remembered tenant context never broadens authorization.
|
||||
- Standard selector membership never implies universal tenant discoverability.
|
||||
- Route legitimacy always outranks selected tenant context.
|
||||
- No-selected-tenant is a valid workspace shell state.
|
||||
173
specs/147-tenant-selector-remembered-context-enforcement/plan.md
Normal file
173
specs/147-tenant-selector-remembered-context-enforcement/plan.md
Normal file
@ -0,0 +1,173 @@
|
||||
# Implementation Plan: Tenant Selector and Remembered Context Enforcement
|
||||
|
||||
**Branch**: `147-tenant-selector-remembered-context-enforcement` | **Date**: 2026-03-16 | **Spec**: [specs/147-tenant-selector-remembered-context-enforcement/spec.md](./spec.md)
|
||||
**Input**: Feature specification from `/specs/147-tenant-selector-remembered-context-enforcement/spec.md`
|
||||
|
||||
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
|
||||
|
||||
## Summary
|
||||
|
||||
Harden tenant context handling around one workspace-scoped truth model: the standard selector represents only the normal active operating lane, remembered tenant context is a revalidated convenience preference, and route legitimacy remains driven by record identity plus policy rather than header state. Implement the feature by consolidating active-lane eligibility around `TenantOperabilityService`, consolidating remembered-context validation and invalidation inside `WorkspaceContext`, and narrowing shell resolution in `OperateHubShell`, the header context bar, choose-tenant flow, and workspace-level pages so stale or non-active tenants cannot leak into active selection or break canonical and tenant-bound pages.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4.15
|
||||
**Primary Dependencies**: Laravel 12, Filament 5, Livewire 4, Tailwind CSS 4
|
||||
**Storage**: PostgreSQL plus existing session-backed workspace and remembered-tenant context; no schema change planned
|
||||
**Testing**: Pest 4 feature and unit tests, including Filament and Livewire coverage
|
||||
**Target Platform**: Laravel Sail web application on the Filament admin panel and workspace-canonical admin routes
|
||||
**Project Type**: Web application monolith
|
||||
**Performance Goals**: Keep tenant-context resolution render-safe and DB/session-only, add no external calls or background work, and verify that the shell, choose-tenant page, tenant detail, and operations viewer do not introduce material query-count regressions in the focused regression suite
|
||||
**Constraints**: Preserve Spec 143 workspace-first semantics, Spec 144 canonical viewer safety, and Spec 146 lifecycle presentation; keep Livewire v4 and Filament v5 patterns intact; keep provider registration unchanged in `bootstrap/providers.php`; no new panel or tenancy model; no raw session-truth assumptions; no authorization broadening
|
||||
**Scale/Scope**: Workspace shell context, choose-tenant flow, remembered tenant invalidation, tenant-bound route safety, and canonical operations viewer safety across existing `/admin` routes and the shared support layer
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
- Inventory-first: PASS. This feature only changes selector and shell context semantics over existing workspace, tenant, and operation records.
|
||||
- Read/write separation: PASS. No new business write workflows, preview flows, or destructive mutations are introduced; existing context POST endpoints remain lightweight preference updates only.
|
||||
- Graph contract path: PASS. No Microsoft Graph calls are added or changed.
|
||||
- Deterministic capabilities: PASS. Capability resolution remains central; no new capability derivation paths are introduced.
|
||||
- RBAC-UX: PASS WITH ENFORCEMENT FOCUS. Admin-plane separation, deny-as-not-found semantics for non-members/non-entitled actors, and 403 for in-scope capability denials remain unchanged. The implementation must remove any hidden reliance on selected tenant equality as an authorization shortcut.
|
||||
- Workspace isolation: PASS. Workspace remains primary context, and workspace switching must continue to clear or re-evaluate tenant preference per workspace.
|
||||
- RBAC-UX destructive confirmation: PASS. No new destructive-like actions are introduced.
|
||||
- RBAC-UX global search: PASS WITH REQUIRED AUDIT. The implementation must audit workspace-context global search so remembered tenant state cannot leak tenant-owned results or hints when no active tenant is selected.
|
||||
- Tenant isolation: PASS. Tenant selection and remembered tenant resolution remain workspace-scoped and entitlement-checked.
|
||||
- Run observability: PASS. No `OperationRun` creation or mutation changes are planned.
|
||||
- Ops-UX 3-surface feedback: PASS. Not applicable because no operational workflow or run feedback changes are introduced.
|
||||
- Ops-UX lifecycle/service ownership: PASS. No `OperationRun.status` or `outcome` transitions are touched.
|
||||
- Ops-UX summary counts/guards/system runs: PASS. Not applicable.
|
||||
- Automation/data minimization: PASS. No new queued work or logging paths.
|
||||
- Badge semantics (BADGE-001): PASS. Lifecycle and mismatch surfaces continue consuming the existing centralized lifecycle presentation introduced by Spec 146.
|
||||
- UI naming (UI-NAMING-001): PASS. Copy must keep `workspace`, `selected tenant`, `no tenant selected`, `viewed tenant`, and `run tenant` semantically distinct.
|
||||
- Filament UI Action Surface Contract: PASS. Existing action surfaces remain, but the header context bar, choose-tenant page, and canonical run viewer messaging must keep their current action inventories consistent and must not reintroduce contradictory selector affordances.
|
||||
- Filament UI UX-001 (Layout & IA): PASS. Layout changes are not required. The plan only adjusts context semantics, fallback behavior, and informational messaging inside existing surfaces.
|
||||
- UI-STD-001 list surface checklist: PASS WITH REQUIRED REVIEW. Because `/admin/tenants` and `/admin/operations` are modified list surfaces, implementation and validation must reference `docs/product/standards/list-surface-review-checklist.md`.
|
||||
|
||||
**Post-Design Re-check**: PASS. Phase 1 design keeps the feature read-only from an operational perspective, centralizes selector and remembered-context rules in existing support services, preserves Filament v5 + Livewire v4 architecture, and leaves panel registration in `bootstrap/providers.php` unchanged.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/147-tenant-selector-remembered-context-enforcement/
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
│ └── tenant-context-enforcement.openapi.yaml
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
app/
|
||||
├── Filament/
|
||||
│ ├── Concerns/
|
||||
│ └── Pages/
|
||||
│ ├── Monitoring/
|
||||
│ ├── Operations/
|
||||
│ └── Workspaces/
|
||||
├── Http/
|
||||
│ ├── Controllers/
|
||||
│ └── Middleware/
|
||||
├── Models/
|
||||
│ ├── Tenant.php
|
||||
│ ├── User.php
|
||||
│ └── Workspace.php
|
||||
├── Policies/
|
||||
├── Services/
|
||||
│ ├── Auth/
|
||||
│ └── Tenants/
|
||||
└── Support/
|
||||
├── OperateHub/
|
||||
├── Tenants/
|
||||
└── Workspaces/
|
||||
resources/
|
||||
└── views/
|
||||
└── filament/
|
||||
├── pages/
|
||||
└── partials/
|
||||
routes/
|
||||
└── web.php
|
||||
tests/
|
||||
├── Feature/
|
||||
│ ├── Filament/
|
||||
│ ├── Onboarding/
|
||||
│ ├── Operations/
|
||||
│ └── Rbac/
|
||||
└── Unit/
|
||||
```
|
||||
|
||||
**Structure Decision**: Use the existing Laravel monolith and extend the current support-layer seams instead of creating a new selector subsystem. `app/Services/Tenants/TenantOperabilityService.php` remains the source for active-lane eligibility, `app/Support/Workspaces/WorkspaceContext.php` becomes the authoritative remembered-context validation and invalidation layer, `app/Support/OperateHub/OperateHubShell.php` becomes the route-safe shell resolver, and the affected UI surfaces stay in their current Filament pages and Blade partials.
|
||||
|
||||
## Phase 0 Research Summary
|
||||
|
||||
- Confirmed the app already has the core building blocks for this feature: `TenantOperabilityService::canSelectAsContext()` defines active-lane eligibility, `WorkspaceContext` already stores remembered tenant IDs keyed by workspace, and `OperateHubShell::activeEntitledTenant()` already resolves route tenant, Filament tenant, and remembered tenant in one place.
|
||||
- Confirmed the current contradiction sources are architectural rather than missing primitives: the header context bar builds its own tenant list, `ChooseTenant` and `SelectTenantController` duplicate selection checks, and shell resolution still mixes route-authoritative and remembered-context behavior in ways that can leave stale or conflicting state alive longer than intended.
|
||||
- Confirmed route safety already exists in targeted places: `OperationRunPolicy` uses workspace membership, tenant entitlement, and capability checks without relying on selected tenant equality; `TenantlessOperationRunViewer` already has mismatch-aware banner logic. Spec 147 therefore needs to make the shell stop undermining these route-safe pages.
|
||||
- Confirmed `ChooseTenant` already filters to selectable tenants and its empty state already frames “No active tenants available”, which aligns with Spec 147 and suggests the plan should converge the header selector onto the same meaning rather than invent a new chooser role.
|
||||
- Confirmed Filament v5 tenancy docs support tenant menu customization, but this app already uses a custom workspace-first context bar and custom choose-tenant page. The correct direction is to keep the custom shell and centralize its rules, not to revert to the default Filament tenant switcher.
|
||||
|
||||
## Phase 1 Design
|
||||
|
||||
### Implementation Approach
|
||||
|
||||
1. Treat active selector eligibility as one shared contract.
|
||||
- Keep `TenantOperabilityService` as the single source of truth for whether a tenant belongs in the standard active selector.
|
||||
- Remove or collapse local header/query logic that can drift from `ChooseTenant` and `SelectTenantController` semantics.
|
||||
2. Treat remembered tenant context as one shared workspace-scoped validation flow.
|
||||
- Strengthen `WorkspaceContext` so remembered tenant reads always validate workspace match, existence, entitlement-sensitive access, and active-lane eligibility before returning a tenant.
|
||||
- Expose explicit fallback semantics so stale remembered state deterministically clears to “no selected tenant”.
|
||||
3. Split shell convenience from route legitimacy.
|
||||
- Narrow `OperateHubShell` to prefer route-authoritative tenants on tenant-bound pages, prefer canonical record legitimacy on workspace-level record viewers, and use remembered tenant only for active-lane convenience where appropriate.
|
||||
- Ensure mismatch stays informational on canonical and tenant-bound pages instead of influencing whether the page resolves.
|
||||
4. Align all selector surfaces to the same lane meaning.
|
||||
- Refactor the header context bar and choose-tenant page to consume the same eligibility semantics and similar empty/no-context framing.
|
||||
- Ensure workspace switch and tenant clear flows both result in consistent context state and recovery behavior.
|
||||
5. Preserve non-active tenant discoverability outside the selector.
|
||||
- Keep managed-tenant and onboarding/admin discovery surfaces intentionally usable for onboarding and archived tenants even while those tenants are excluded from the active selector.
|
||||
- Ensure workspace-level managed-tenant administration remains usable with no selected tenant.
|
||||
6. Audit workspace-context search and add regression coverage around the architectural promises.
|
||||
- Verify global search does not use remembered tenant context to expose tenant-owned results when no active tenant is selected.
|
||||
- Cover active selector membership, stale remembered-context invalidation, no-selected-tenant workspace behavior, tenant-bound route mismatch, canonical run mismatch, and workspace switch isolation.
|
||||
7. Preserve list-surface and render safety while hardening context semantics.
|
||||
- Review `/admin/tenants` and `/admin/operations` against `docs/product/standards/list-surface-review-checklist.md`.
|
||||
- Add focused validation that context resolution remains render-safe and does not introduce material query-count regressions in the main shell and viewer flows.
|
||||
|
||||
### Planned Workstreams
|
||||
|
||||
- **Workstream A: Shared context resolution**
|
||||
Introduce or refine shared methods in `WorkspaceContext` and `OperateHubShell` for validated remembered tenant lookup, explicit context clearing, and workspace-safe fallback.
|
||||
- **Workstream B: Selector surface convergence**
|
||||
Update the header context bar, choose-tenant page, and `SelectTenantController` so all active-lane selections use the same eligibility rule and no non-active tenant can appear in one selector path but fail downstream.
|
||||
- **Workstream C: Route-safe shell behavior**
|
||||
Audit the tenant-bound and canonical workspace routes in scope, especially `/admin/tenants/{tenant}` and `/admin/operations/{run}`, so selected-tenant mismatch remains display-only and never becomes a legitimacy gate.
|
||||
- **Workstream D: Discoverability and search safety**
|
||||
Preserve managed-tenant and onboarding/admin discovery surfaces for non-active tenants and audit workspace-context global search so remembered tenant state cannot leak tenant-owned results.
|
||||
- **Workstream E: Regression hardening**
|
||||
Add focused Pest tests at the unit and feature level around remembered-context invalidation, selector membership, managed-tenant no-context behavior, workspace switching, global-search safety, and mismatched tenant behavior on tenant-bound and canonical pages.
|
||||
- **Workstream F: List-surface and render-safety validation**
|
||||
Review the affected list surfaces against `docs/product/standards/list-surface-review-checklist.md` and include focused render/query-safety checks for the shell, chooser, tenant detail, and canonical viewer flows.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- Add unit coverage for `WorkspaceContext` remembered-tenant revalidation and invalidation rules, including workspace mismatch, missing tenant, selector-ineligible lifecycle, and explicit clear behavior.
|
||||
- Add unit or feature coverage for `OperateHubShell` active tenant resolution rules across workspace-level pages, tenant-bound pages, and canonical record viewers.
|
||||
- Add focused feature tests for header and choose-tenant membership semantics so only active tenants are selectable in the normal active-lane flow.
|
||||
- Add focused feature tests for managed-tenant discovery surfaces so onboarding and archived tenants remain intentionally discoverable outside the selector and `/admin/tenants` remains usable with no selected tenant.
|
||||
- Add focused feature tests for workspace switch and clear-tenant flows so stale remembered tenant context does not bleed across workspaces and no-selected-tenant remains legitimate.
|
||||
- Update or add canonical viewer tests confirming `/admin/operations/{run}` remains valid under mismatched, cleared, or stale tenant context.
|
||||
- Update or add tenant-bound page tests confirming `/admin/tenants/{tenant}` remains route-authoritative when selected tenant differs or is absent.
|
||||
- Add a focused global-search audit or regression test confirming workspace-context search does not surface tenant-owned results because of remembered tenant state.
|
||||
- Validate the affected `/admin/tenants` and `/admin/operations` list surfaces against `docs/product/standards/list-surface-review-checklist.md`.
|
||||
- Add focused render/query-safety assertions or instrumentation in the targeted regression suite so the plan's performance goal is explicitly verified rather than implicit.
|
||||
- Run the minimum focused Pest suite through Sail, then ask whether to run the full suite.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
No constitution violations or exceptional complexity are planned at this stage.
|
||||
@ -0,0 +1,69 @@
|
||||
# Quickstart: Tenant Selector and Remembered Context Enforcement
|
||||
|
||||
## Goal
|
||||
|
||||
Implement Spec 147 so tenant selection, remembered tenant context, and route legitimacy follow one workspace-first model across the header selector, choose-tenant page, workspace switching, tenant-bound routes, and canonical workspace record viewers.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Consolidate remembered tenant validation in `app/Support/Workspaces/WorkspaceContext.php`.
|
||||
- Add or refine shared methods that return only validated remembered tenant context.
|
||||
- Ensure stale values clear deterministically for workspace mismatch, missing tenant, entitlement loss, and selector-ineligible lifecycle.
|
||||
|
||||
2. Converge selector membership on `app/Services/Tenants/TenantOperabilityService.php`.
|
||||
- Remove or simplify any custom header-selector queries that can diverge from `ChooseTenant`.
|
||||
- Keep active-lane semantics aligned across the header context bar, choose-tenant page, and `SelectTenantController`.
|
||||
|
||||
3. Refine shell resolution in `app/Support/OperateHub/OperateHubShell.php`.
|
||||
- Prefer route-authoritative tenants on tenant-bound pages.
|
||||
- Prefer validated remembered tenant only for workspace-level active-lane convenience.
|
||||
- Preserve canonical page legitimacy when selected tenant differs or is absent.
|
||||
|
||||
4. Update in-scope UI surfaces.
|
||||
- Header context bar: show only eligible active-lane tenants and keep “clear tenant scope” behavior workspace-safe.
|
||||
- Choose-tenant page: preserve the same active-lane meaning and empty-state framing.
|
||||
- Managed-tenant administration: keep onboarding and archived tenants discoverable and keep `/admin/tenants` usable without selected tenant state.
|
||||
- Tenant-bound and canonical pages: keep mismatch informational and non-blocking.
|
||||
|
||||
5. Audit workspace-context global search.
|
||||
- Ensure no-selected-tenant workspace context does not use remembered tenant state to surface tenant-owned results or hints.
|
||||
|
||||
6. Add regression coverage.
|
||||
- Unit tests for remembered-context invalidation and shell resolution.
|
||||
- Feature tests for selector membership, managed-tenant discoverability, workspace switching, no-selected-tenant workspace behavior, global-search safety, tenant-bound mismatch, and canonical run mismatch.
|
||||
|
||||
7. Review affected list surfaces against `docs/product/standards/list-surface-review-checklist.md`.
|
||||
- Validate that `/admin/tenants` and `/admin/operations` still satisfy the established list-surface standards while selector semantics and no-context fallback are hardened.
|
||||
|
||||
8. Validate render and query safety.
|
||||
- Confirm the focused regression suite covers the shell, choose-tenant page, tenant detail, and canonical operations viewer without introducing material query-count regressions.
|
||||
|
||||
## Suggested Test Commands
|
||||
|
||||
Run focused tests through Sail:
|
||||
|
||||
```bash
|
||||
vendor/bin/sail artisan test --compact tests/Unit
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Operations
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Rbac
|
||||
```
|
||||
|
||||
Format changed files:
|
||||
|
||||
```bash
|
||||
vendor/bin/sail bin pint --dirty --format agent
|
||||
```
|
||||
|
||||
## Manual Verification Checklist
|
||||
|
||||
1. Select an active tenant from the header selector and confirm the tenant dashboard or active-lane flow resolves normally.
|
||||
2. Confirm onboarding and archived tenants do not appear in the header selector or choose-tenant page.
|
||||
3. Switch workspaces and confirm the previous workspace's remembered tenant does not remain active in the new workspace.
|
||||
4. Clear tenant scope and confirm `/admin` and `/admin/operations` remain usable with no selected tenant.
|
||||
5. Confirm `/admin/tenants` remains usable with no selected tenant and still exposes onboarding or archived records through the intended management surfaces.
|
||||
6. Open `/admin/tenants/{tenant}` for an authorized onboarding or archived tenant and confirm the page remains valid.
|
||||
7. Verify workspace-context global search with no selected tenant does not reveal tenant-owned results because of remembered tenant state.
|
||||
8. Open `/admin/operations/{run}` with mismatched or empty selected tenant context and confirm the canonical page still renders with non-blocking mismatch framing.
|
||||
9. Review `/admin/tenants` and `/admin/operations` against `docs/product/standards/list-surface-review-checklist.md` and confirm inspection, empty-state, and filter behavior remain compliant.
|
||||
10. Confirm the focused regression suite or local instrumentation shows no material query-count regression on the shell, choose-tenant page, tenant detail, and canonical operations viewer flows.
|
||||
@ -0,0 +1,61 @@
|
||||
# Research: Tenant Selector and Remembered Context Enforcement
|
||||
|
||||
## Decision 1: Keep active-lane selector eligibility anchored in `TenantOperabilityService`
|
||||
|
||||
**Decision**: Use `App\Services\Tenants\TenantOperabilityService` as the single authoritative rule for standard selector membership and require the header selector, choose-tenant page, and selection controllers to converge on that service.
|
||||
|
||||
**Rationale**: The service already encapsulates lifecycle-aware operability and exposes `canSelectAsContext()` and `filterSelectable()`. Spec 147 needs consistency, not a second selector rule. Reusing this service directly keeps Spec 143 lifecycle semantics intact and avoids per-surface drift.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Build a new selector-specific eligibility service: rejected because it would duplicate `canSelectAsContext()` semantics and create a new place for lifecycle drift.
|
||||
- Let each surface query `status = active` independently: rejected because it would bypass existing operability semantics and repeat the exact fragmentation this spec is intended to remove.
|
||||
|
||||
## Decision 2: Centralize remembered tenant revalidation in `WorkspaceContext`
|
||||
|
||||
**Decision**: Make `App\Support\Workspaces\WorkspaceContext` the authoritative place that validates, returns, clears, and invalidates remembered tenant context for active-lane use.
|
||||
|
||||
**Rationale**: `WorkspaceContext` already owns the session map keyed by workspace and already clears stale remembered IDs when workspace or lifecycle checks fail. It is the natural home for enforcing the workspace-scoped preference contract required by Spec 147.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Revalidate remembered tenant context separately in controllers, Livewire pages, and Blade partials: rejected because it preserves scattered raw-session truth assumptions.
|
||||
- Move remembered-context validation into `OperateHubShell`: rejected because shell resolution should consume validated context, not become the storage owner.
|
||||
|
||||
## Decision 3: Keep the custom workspace-first shell instead of reverting to Filament's default tenant switcher
|
||||
|
||||
**Decision**: Continue using the existing custom context bar and custom choose-tenant page, but align both to one active-lane rule set.
|
||||
|
||||
**Rationale**: The product architecture is explicitly workspace first. Filament's tenant switcher is tenant-menu-centric, while this app already uses a workspace-scoped shell, explicit workspace switching, and canonical workspace-level routes. Docs confirm Filament tenant menu customization exists, but the custom shell is already the correct product pattern here.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Replace the custom selector with Filament's built-in tenant switcher: rejected because it would pull the product back toward a tenant-first mental model and would not solve the workspace-scoped remembered-context rules already present in the app.
|
||||
- Keep the custom shell but allow it to diverge from the choose-tenant page: rejected because Spec 147 explicitly requires shared semantics between both selector surfaces.
|
||||
|
||||
## Decision 4: Treat route authority and shell context as separate layers
|
||||
|
||||
**Decision**: Preserve route-authority semantics for tenant-bound and canonical routes and use shell context only for convenience, not for legitimacy.
|
||||
|
||||
**Rationale**: `OperationRunPolicy` already authorizes canonical runs by workspace membership, tenant entitlement, and capability checks. `TenantlessOperationRunViewer` already renders non-blocking context banners. Spec 147 should extend this pattern consistently through shell resolution instead of reintroducing tenant-context-coupled validity at the page level.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Use selected tenant equality as a fast validity shortcut for tenant-bound or canonical pages: rejected because it conflicts with Specs 143 and 144 and causes false invalidity.
|
||||
- Auto-switch the selected tenant to match the route record: rejected because it makes page rendering mutate user context implicitly and hides the distinction between route subject and current active lane.
|
||||
|
||||
## Decision 5: “No tenant selected” is a first-class valid shell state
|
||||
|
||||
**Decision**: Treat no-selected-tenant as the correct fallback state for workspace-level pages after invalidation, workspace switch, or explicit clear.
|
||||
|
||||
**Rationale**: Workspace-level surfaces such as `/admin` and `/admin/operations` are legitimate without an active tenant. Spec 147 requires graceful degradation to a valid workspace-wide view instead of hidden failure when remembered context becomes stale.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Force tenant reselection before allowing workspace-level pages: rejected because it would contradict the workspace-first architecture.
|
||||
- Keep stale remembered tenant until the user manually clears it: rejected because it would preserve hidden authority and inconsistent page behavior.
|
||||
|
||||
## Decision 6: No schema change is required for this feature
|
||||
|
||||
**Decision**: Implement Spec 147 as behavior consolidation over existing session-backed workspace tenant preferences and existing user preference persistence paths.
|
||||
|
||||
**Rationale**: The app already stores current workspace in session and last tenant IDs in a workspace-keyed session map, with supplemental persistence through `users.last_tenant_id` or `user_tenant_preferences`. The problem is validation and consumption semantics, not missing storage.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Introduce a new tenant-context table or richer persisted context model: rejected because the spec explicitly says remembered tenant context is a convenience preference, not a durable parallel ownership model.
|
||||
- Persist no tenant preference at all: rejected because remembered context remains a supported convenience feature within the workspace-scoped shell.
|
||||
221
specs/147-tenant-selector-remembered-context-enforcement/spec.md
Normal file
221
specs/147-tenant-selector-remembered-context-enforcement/spec.md
Normal file
@ -0,0 +1,221 @@
|
||||
# Feature Specification: Tenant Selector and Remembered Context Enforcement
|
||||
|
||||
**Feature Branch**: `147-tenant-selector-remembered-context-enforcement`
|
||||
**Created**: 2026-03-16
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Spec 147 — Tenant Selector and Remembered Context Enforcement"
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: workspace + tenant selector + remembered tenant context + canonical-view-adjacent shell behavior
|
||||
- **Primary Routes**:
|
||||
- `/admin`
|
||||
- `/admin/choose-workspace`
|
||||
- `/admin/choose-tenant`
|
||||
- `/admin/tenants`
|
||||
- `/admin/tenants/{tenant}`
|
||||
- `/admin/operations`
|
||||
- `/admin/operations/{run}`
|
||||
- Any in-scope route that reads, restores, or reacts to remembered tenant context
|
||||
- **Data Ownership**:
|
||||
- Workspaces remain the primary context and ownership boundary.
|
||||
- Tenants remain workspace-owned records.
|
||||
- Remembered tenant context remains a workspace-scoped operator preference rather than a durable authorization or ownership object.
|
||||
- Canonical workspace-level records such as operation runs remain authoritative on their own routes even when they reference a tenant.
|
||||
- This feature does not introduce a new tenant data model and does not change workspace or tenant ownership boundaries.
|
||||
- **RBAC**:
|
||||
- Authorization planes involved: tenant/admin `/admin` workspace-scoped routes, tenant-bound routes, and canonical workspace record viewers that may reference a tenant.
|
||||
- Non-members or users lacking tenant entitlement for a route-resolved record remain deny-as-not-found.
|
||||
- In-scope members lacking a required capability for an otherwise visible page or action remain forbidden.
|
||||
- Remembered tenant context must not substitute for membership, entitlement, capability checks, or route identity.
|
||||
|
||||
For canonical-view specs, the spec MUST define:
|
||||
|
||||
- **Default filter behavior when tenant-context is active**: Workspace-level indexes such as `/admin` and `/admin/operations` may prefilter to the currently selected tenant as a convenience, but clearing, invalidating, or mismatching remembered tenant context must fall back to a valid no-tenant workspace view rather than changing route legitimacy.
|
||||
- **Explicit entitlement checks preventing cross-tenant leakage**: Canonical and tenant-bound pages must resolve access from workspace relationship, route record identity, and referenced tenant entitlement where applicable. Remembered tenant context may inform copy or filters but must never reveal unauthorized tenant details or become a shortcut for record visibility.
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Trust The Active Tenant Selector (Priority: P1)
|
||||
|
||||
As a workspace operator, I need the standard tenant selector and choose-tenant flow to show only valid active-lane tenants, so that selecting a tenant always feels like entering a safe normal operating context rather than a hidden trap.
|
||||
|
||||
**Why this priority**: The selector is top-level shell behavior. If it offers ineligible tenants, the rest of the workspace feels structurally unreliable.
|
||||
|
||||
**Independent Test**: Can be fully tested by preparing active, draft, onboarding, and archived tenants in one workspace and confirming that only active tenants are selectable through the header selector and choose-tenant page while non-active tenants remain discoverable elsewhere.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a workspace contains active, draft, onboarding, and archived tenants, **When** the operator opens the standard tenant selector, **Then** only active tenants are presented as selectable active operating context.
|
||||
2. **Given** a workspace contains onboarding or archived tenants, **When** the operator opens the choose-tenant page, **Then** those tenants are not offered as active-lane choices and the page uses the same selection meaning as the header selector.
|
||||
3. **Given** a tenant is excluded from the standard selector because of lifecycle, **When** the operator visits the correct management, onboarding, audit, or canonical-view surface, **Then** the tenant remains discoverable there rather than disappearing from the product.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Recover Safely From Stale Remembered Context (Priority: P2)
|
||||
|
||||
As a workspace operator, I need remembered tenant context to be revalidated and cleared gracefully when it becomes stale or ineligible, so that workspace-level pages stay usable and the shell does not route me into confusing breakage.
|
||||
|
||||
**Why this priority**: Stale remembered context is the main way hidden shell state turns into false invalidity. Safe fallback is necessary for a trustworthy workspace-first model.
|
||||
|
||||
**Independent Test**: Can be fully tested by storing remembered context for an active tenant, changing that tenant to an ineligible lifecycle or switching workspaces, and confirming the shell falls back to a legitimate no-tenant state without breaking workspace-level routes.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** remembered tenant context points to a tenant that becomes archived, **When** the operator next opens an in-scope workspace page, **Then** the system clears or ignores the stale remembered tenant for active-lane use and presents a valid fallback workspace view.
|
||||
2. **Given** remembered tenant context belongs to a different workspace, **When** the operator switches workspaces, **Then** the previous workspace's remembered tenant does not bleed into the new workspace as active context.
|
||||
3. **Given** remembered tenant context refers to a tenant the operator is no longer entitled to view, **When** an in-scope page reads active context, **Then** the UI recovers to a no-tenant or cleared-filter state instead of treating the stale preference as authoritative.
|
||||
4. **Given** no tenant is currently selected, **When** the operator opens a workspace-level managed-tenant surface such as `/admin/tenants`, **Then** the page remains usable as a workspace-scoped administrative surface rather than failing because active tenant context is absent.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Keep Route Legitimacy Separate From Header Context (Priority: P3)
|
||||
|
||||
As a workspace operator, I need tenant-bound pages and canonical record viewers to remain valid based on the route record and policy even when the selected tenant differs or is missing, so that direct links and investigations behave predictably.
|
||||
|
||||
**Why this priority**: Route legitimacy is a core architectural promise from earlier specs. This feature needs to harden that promise at the shell level so the selector stops acting like a hidden validity gate.
|
||||
|
||||
**Independent Test**: Can be fully tested by opening tenant detail pages and canonical operation-run pages while remembered tenant context is mismatched, invalid, or empty and verifying that authorized routes still resolve while mismatch is treated as informational only.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an operator opens an authorized tenant detail route for a tenant that is not currently selected, **When** the page loads, **Then** the tenant-bound page remains valid because the route tenant is authoritative.
|
||||
2. **Given** an operator opens an authorized canonical operation viewer while selected tenant context points at another tenant or no tenant is selected, **When** the page loads, **Then** the canonical page renders successfully and any mismatch is presented as non-blocking context.
|
||||
3. **Given** a route resolves to a record the operator is not entitled to inspect, **When** the operator opens the route, **Then** normal authorization semantics apply and remembered tenant context does not broaden access or alter the failure class.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- A remembered tenant was valid when stored but later becomes `draft`, `onboarding`, or `archived`; the shell must stop using it as active context without making workspace routes feel broken.
|
||||
- A remembered tenant no longer exists, no longer belongs to the current workspace, or is outside the operator's entitled tenant set; the stale value must be ignored or cleared deterministically.
|
||||
- An operator arrives on a canonical record page through a deep link before choosing any tenant; no-tenant-selected must remain a legitimate shell state.
|
||||
- An authorized operator opens a tenant-bound page for an onboarding or archived tenant; the route remains valid even though that tenant is excluded from the standard active selector.
|
||||
- Search or direct navigation can find a tenant that does not appear in the standard selector; that difference must remain consistent with lifecycle and context semantics rather than feeling accidental.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** This feature introduces no new Microsoft Graph calls, new write workflows, or new queued or scheduled work. It hardens selector semantics, remembered-context validity, and route-legitimacy rules so later implementation can centralize them without creating a parallel tenant model.
|
||||
|
||||
**Constitution alignment (OPS-UX):** This feature does not create a new `OperationRun` type and does not change service ownership of run status or outcome. It reaffirms that canonical operation viewers remain workspace-level pages whose legitimacy comes from the record, workspace, and authorization rules rather than remembered tenant context.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** This feature changes authorization-adjacent shell behavior inside the admin plane. Non-members or non-entitled actors remain deny-as-not-found. Members lacking an otherwise required capability remain forbidden. Server-side enforcement must continue to rely on workspace membership, tenant entitlement, route record identity, and canonical capability policy checks rather than raw remembered session state. No new destructive action is introduced by this spec.
|
||||
|
||||
This feature MUST also preserve RBAC-UX global-search safety: when no active tenant is selected, remembered tenant context must not cause tenant-owned global-search results or hints to appear in workspace context.
|
||||
|
||||
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. This feature does not alter authentication handshakes or synchronous auth exceptions.
|
||||
|
||||
**Constitution alignment (BADGE-001):** This feature reuses the tenant lifecycle presentation semantics defined by Spec 146 wherever lifecycle is shown in selectors, context surfaces, mismatch messaging, or tenant references. No ad hoc lifecycle mappings may be introduced for selector exclusion or stale-context messaging.
|
||||
|
||||
**Constitution alignment (UI-NAMING-001):** The target objects are workspace context, active tenant context, and route-resolved tenant references. Operator-facing copy must preserve the distinction between `workspace`, `selected tenant`, `active tenant context`, and `viewed tenant` or `run tenant`. Messaging must avoid implying that a missing or mismatched selected tenant invalidates an otherwise legitimate route.
|
||||
|
||||
**Constitution alignment (Filament Action Surfaces):** This feature changes the meaning and trust model of existing Filament and shell-adjacent surfaces rather than adding new mutation actions. The Action Surface Contract remains satisfied because no new destructive behavior or hidden action inventory is introduced. The UI Action Matrix below records the affected surfaces and their semantic constraints.
|
||||
|
||||
**Constitution alignment (UX-001 — Layout & Information Architecture):** This feature primarily changes information architecture semantics and recovery behavior rather than screen layout. Existing in-scope pages must preserve current layout rules while presenting no-tenant-selected as a legitimate workspace state and any selected-tenant mismatch as non-blocking informational context.
|
||||
|
||||
**Constitution alignment (UI-STD-001 list surfaces):** Because this feature modifies workspace-level list surfaces such as `/admin/tenants` and `/admin/operations`, implementation and verification MUST reference `docs/product/standards/list-surface-review-checklist.md` so selector and no-context semantics do not regress established table, filter, empty-state, and inspection-affordance standards.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-147-001**: The system MUST define the standard tenant selector as the selector for normal active operating tenant context within the current workspace.
|
||||
- **FR-147-002**: The standard tenant selector and choose-tenant page MUST include only tenants eligible for normal active operating context.
|
||||
- **FR-147-003**: For the lifecycle model already established by Spec 143, `active` tenants MUST be eligible for the standard active selector, while `draft`, `onboarding`, and `archived` tenants MUST NOT be eligible.
|
||||
- **FR-147-004**: The header selector, choose-tenant page, and any equivalent standard active-lane tenant picker within this scope MUST share the same selector eligibility semantics.
|
||||
- **FR-147-005**: The system MUST preserve deliberate discoverability for non-active tenants through the correct non-selector surfaces, including managed-tenant, onboarding, administrative, audit, archive, restore, and canonical record routes where authorized.
|
||||
- **FR-147-006**: Remembered tenant context MUST be treated as a workspace-scoped preference for active-lane convenience rather than as an authorization primitive, ownership model, or route-legitimacy requirement.
|
||||
- **FR-147-007**: Whenever remembered tenant context is read for active-lane use, the system MUST revalidate that the tenant still belongs to the current workspace, remains visible to the actor where required, remains eligible for normal active operating context, and still exists.
|
||||
- **FR-147-008**: If remembered tenant context fails revalidation, the system MUST clear or ignore it for active-context behavior and MUST recover to a deterministic fallback such as no selected tenant or a cleared tenant filter.
|
||||
- **FR-147-009**: Workspace changes MUST trigger re-evaluation of remembered tenant context so that tenant preferences do not bleed across workspaces.
|
||||
- **FR-147-010**: Workspace-level pages within this scope MUST remain valid and usable without any selected tenant context unless a page is explicitly defined elsewhere as tenant-required.
|
||||
- **FR-147-011**: On workspace-level pages, selected tenant context MAY act as a default filter, scope suggestion, or shortcut affordance, but it MUST NOT act as a route-validity precondition.
|
||||
- **FR-147-012**: Tenant-bound pages MUST derive legitimacy from the route tenant record, workspace relationship, entitlement, and page semantics rather than from header tenant equality.
|
||||
- **FR-147-013**: Canonical workspace-level record viewers, including `/admin/operations/{run}`, MUST remain valid when authorized regardless of remembered tenant mismatch, absence, or stale remembered tenant state.
|
||||
- **FR-147-014**: The system MUST NOT select a tenant into the standard active lane through one path and then immediately invalidate that choice downstream because later logic applies stricter active-lane rules.
|
||||
- **FR-147-015**: No selected tenant MUST be treated as a legitimate shell state for workspace-first operation rather than as an error condition by default.
|
||||
- **FR-147-016**: Where selected tenant context and a viewed tenant or referenced run tenant differ, the UI MAY explain the mismatch, but the mismatch MUST remain informational and MUST NOT masquerade as missing data or route invalidity.
|
||||
- **FR-147-017**: In-scope code MUST move toward one authoritative selector eligibility rule and one authoritative remembered-context validation rule rather than duplicating ad hoc checks in multiple pages or helpers.
|
||||
- **FR-147-018**: In-scope code MUST NOT treat raw stored remembered tenant state as durable truth without revalidation.
|
||||
- **FR-147-019**: Exclusion from the standard active selector MUST NOT be interpreted as non-existence of the tenant record, and UI copy or navigation within scope MUST reinforce that distinction.
|
||||
- **FR-147-020**: Regression coverage for this feature MUST include active tenant visible in selector, onboarding tenant excluded, archived tenant excluded, stale remembered tenant invalidation, workspace switch re-evaluation, no-selected-tenant workspace usage, tenant-bound route with mismatched selected tenant, and canonical operation viewer with mismatched selected tenant.
|
||||
- **FR-147-021**: In workspace context with no selected tenant, global search MUST remain workspace-safe and MUST NOT use remembered tenant context to surface tenant-owned results or hints.
|
||||
|
||||
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||
|
||||
If this feature adds/modifies any Filament Resource / RelationManager / Page, fill out the matrix below.
|
||||
|
||||
For each surface, list the exact action labels, whether they are destructive (confirmation? typed confirmation?),
|
||||
RBAC gating (capability + enforcement helper), and whether the mutation writes an audit log.
|
||||
|
||||
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Workspace shell and header context bar | `/admin` shared shell | Existing workspace and tenant context affordances only | Not applicable | None introduced by this spec | None | Existing workspace chooser CTA unchanged | Not applicable | Not applicable | No direct mutation | This feature changes context semantics and empty-state legitimacy, not mutation inventory. |
|
||||
| Choose-tenant page | `/admin/choose-tenant` | None beyond existing navigation | Existing tenant inspection or selection affordance | `Select tenant` for eligible active-lane tenants only | None | Existing fallback CTA to managed-tenant or workspace surfaces may remain | Not applicable | Not applicable | No direct mutation | The page must not become a backdoor for selecting non-active tenants into active context. |
|
||||
| Managed tenants index and tenant detail | `/admin/tenants`, `/admin/tenants/{tenant}` | Existing header actions remain | Existing row click and route record inspection remain | Existing lifecycle-safe row actions unchanged by this spec | Existing grouped bulk actions unchanged | Existing empty-state CTA unchanged | Existing detail header actions unchanged | Existing save and cancel behavior unchanged | Existing audit rules unchanged | Route legitimacy is route-record-driven even when selected tenant differs or is empty. |
|
||||
| Operations index and canonical run viewer | `/admin/operations`, `/admin/operations/{run}` | Existing navigation and filter affordances remain | Existing run inspection links remain | `View run` remains canonical inspect action | Existing grouped bulk actions unchanged | Existing empty-state CTA unchanged | Existing run-view actions unchanged | Not applicable | Existing audit rules unchanged | Selected tenant is a convenience filter on the index only and must not become a hidden validity gate for the canonical viewer. |
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Workspace Context**: The primary operating boundary that owns tenants and canonical workspace records.
|
||||
- **Standard Active Tenant Selector**: The shared product affordance used to choose the current active operating tenant context within a workspace.
|
||||
- **Remembered Tenant Context**: A workspace-scoped preference storing the operator's last active-lane tenant choice for convenience only.
|
||||
- **Tenant Route Subject**: A route-resolved tenant record whose legitimacy comes from route identity and policy rather than current header selection.
|
||||
- **Canonical Workspace Record**: A workspace-owned record such as an operation run that may reference a tenant but remains authoritative on its own route.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-147-001**: In focused regression coverage, 100% of tenants shown in the standard selector are eligible for normal active operating context and 0 ineligible tenants are selectable there.
|
||||
- **SC-147-002**: In focused regression coverage, 100% of stale remembered-tenant scenarios recover to a deterministic fallback state without producing false not-found behavior or unusable workspace-level pages.
|
||||
- **SC-147-003**: In focused regression coverage, 100% of authorized tenant-bound and canonical record routes remain accessible when selected tenant context is mismatched, empty, or cleared.
|
||||
- **SC-147-004**: In focused regression coverage, 100% of workspace switches clear or re-evaluate remembered tenant context so that no previous workspace tenant is reused as active context in another workspace.
|
||||
- **SC-147-005**: In focused UX validation across covered pages, operators can complete workspace-level work with no selected tenant and receive explicit, non-blocking mismatch messaging instead of ambiguous failure.
|
||||
- **SC-147-006**: In covered paths for onboarding and archived tenants, exclusion from the standard selector does not remove those tenants from their intended management, onboarding, audit, or canonical-view discovery surfaces.
|
||||
- **SC-147-007**: In focused validation, workspace-context global search with no selected tenant does not reveal tenant-owned results or hints through remembered tenant state.
|
||||
|
||||
## Summary
|
||||
|
||||
The product already follows a workspace-first architecture, but tenant selector behavior and remembered tenant context still act too much like hidden authority. This makes non-active tenants leak into active-lane selection, lets stale remembered state survive too long, and causes operators to misread header state as a determinant of whether pages or records are legitimate.
|
||||
|
||||
This feature defines the standard tenant selector as the normal active operating lane only, and defines remembered tenant context as a revalidated workspace-scoped preference for that lane rather than a source of truth. It hardens the shell so selector trust, route legitimacy, and non-active tenant discoverability stay aligned.
|
||||
|
||||
## Goals
|
||||
|
||||
- Define the standard tenant selector as the normal active-lane selector for workspace-scoped operations.
|
||||
- Ensure only eligible active-lane tenants are selectable through the standard selector surfaces.
|
||||
- Define remembered tenant context as revalidated convenience state rather than route or authorization truth.
|
||||
- Protect workspace-level pages, tenant-bound pages, and canonical record viewers from false invalidity caused by stale or mismatched selected tenant state.
|
||||
- Preserve intentional discoverability for onboarding and archived tenants outside the standard selector.
|
||||
- Reduce scattered selector-membership and remembered-context validity logic in favor of shared semantics.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- This feature does not redesign the visual style of the header bar beyond what is necessary to communicate safe context semantics.
|
||||
- This feature does not redefine canonical operation viewing beyond reinforcing the rules already established by Spec 144.
|
||||
- This feature does not introduce a new tenant ownership model, a new tenant lifecycle model, or a parallel second selector system.
|
||||
- This feature does not require non-active tenants to appear in the standard selector.
|
||||
- This feature does not remove onboarding or archived tenants from the product.
|
||||
- This feature does not implement the full future central operability policy, though it is intended to align with that direction.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Spec 143 remains the source of truth for workspace-first context semantics, canonical lifecycle values, and page-type distinctions.
|
||||
- Spec 144 remains the source of truth for canonical operation viewer legitimacy under tenant-context mismatch.
|
||||
- Spec 146 remains the source of truth for how lifecycle is presented wherever lifecycle state is shown.
|
||||
- The current product already has legitimate non-selector surfaces where onboarding, archived, administrative, and canonical tenant references can remain discoverable.
|
||||
- This feature is implemented as consolidation and enforcement of existing architectural intent rather than as a new context model.
|
||||
|
||||
## Risks
|
||||
|
||||
- Removing non-active tenants from the selector without preserving clear discovery paths could make those records feel lost.
|
||||
- Leaving selector eligibility or remembered-context validation fragmented would allow contradictions to reappear under different pages or helper flows.
|
||||
- Treating no-selected-tenant as an implicit error would make graceful invalidation feel like breakage rather than safe recovery.
|
||||
- Over-coupling this feature to a future operability abstraction could delay necessary correctness work on current shell behavior.
|
||||
|
||||
## Follow-Up Dependencies
|
||||
|
||||
- Spec 148 — Central Tenant Operability Policy
|
||||
- Future shell polish and information-architecture specs for mismatch messaging, no-context framing, and operator guidance
|
||||
- Future governance, customer-view, and workspace-summary specs that must not reintroduce selector-driven route ambiguity
|
||||
|
||||
## Final Direction
|
||||
|
||||
The product must stay workspace first, tenant context second. The standard selector is only for the normal active operating lane, remembered tenant is a revalidated preference rather than truth, and route identity plus policy remain the source of legitimacy for tenant-bound and canonical pages. Onboarding and archived tenants remain real records, but they stay discoverable through the right surfaces instead of leaking into the active-lane selector.
|
||||
@ -0,0 +1,219 @@
|
||||
# Tasks: Tenant Selector and Remembered Context Enforcement
|
||||
|
||||
**Input**: Design documents from `/specs/147-tenant-selector-remembered-context-enforcement/`
|
||||
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/
|
||||
|
||||
**Tests**: For runtime behavior changes in this repo, tests are REQUIRED (Pest).
|
||||
**Operations**: This feature does not introduce long-running, remote, queued, or scheduled work. No `OperationRun` creation or lifecycle changes are required.
|
||||
**RBAC**: This feature hardens admin-plane context behavior without changing the capability model. Tasks MUST preserve `404` for non-members or non-entitled actors and `403` for in-scope capability denial, and MUST include positive and negative authorization regression coverage for tenant-bound and canonical routes.
|
||||
**UI Naming**: Copy must preserve the distinction between `workspace`, `selected tenant`, `no tenant selected`, `viewed tenant`, and `run tenant`, and must not imply that selected tenant state determines route legitimacy.
|
||||
**Filament UI Action Surfaces**: This feature does not add new actions. Existing header, row, bulk, and empty-state action inventories must remain consistent while selector and shell semantics are hardened.
|
||||
**Filament UI UX-001 (Layout & IA)**: Existing layouts remain intact. Tasks below only adjust selector semantics, workspace-safe fallback behavior, and informational mismatch messaging within current screens.
|
||||
**Badges**: Existing centralized lifecycle presentation from Spec 146 must continue to be used anywhere lifecycle or selector availability is shown.
|
||||
|
||||
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
|
||||
- Include exact file paths in descriptions
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Prepare the focused regression targets and implementation surfaces for context-enforcement work.
|
||||
|
||||
- [X] T001 [P] Create the remembered-context unit test target in `tests/Unit/Support/Workspaces/WorkspaceContextRememberedTenantTest.php`
|
||||
- [X] T002 [P] Create the choose-tenant and workspace-fallback feature test target in `tests/Feature/Workspaces/ChooseTenantPageTest.php`
|
||||
- [X] T003 [P] Review and extend the existing shell and canonical route regression targets in `tests/Feature/OpsUx/OperateHubShellTest.php` and `tests/Feature/Operations/TenantlessOperationRunViewerTest.php`
|
||||
- [X] T004 [P] Create the workspace-context global-search safety regression target in `tests/Feature/Rbac/AdminGlobalSearchContextSafetyTest.php`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Centralize selector eligibility, remembered-context validation, and workspace-safe no-tenant fallback before any story-specific surface work begins.
|
||||
|
||||
**⚠️ CRITICAL**: No user story work can begin until this phase is complete
|
||||
|
||||
- [X] T005 Implement validated remembered-tenant lookup, invalidation, and explicit clear helpers in `app/Support/Workspaces/WorkspaceContext.php`
|
||||
- [X] T006 [P] Refactor shell active-tenant resolution states for workspace-level, tenant-bound, and canonical routes in `app/Support/OperateHub/OperateHubShell.php`
|
||||
- [X] T007 [P] Align active-lane selector eligibility helpers across `app/Services/Tenants/TenantOperabilityService.php` and `app/Models/User.php`
|
||||
- [X] T008 Remove hidden tenant-required gating from workspace-safe middleware and routes in `app/Support/Middleware/EnsureFilamentTenantSelected.php` and `routes/web.php`
|
||||
- [X] T009 Audit and harden workspace-context global-search scoping so remembered tenant state cannot leak tenant-owned results in `app/Filament/Resources/TenantResource.php`, `app/Filament/Resources/OperationRunResource.php`, and `tests/Feature/Rbac/AdminGlobalSearchContextSafetyTest.php`
|
||||
- [X] T010 Add foundational coverage for shared remembered-context and shell-resolution behavior in `tests/Unit/Support/Workspaces/WorkspaceContextRememberedTenantTest.php` and `tests/Feature/OpsUx/OperateHubShellTest.php`
|
||||
|
||||
**Checkpoint**: Foundation ready. Selector and shell rules are centralized, and user stories can proceed against one shared context model.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - Trust The Active Tenant Selector (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Make the standard header selector and choose-tenant page represent only the normal active operating lane.
|
||||
|
||||
**Independent Test**: In one workspace with active, draft, onboarding, and archived tenants, verify that only active tenants appear as selectable choices in both the header selector and choose-tenant page while non-active tenants remain discoverable elsewhere.
|
||||
|
||||
### Tests for User Story 1 ⚠️
|
||||
|
||||
- [X] T011 [P] [US1] Add choose-tenant eligibility and empty-state assertions in `tests/Feature/Workspaces/ChooseTenantPageTest.php`
|
||||
- [X] T012 [P] [US1] Add header selector scope and non-active exclusion assertions in `tests/Feature/TenantRBAC/TenantSwitcherScopeTest.php`
|
||||
- [X] T013 [P] [US1] Add managed-tenant discoverability assertions for onboarding and archived records in `tests/Feature/Filament/ManagedTenantsLandingLifecycleTest.php`
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T014 [US1] Refactor active-lane tenant retrieval and selection handling in `app/Filament/Pages/ChooseTenant.php`
|
||||
- [X] T015 [US1] Align POST tenant selection enforcement with the shared selector rule in `app/Http/Controllers/SelectTenantController.php`
|
||||
- [X] T016 [US1] Update choose-tenant copy and card behavior to reflect the active-lane-only selector meaning in `resources/views/filament/pages/choose-tenant.blade.php`
|
||||
- [X] T017 [US1] Update the header context-bar tenant list and selector affordances to use the same active-lane semantics in `resources/views/filament/partials/context-bar.blade.php`
|
||||
- [X] T018 [US1] Preserve intentional non-active tenant discoverability in workspace admin surfaces in `app/Filament/Pages/Workspaces/ManagedTenantsLanding.php` and `app/Filament/Resources/TenantResource.php`
|
||||
|
||||
**Checkpoint**: User Story 1 is complete when both selector surfaces expose the same active-only operating lane and no contradictory inactive choices remain.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Recover Safely From Stale Remembered Context (Priority: P2)
|
||||
|
||||
**Goal**: Revalidate remembered tenant context on use and degrade cleanly to a legitimate no-tenant workspace state when it becomes stale or invalid.
|
||||
|
||||
**Independent Test**: Persist remembered context for an active tenant, then invalidate it by lifecycle change, entitlement loss, or workspace switch and verify the shell falls back to no selected tenant while `/admin`, `/admin/operations`, and `/admin/tenants` remain usable.
|
||||
|
||||
### Tests for User Story 2 ⚠️
|
||||
|
||||
- [X] T019 [P] [US2] Add remembered-context invalidation scenarios for workspace mismatch, missing tenant, and selector-ineligible lifecycle in `tests/Unit/Support/Workspaces/WorkspaceContextRememberedTenantTest.php`
|
||||
- [X] T020 [P] [US2] Add workspace switch, explicit clear, and no-selected-tenant fallback assertions in `tests/Feature/Workspaces/ChooseTenantPageTest.php`, `tests/Feature/Workspaces/ChooseWorkspaceRedirectsToChooseTenantTest.php`, and `tests/Feature/Rbac/TenantResourceAuthorizationTest.php`
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T021 [US2] Update workspace-switch and explicit-clear flows to preserve workspace-safe no-tenant fallback in `app/Http/Controllers/SwitchWorkspaceController.php` and `app/Http/Controllers/ClearTenantContextController.php`
|
||||
- [X] T022 [US2] Remove workspace-page assumptions that an active tenant is required in `app/Filament/Pages/Monitoring/Operations.php`, `app/Filament/Resources/TenantResource.php`, and `app/Support/Middleware/EnsureFilamentTenantSelected.php`
|
||||
- [X] T023 [US2] Update shell and chooser copy to present “no tenant selected” as a legitimate workspace state in `resources/views/filament/partials/context-bar.blade.php` and `resources/views/filament/pages/choose-tenant.blade.php`
|
||||
|
||||
**Checkpoint**: User Story 2 is complete when stale remembered context clears deterministically and workspace-level pages continue working without selected tenant state.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Keep Route Legitimacy Separate From Header Context (Priority: P3)
|
||||
|
||||
**Goal**: Preserve route-authoritative behavior for tenant-bound pages and canonical run viewers even when selected tenant context differs, is empty, or has just been cleared.
|
||||
|
||||
**Independent Test**: Open authorized tenant-bound and canonical run routes with mismatched, stale, or empty selected tenant state and verify the pages still resolve while non-member or non-entitled access keeps `404` semantics and capability denial keeps `403` semantics.
|
||||
|
||||
### Tests for User Story 3 ⚠️
|
||||
|
||||
- [X] T024 [P] [US3] Add positive and negative tenant-bound route-authority assertions in `tests/Feature/TenantRBAC/ArchivedTenantRouteAccessTest.php` and `tests/Feature/TenantRBAC/TenantRouteDenyAsNotFoundTest.php`
|
||||
- [X] T025 [P] [US3] Add canonical run mismatch, no-selected-tenant, entitlement, and capability assertions in `tests/Feature/Operations/TenantlessOperationRunViewerTest.php`
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T026 [US3] Refine route-authoritative tenant resolution for admin-panel pages in `app/Support/OperateHub/OperateHubShell.php` and `app/Filament/Concerns/ResolvesPanelTenantContext.php`
|
||||
- [X] T027 [US3] Preserve mismatch-as-information behavior for canonical run viewing in `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` and `resources/views/filament/pages/operations/tenantless-operation-run-viewer.blade.php`
|
||||
- [X] T028 [US3] Audit workspace and canonical route integration so selected tenant never acts as a legitimacy shortcut in `routes/web.php` and `app/Policies/OperationRunPolicy.php`
|
||||
|
||||
**Checkpoint**: User Story 3 is complete when route identity and policy fully outrank selected tenant state on tenant-bound and canonical pages.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Final regression validation, formatting, and manual verification across all stories.
|
||||
|
||||
- [X] T029 [P] Run focused Pest suites and render/query-safety validation covering `tests/Unit/Support/Workspaces/WorkspaceContextRememberedTenantTest.php`, `tests/Feature/Workspaces/ChooseTenantPageTest.php`, `tests/Feature/Workspaces/ChooseWorkspaceRedirectsToChooseTenantTest.php`, `tests/Feature/Filament/ManagedTenantsLandingLifecycleTest.php`, `tests/Feature/TenantRBAC/TenantSwitcherScopeTest.php`, `tests/Feature/Rbac/TenantResourceAuthorizationTest.php`, `tests/Feature/Rbac/AdminGlobalSearchContextSafetyTest.php`, `tests/Feature/TenantRBAC/ArchivedTenantRouteAccessTest.php`, `tests/Feature/TenantRBAC/TenantRouteDenyAsNotFoundTest.php`, `tests/Feature/OpsUx/OperateHubShellTest.php`, and `tests/Feature/Operations/TenantlessOperationRunViewerTest.php`
|
||||
- [X] T030 Run formatting for touched files with `vendor/bin/sail bin pint --dirty --format agent`
|
||||
- [X] T031 [P] Validate the manual verification checklist in `specs/147-tenant-selector-remembered-context-enforcement/quickstart.md` and review `/admin/tenants` plus `/admin/operations` against `docs/product/standards/list-surface-review-checklist.md`
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: No dependencies, can start immediately
|
||||
- **Foundational (Phase 2)**: Depends on Setup completion and blocks all story work
|
||||
- **User Story 1 (Phase 3)**: Depends on Foundational completion
|
||||
- **User Story 2 (Phase 4)**: Depends on Foundational completion and benefits from US1 surface alignment
|
||||
- **User Story 3 (Phase 5)**: Depends on Foundational completion and is safest after US1 and US2 because it hardens route behavior on the same shell/context surfaces
|
||||
- **Polish (Phase 6)**: Depends on all desired user stories being complete
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **User Story 1 (P1)**: No dependency on other stories after Foundational; this is the MVP slice
|
||||
- **User Story 2 (P2)**: Depends on the shared context-validation foundation and uses the selector semantics stabilized in US1
|
||||
- **User Story 3 (P3)**: Depends on the shared context-validation foundation and benefits from the no-tenant and selector semantics stabilized in US1 and US2
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Tests for the story should be written first and fail before implementation
|
||||
- Shared support-layer changes should land before UI surface refinements
|
||||
- Route and middleware behavior should be stabilized before final mismatch-copy adjustments
|
||||
- Story-specific validation should pass before moving to the next priority story
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- T001, T002, T003, and T004 can run in parallel because they prepare separate test targets
|
||||
- T006 and T007 can run in parallel after T005 defines the shared remembered-context contract
|
||||
- T011, T012, and T013 can run in parallel because they cover different selector and discoverability surfaces
|
||||
- T019 and T020 can run in parallel because they cover different fallback paths
|
||||
- T024 and T025 can run in parallel because they cover different route categories
|
||||
- T029 and T031 can run in parallel after implementation is complete
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 1
|
||||
|
||||
```bash
|
||||
# Launch the P1 selector regression tasks together:
|
||||
Task: "Add choose-tenant eligibility and empty-state assertions in tests/Feature/Workspaces/ChooseTenantPageTest.php"
|
||||
Task: "Add header selector scope and non-active exclusion assertions in tests/Feature/TenantRBAC/TenantSwitcherScopeTest.php"
|
||||
Task: "Add managed-tenant discoverability assertions in tests/Feature/Filament/ManagedTenantsLandingLifecycleTest.php"
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 2
|
||||
|
||||
```bash
|
||||
# After the foundation is in place, stale-context validation and workspace-switch coverage can proceed together:
|
||||
Task: "Add remembered-context invalidation scenarios in tests/Unit/Support/Workspaces/WorkspaceContextRememberedTenantTest.php"
|
||||
Task: "Add workspace switch and no-selected-tenant fallback assertions in tests/Feature/Workspaces/ChooseTenantPageTest.php, tests/Feature/Workspaces/ChooseWorkspaceRedirectsToChooseTenantTest.php, and tests/Feature/Rbac/TenantResourceAuthorizationTest.php"
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 3
|
||||
|
||||
```bash
|
||||
# Route-authority coverage can split by tenant-bound and canonical routes:
|
||||
Task: "Add tenant-bound route-authority assertions in tests/Feature/TenantRBAC/ArchivedTenantRouteAccessTest.php and tests/Feature/TenantRBAC/TenantRouteDenyAsNotFoundTest.php"
|
||||
Task: "Add canonical run mismatch and authorization assertions in tests/Feature/Operations/TenantlessOperationRunViewerTest.php"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Story 1 Only)
|
||||
|
||||
1. Complete Phase 1: Setup
|
||||
2. Complete Phase 2: Foundational
|
||||
3. Complete Phase 3: User Story 1
|
||||
4. **Stop and validate** the selector semantics independently on the header selector and choose-tenant page
|
||||
5. Demo or merge the MVP slice if acceptable
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Complete Setup + Foundational to establish one shared context model
|
||||
2. Deliver User Story 1 to make the active selector trustworthy
|
||||
3. Deliver User Story 2 to make stale remembered context recover safely
|
||||
4. Deliver User Story 3 to harden tenant-bound and canonical route authority
|
||||
5. Finish with Polish for regression validation and formatting
|
||||
|
||||
### Team Strategy
|
||||
|
||||
1. One engineer owns the support-layer foundation in `WorkspaceContext`, `OperateHubShell`, and middleware/routes
|
||||
2. A second engineer prepares selector-surface and fallback regression coverage in parallel once the shared context contract is clear
|
||||
3. Route-authority hardening for canonical and tenant-bound pages can proceed as a separate stream after the foundation is merged
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- [P] tasks touch separate files and can be worked in parallel without blocking each other
|
||||
- Every user story remains independently testable after the foundational phase
|
||||
- This feature does not add schema changes, Graph calls, or new operations workflows
|
||||
- Keep route legitimacy tied to route subject and policy, never to raw remembered tenant session state
|
||||
@ -126,12 +126,17 @@
|
||||
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$subjectExternalId = 'rbac-role-1';
|
||||
$rawSubjectExternalId = 'rbac-role-1';
|
||||
$subjectExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalIdForPolicy(
|
||||
'intuneRoleDefinition',
|
||||
'Security Reader',
|
||||
$rawSubjectExternalId,
|
||||
);
|
||||
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'external_id' => $subjectExternalId,
|
||||
'external_id' => $rawSubjectExternalId,
|
||||
'policy_type' => 'intuneRoleDefinition',
|
||||
'display_name' => 'Security Reader',
|
||||
'meta_jsonb' => ['etag' => 'E1'],
|
||||
|
||||
@ -54,3 +54,35 @@
|
||||
->assertSee('Archived')
|
||||
->assertDontSee('Other Workspace Tenant');
|
||||
});
|
||||
|
||||
it('keeps managed tenants discoverable with no selected tenant context', function (): void {
|
||||
$workspace = Workspace::factory()->create(['slug' => 'discoverable-managed-tenants']);
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
$onboardingTenant = Tenant::factory()->onboarding()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'name' => 'Discoverable Onboarding Tenant',
|
||||
]);
|
||||
$archivedTenant = Tenant::factory()->archived()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'name' => 'Discoverable Archived Tenant',
|
||||
]);
|
||||
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
$onboardingTenant->getKey() => ['role' => 'owner'],
|
||||
$archivedTenant->getKey() => ['role' => 'owner'],
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
||||
->get(route('admin.workspace.managed-tenants.index', ['workspace' => $workspace]))
|
||||
->assertSuccessful()
|
||||
->assertSee('Discoverable Onboarding Tenant')
|
||||
->assertSee('Discoverable Archived Tenant');
|
||||
});
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
use App\Filament\Resources\FindingResource\Pages\ListFindings;
|
||||
use App\Models\Finding;
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
@ -92,3 +93,32 @@ function findingsDefaultIndicatorLabels($component): array
|
||||
->toContain('Created from '.now()->subDay()->toFormattedDateString())
|
||||
->toContain('Created until '.now()->toFormattedDateString());
|
||||
});
|
||||
|
||||
it('shows evidence display-name fallback in the findings list when the subject external id is workspace-safe', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$subjectExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalIdForPolicy(
|
||||
'intuneRoleDefinition',
|
||||
'Security Reader',
|
||||
'rbac-role-1',
|
||||
);
|
||||
|
||||
$finding = Finding::factory()->for($tenant)->create([
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => (string) $subjectExternalId,
|
||||
'evidence_jsonb' => [
|
||||
'policy_type' => 'intuneRoleDefinition',
|
||||
'display_name' => 'Security Reader',
|
||||
'summary' => [
|
||||
'kind' => 'rbac_role_definition',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
Livewire::test(ListFindings::class)
|
||||
->assertCanSeeTableRecords([$finding])
|
||||
->assertSee('Security Reader');
|
||||
});
|
||||
|
||||
@ -175,6 +175,49 @@
|
||||
->assertSee('This canonical workspace view is not tied to the current tenant context');
|
||||
});
|
||||
|
||||
it('keeps a canonical run viewer accessible when remembered tenant context is cleared as stale', function (): void {
|
||||
$runTenant = Tenant::factory()->active()->create([
|
||||
'name' => 'Viewer Run Tenant',
|
||||
]);
|
||||
[$user, $runTenant] = createUserWithTenant(tenant: $runTenant, role: 'owner');
|
||||
|
||||
$rememberedTenant = Tenant::factory()->onboarding()->create([
|
||||
'workspace_id' => (int) $runTenant->workspace_id,
|
||||
'name' => 'Viewer Onboarding Tenant',
|
||||
]);
|
||||
|
||||
createUserWithTenant(
|
||||
tenant: $rememberedTenant,
|
||||
user: $user,
|
||||
role: 'owner',
|
||||
workspaceRole: 'owner',
|
||||
ensureDefaultMicrosoftProviderConnection: false,
|
||||
);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $runTenant->getKey(),
|
||||
'workspace_id' => (int) $runTenant->workspace_id,
|
||||
'type' => 'inventory_sync',
|
||||
'status' => OperationRunStatus::Queued->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
]);
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $runTenant->workspace_id,
|
||||
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
|
||||
(string) $runTenant->workspace_id => (int) $rememberedTenant->getKey(),
|
||||
],
|
||||
])
|
||||
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||
->assertSuccessful()
|
||||
->assertSee('All tenants')
|
||||
->assertSee('Canonical workspace view')
|
||||
->assertSee('No tenant context is currently selected.');
|
||||
});
|
||||
|
||||
it('renders stored target scope and failure details for a completed run', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
@ -496,3 +496,45 @@
|
||||
->assertDontSee('Filtered by tenant')
|
||||
->assertDontSee('Scope: Tenant');
|
||||
})->group('ops-ux');
|
||||
|
||||
it('treats selector-ineligible remembered tenants as no selected tenant on canonical viewer routes', function (): void {
|
||||
$runTenant = Tenant::factory()->active()->create([
|
||||
'name' => 'Canonical Run Tenant',
|
||||
]);
|
||||
[$user, $runTenant] = createUserWithTenant(tenant: $runTenant, role: 'owner');
|
||||
|
||||
$rememberedTenant = Tenant::factory()->onboarding()->create([
|
||||
'workspace_id' => (int) $runTenant->workspace_id,
|
||||
'name' => 'Stale Onboarding Tenant',
|
||||
]);
|
||||
|
||||
createUserWithTenant(
|
||||
tenant: $rememberedTenant,
|
||||
user: $user,
|
||||
role: 'owner',
|
||||
workspaceRole: 'owner',
|
||||
ensureDefaultMicrosoftProviderConnection: false,
|
||||
);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $runTenant->getKey(),
|
||||
'workspace_id' => (int) $runTenant->workspace_id,
|
||||
'type' => 'policy.sync',
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$response = $this->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $runTenant->workspace_id,
|
||||
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
|
||||
(string) $runTenant->workspace_id => (int) $rememberedTenant->getKey(),
|
||||
],
|
||||
])->get(route('admin.operations.view', ['run' => (int) $run->getKey()]));
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertSee('All tenants')
|
||||
->assertSee('Canonical workspace view')
|
||||
->assertSee('No tenant context is currently selected.');
|
||||
})->group('ops-ux');
|
||||
|
||||
47
tests/Feature/Rbac/AdminGlobalSearchContextSafetyTest.php
Normal file
47
tests/Feature/Rbac/AdminGlobalSearchContextSafetyTest.php
Normal file
@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\OperationRunResource;
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function adminGlobalSearchTitles($results): array
|
||||
{
|
||||
return collect($results)->map(fn ($result): string => (string) $result->title)->all();
|
||||
}
|
||||
|
||||
it('does not leak non-selectable tenant results from remembered tenant context', function (): void {
|
||||
$activeTenant = Tenant::factory()->active()->create(['name' => 'Search Safety Active']);
|
||||
[$user, $activeTenant] = createUserWithTenant(tenant: $activeTenant, role: 'owner');
|
||||
|
||||
$onboardingTenant = Tenant::factory()->onboarding()->create([
|
||||
'workspace_id' => (int) $activeTenant->workspace_id,
|
||||
'name' => 'Search Safety Onboarding',
|
||||
]);
|
||||
|
||||
createUserWithTenant(tenant: $onboardingTenant, user: $user, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
Filament::setCurrentPanel('admin');
|
||||
Filament::setTenant(null, true);
|
||||
Filament::bootCurrentPanel();
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $activeTenant->workspace_id);
|
||||
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
|
||||
(string) $activeTenant->workspace_id => (int) $onboardingTenant->getKey(),
|
||||
]);
|
||||
|
||||
expect(adminGlobalSearchTitles(TenantResource::getGlobalSearchResults('Search Safety')))
|
||||
->toBe(['Search Safety Active']);
|
||||
});
|
||||
|
||||
it('keeps operation runs out of admin global search regardless of remembered context state', function (): void {
|
||||
expect(OperationRunResource::canGloballySearch())->toBeFalse();
|
||||
});
|
||||
@ -5,6 +5,7 @@
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
|
||||
describe('Tenant resource authorization', function () {
|
||||
it('cannot be created by non-members', function () {
|
||||
@ -100,4 +101,14 @@
|
||||
->and(TenantResource::canDelete($onboardingTenant))->toBeFalse()
|
||||
->and(TenantResource::canDelete($archivedTenant))->toBeFalse();
|
||||
});
|
||||
|
||||
it('keeps the tenant resource index usable with no selected tenant context', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(TenantResource::getUrl(panel: 'admin'))
|
||||
->assertSuccessful()
|
||||
->assertSee((string) $tenant->name);
|
||||
});
|
||||
});
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
@ -73,3 +74,27 @@
|
||||
->assertActionHidden('archive')
|
||||
->assertActionHidden('related_onboarding');
|
||||
});
|
||||
|
||||
it('keeps archived tenant routes authoritative when another tenant is currently selected', function (): void {
|
||||
[$user, $selectedTenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$archivedTenant = Tenant::factory()->archived()->create([
|
||||
'workspace_id' => (int) $selectedTenant->workspace_id,
|
||||
]);
|
||||
|
||||
createUserWithTenant(
|
||||
tenant: $archivedTenant,
|
||||
user: $user,
|
||||
role: 'owner',
|
||||
workspaceRole: 'owner',
|
||||
ensureDefaultMicrosoftProviderConnection: false,
|
||||
);
|
||||
|
||||
Filament::setTenant($selectedTenant, true);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $selectedTenant->workspace_id])
|
||||
->get(TenantResource::getUrl('view', ['record' => $archivedTenant]))
|
||||
->assertSuccessful()
|
||||
->assertSee('Archived');
|
||||
});
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
@ -41,3 +42,18 @@
|
||||
->get("/admin/t/{$otherTenant->external_id}")
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('keeps non-member tenant-bound requests as 404 even when another tenant is selected', function () {
|
||||
[$user, $selectedTenant] = createUserWithTenant(role: 'readonly');
|
||||
$otherTenant = Tenant::factory()->active()->create([
|
||||
'workspace_id' => (int) $selectedTenant->workspace_id,
|
||||
'external_id' => 'hidden-tenant-b',
|
||||
]);
|
||||
|
||||
Filament::setTenant($selectedTenant, true);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $selectedTenant->workspace_id])
|
||||
->get("/admin/t/{$otherTenant->external_id}")
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
@ -83,6 +83,8 @@
|
||||
$archived->getKey() => ['role' => 'readonly'],
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $active->workspace_id);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ChooseTenant::class)
|
||||
->call('selectTenant', $onboarding->getKey())
|
||||
@ -123,3 +125,31 @@
|
||||
|
||||
expect(TenantResource::getGlobalSearchResults('Search'))->toHaveCount(0);
|
||||
});
|
||||
|
||||
it('does not render onboarding or archived tenants in the header selector on workspace pages', function (): void {
|
||||
$activeTenant = Tenant::factory()->active()->create(['name' => 'Header Active Tenant']);
|
||||
[$user, $activeTenant] = createUserWithTenant(tenant: $activeTenant, role: 'owner');
|
||||
|
||||
$onboardingTenant = Tenant::factory()->onboarding()->create([
|
||||
'workspace_id' => (int) $activeTenant->workspace_id,
|
||||
'name' => 'Header Onboarding Tenant',
|
||||
]);
|
||||
$archivedTenant = Tenant::factory()->archived()->create([
|
||||
'workspace_id' => (int) $activeTenant->workspace_id,
|
||||
'name' => 'Header Archived Tenant',
|
||||
]);
|
||||
|
||||
createUserWithTenant(tenant: $onboardingTenant, user: $user, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||
createUserWithTenant(tenant: $archivedTenant, user: $user, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $activeTenant->workspace_id])
|
||||
->get(route('admin.operations.index'))
|
||||
->assertSuccessful()
|
||||
->assertSee('Header Active Tenant')
|
||||
->assertDontSee('Header Onboarding Tenant')
|
||||
->assertDontSee('Header Archived Tenant')
|
||||
->assertSee('No tenant selected');
|
||||
});
|
||||
|
||||
87
tests/Feature/Workspaces/ChooseTenantPageTest.php
Normal file
87
tests/Feature/Workspaces/ChooseTenantPageTest.php
Normal file
@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('shows only active tenants and no-tenant helper copy on the choose-tenant page', function (): void {
|
||||
$activeTenant = Tenant::factory()->active()->create(['name' => 'Choose Active Tenant']);
|
||||
[$user, $activeTenant] = createUserWithTenant(tenant: $activeTenant, role: 'owner');
|
||||
|
||||
$otherActiveTenant = Tenant::factory()->active()->create([
|
||||
'workspace_id' => (int) $activeTenant->workspace_id,
|
||||
'name' => 'Choose Other Active Tenant',
|
||||
]);
|
||||
$onboardingTenant = Tenant::factory()->onboarding()->create([
|
||||
'workspace_id' => (int) $activeTenant->workspace_id,
|
||||
'name' => 'Choose Onboarding Tenant',
|
||||
]);
|
||||
$archivedTenant = Tenant::factory()->archived()->create([
|
||||
'workspace_id' => (int) $activeTenant->workspace_id,
|
||||
'name' => 'Choose Archived Tenant',
|
||||
]);
|
||||
|
||||
createUserWithTenant(tenant: $otherActiveTenant, user: $user, role: 'owner');
|
||||
createUserWithTenant(tenant: $onboardingTenant, user: $user, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||
createUserWithTenant(tenant: $archivedTenant, user: $user, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $activeTenant->workspace_id])
|
||||
->get('/admin/choose-tenant')
|
||||
->assertSuccessful()
|
||||
->assertSee('Choose Active Tenant')
|
||||
->assertSee('Choose Other Active Tenant')
|
||||
->assertDontSee('Choose Onboarding Tenant')
|
||||
->assertDontSee('Choose Archived Tenant')
|
||||
->assertSee('Select the tenant for your normal active operating context.')
|
||||
->assertSee('No tenant selected is still a valid workspace state');
|
||||
});
|
||||
|
||||
it('shows a workspace-safe empty state when no selectable tenants remain', function (): void {
|
||||
$onboardingTenant = Tenant::factory()->onboarding()->create(['name' => 'Only Onboarding Tenant']);
|
||||
[$user, $onboardingTenant] = createUserWithTenant(
|
||||
tenant: $onboardingTenant,
|
||||
role: 'owner',
|
||||
ensureDefaultMicrosoftProviderConnection: false,
|
||||
);
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $onboardingTenant->workspace_id])
|
||||
->get('/admin/choose-tenant')
|
||||
->assertSuccessful()
|
||||
->assertSee('No active tenants available')
|
||||
->assertSee('Workspace-level pages still work with no tenant selected')
|
||||
->assertSee('View managed tenants');
|
||||
});
|
||||
|
||||
it('redirects clear selected tenant from tenant-bound pages back to a workspace-safe managed-tenants page', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
|
||||
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
|
||||
(string) $tenant->workspace_id => (int) $tenant->getKey(),
|
||||
],
|
||||
])
|
||||
->from("/admin/tenants/{$tenant->external_id}")
|
||||
->post(route('admin.clear-tenant-context'))
|
||||
->assertRedirect(route('admin.workspace.managed-tenants.index', ['workspace' => $tenant->workspace]));
|
||||
|
||||
$this->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
|
||||
])->get(route('admin.operations.index'))
|
||||
->assertSuccessful()
|
||||
->assertSee('All tenants');
|
||||
});
|
||||
@ -10,6 +10,7 @@
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
@ -110,3 +111,30 @@
|
||||
->call('selectWorkspace', $workspace->getKey())
|
||||
->assertRedirect('/admin/operations');
|
||||
});
|
||||
|
||||
it('clears active tenant context when switching into another workspace', function (): void {
|
||||
[$user, $tenantA] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$workspaceB = Workspace::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspaceB->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
Filament::setTenant($tenantA, true);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id,
|
||||
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
|
||||
(string) $tenantA->workspace_id => (int) $tenantA->getKey(),
|
||||
],
|
||||
])
|
||||
->post(route('admin.switch-workspace'), ['workspace_id' => (int) $workspaceB->getKey()])
|
||||
->assertRedirect(route('admin.workspace.managed-tenants.index', ['workspace' => $workspaceB->slug ?? $workspaceB->getKey()]));
|
||||
|
||||
expect(Filament::getTenant())->toBeNull();
|
||||
expect(session(WorkspaceContext::SESSION_KEY))->toBe((int) $workspaceB->getKey());
|
||||
});
|
||||
|
||||
@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('returns the remembered tenant when it remains selectable and entitled', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
|
||||
(string) $tenant->workspace_id => (int) $tenant->getKey(),
|
||||
]);
|
||||
|
||||
$rememberedTenant = app(WorkspaceContext::class)->rememberedTenant(request());
|
||||
|
||||
expect($rememberedTenant?->is($tenant))->toBeTrue();
|
||||
});
|
||||
|
||||
it('clears remembered tenant context when the stored tenant belongs to another workspace', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$otherWorkspaceTenant = Tenant::factory()->active()->create();
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
|
||||
(string) $tenant->workspace_id => (int) $otherWorkspaceTenant->getKey(),
|
||||
]);
|
||||
|
||||
expect(app(WorkspaceContext::class)->rememberedTenant(request()))->toBeNull();
|
||||
expect(session()->get(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, []))
|
||||
->not->toHaveKey((string) $tenant->workspace_id);
|
||||
});
|
||||
|
||||
it('clears remembered tenant context when the stored tenant no longer exists', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$missingTenant = Tenant::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
createUserWithTenant(tenant: $missingTenant, user: $user, role: 'owner');
|
||||
$missingTenantId = (int) $missingTenant->getKey();
|
||||
$missingTenant->forceDelete();
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
|
||||
(string) $tenant->workspace_id => $missingTenantId,
|
||||
]);
|
||||
|
||||
expect(app(WorkspaceContext::class)->rememberedTenant(request()))->toBeNull();
|
||||
expect(session()->get(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, []))
|
||||
->not->toHaveKey((string) $tenant->workspace_id);
|
||||
});
|
||||
|
||||
it('clears remembered tenant context when the actor is no longer entitled to the tenant', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$nonEntitledTenant = Tenant::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
|
||||
(string) $tenant->workspace_id => (int) $nonEntitledTenant->getKey(),
|
||||
]);
|
||||
|
||||
expect(app(WorkspaceContext::class)->rememberedTenant(request()))->toBeNull();
|
||||
expect(session()->get(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, []))
|
||||
->not->toHaveKey((string) $tenant->workspace_id);
|
||||
});
|
||||
|
||||
it('clears remembered tenant context when the tenant becomes selector ineligible', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$onboardingTenant = Tenant::factory()->onboarding()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
createUserWithTenant(
|
||||
tenant: $onboardingTenant,
|
||||
user: $user,
|
||||
role: 'owner',
|
||||
workspaceRole: 'owner',
|
||||
ensureDefaultMicrosoftProviderConnection: false,
|
||||
);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
|
||||
(string) $tenant->workspace_id => (int) $onboardingTenant->getKey(),
|
||||
]);
|
||||
|
||||
expect(app(WorkspaceContext::class)->rememberedTenant(request()))->toBeNull();
|
||||
expect(session()->get(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, []))
|
||||
->not->toHaveKey((string) $tenant->workspace_id);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user