feat: canonical tenant context resolution #164

Merged
ahmido merged 1 commits from 135-canonical-tenant-context-resolution into dev 2026-03-11 21:24:30 +00:00
42 changed files with 2584 additions and 75 deletions

View File

@ -63,6 +63,8 @@ ## Active Technologies
- PostgreSQL via Laravel Sail plus existing workspace and tenant context, existing Eloquent relations, and provider-derived identifiers already stored in domain records (132-guid-context-resolver)
- PostgreSQL via Laravel Sail; no schema change expected (133-detail-page-template)
- PostgreSQL via Laravel Sail; existing `audit_logs` table expanded in place; JSON context payload remains application-shaped rather than raw archival payloads (134-audit-log-foundation)
- PHP 8.4 on Laravel 12 + Filament v5, Livewire v4, Pest v4, Laravel Sail (135-canonical-tenant-context-resolution)
- PostgreSQL application database (135-canonical-tenant-context-resolution)
- PHP 8.4.15 (feat/005-bulk-operations)
@ -82,8 +84,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## Recent Changes
- 135-canonical-tenant-context-resolution: Added PHP 8.4 on Laravel 12 + Filament v5, Livewire v4, Pest v4, Laravel Sail
- 134-audit-log-foundation: Added PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Tailwind CSS v4
- 133-detail-page-template: Added PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Tailwind CSS v4
- 132-guid-context-resolver: Added PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Tailwind CSS v4
<!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END -->

View File

@ -4,6 +4,8 @@
namespace App\Filament\Concerns;
use App\Models\Tenant;
use App\Support\OperateHub\OperateHubShell;
use Filament\Facades\Filament;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
@ -27,7 +29,7 @@ public static function getGlobalSearchEloquentQuery(): Builder
}
}
$tenant = Filament::getTenant();
$tenant = static::resolveGlobalSearchTenant();
if (! $tenant instanceof Model) {
return $query->whereRaw('1 = 0');
@ -41,4 +43,17 @@ public static function getGlobalSearchEloquentQuery(): Builder
return $query->whereBelongsTo($tenant, static::$globalSearchTenantRelationship);
}
protected static function resolveGlobalSearchTenant(): ?Model
{
if (Filament::getCurrentPanel()?->getId() === 'admin') {
$tenant = app(OperateHubShell::class)->activeEntitledTenant(request());
return $tenant instanceof Tenant ? $tenant : null;
}
$tenant = Filament::getTenant();
return $tenant instanceof Model ? $tenant : null;
}
}

View File

@ -13,6 +13,7 @@
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Filament\CanonicalAdminTenantFilterState;
use App\Support\Filament\FilterOptionCatalog;
use App\Support\Filament\FilterPresets;
use App\Support\Filament\TablePaginationProfiles;
@ -82,6 +83,9 @@ public function mount(): void
{
$this->authorizePageAccess();
$this->selectedAuditLogId = is_numeric(request()->query('event')) ? (int) request()->query('event') : null;
app(CanonicalAdminTenantFilterState::class)->sync($this->getTableFiltersSessionKey(), request: request());
$this->mountInteractsWithTable();
if ($this->selectedAuditLogId !== null) {

View File

@ -8,6 +8,7 @@
use App\Filament\Widgets\Operations\OperationsKpiHeader;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\Filament\CanonicalAdminTenantFilterState;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\OperateHub\OperateHubShell;
use App\Support\OperationRunOutcome;
@ -51,6 +52,13 @@ class Operations extends Page implements HasForms, HasTable
public function mount(): void
{
$this->navigationContextPayload = is_array(request()->query('nav')) ? request()->query('nav') : null;
app(CanonicalAdminTenantFilterState::class)->sync(
$this->getTableFiltersSessionKey(),
['type', 'initiator_name'],
request(),
);
$this->mountInteractsWithTable();
}

View File

@ -142,6 +142,15 @@ public function mount(OperationRun $run): void
$this->authorize('view', $run);
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
if (
$activeTenant instanceof Tenant
&& (int) ($run->tenant_id ?? 0) !== (int) $activeTenant->getKey()
) {
abort(404);
}
$this->run = $run->loadMissing(['workspace', 'tenant', 'user']);
$this->navigationContextPayload = is_array(request()->query('nav')) ? request()->query('nav') : null;
}

View File

@ -62,6 +62,8 @@ public function mount(): void
}
$this->scopedTenant = $tenant;
$this->heading = $tenant->getFilamentName();
$this->subheading = 'Required permissions';
$queryFeatures = request()->query('features', $this->features);

View File

@ -274,7 +274,13 @@ public function content(Schema $schema): Schema
->label('App (client) ID')
->required()
->maxLength(255)
->helperText('The client secret is not editable here.'),
->helperText('If you change the App (client) ID, enter the matching new client secret below.'),
TextInput::make('client_secret')
->label('New client secret')
->password()
->revealable(false)
->maxLength(255)
->helperText('Optional for name-only edits. Required when changing the App (client) ID. The existing secret is never shown.'),
])
->action(function (array $data, Get $get): void {
$recordId = $get('provider_connection_id');
@ -2089,7 +2095,7 @@ private function inlineEditSelectedConnectionFill(int $providerConnectionId): ar
}
/**
* @param array{display_name?: mixed, client_id?: mixed} $data
* @param array{display_name?: mixed, client_id?: mixed, client_secret?: mixed} $data
*/
public function updateSelectedProviderConnectionInline(int $providerConnectionId, array $data): void
{
@ -2118,6 +2124,7 @@ public function updateSelectedProviderConnectionInline(int $providerConnectionId
$displayName = trim((string) ($data['display_name'] ?? ''));
$clientId = trim((string) ($data['client_id'] ?? ''));
$clientSecret = trim((string) ($data['client_secret'] ?? ''));
if ($displayName === '') {
throw ValidationException::withMessages([
@ -2145,15 +2152,33 @@ public function updateSelectedProviderConnectionInline(int $providerConnectionId
$changedFields[] = 'client_id';
}
DB::transaction(function () use ($connection, $displayName, $clientId): void {
if ($clientId !== $existingClientId && $clientSecret === '') {
throw ValidationException::withMessages([
'client_secret' => 'Enter a new client secret when changing the App (client) ID.',
]);
}
if ($clientSecret !== '') {
$changedFields[] = 'client_secret';
}
DB::transaction(function () use ($connection, $displayName, $clientId, $clientSecret): void {
$connection->forceFill([
'display_name' => $displayName,
])->save();
app(CredentialManager::class)->updateClientIdPreservingSecret(
connection: $connection,
clientId: $clientId,
);
if ($clientSecret !== '') {
app(CredentialManager::class)->upsertClientSecretCredential(
connection: $connection,
clientId: $clientId,
clientSecret: $clientSecret,
);
} else {
app(CredentialManager::class)->updateClientIdPreservingSecret(
connection: $connection,
clientId: $clientId,
);
}
});
if ($changedFields !== []) {

View File

@ -5,7 +5,7 @@
namespace App\Filament\Resources\AlertDeliveryResource\Pages;
use App\Filament\Resources\AlertDeliveryResource;
use App\Models\Tenant;
use App\Support\Filament\CanonicalAdminTenantFilterState;
use App\Support\OperateHub\OperateHubShell;
use Filament\Resources\Pages\ListRecords;
@ -15,21 +15,7 @@ class ListAlertDeliveries extends ListRecords
public function mount(): void
{
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
if ($activeTenant instanceof Tenant) {
$filtersSessionKey = $this->getTableFiltersSessionKey();
$persistedFilters = session()->get($filtersSessionKey, []);
if (! is_array($persistedFilters)) {
$persistedFilters = [];
}
if (! is_string(data_get($persistedFilters, 'tenant_id.value'))) {
data_set($persistedFilters, 'tenant_id.value', (string) $activeTenant->getKey());
session()->put($filtersSessionKey, $persistedFilters);
}
}
app(CanonicalAdminTenantFilterState::class)->sync($this->getTableFiltersSessionKey(), request: request());
parent::mount();
}

View File

@ -2,12 +2,14 @@
namespace App\Filament\Resources;
use App\Filament\Concerns\ScopesGlobalSearchToTenant;
use App\Filament\Resources\EntraGroupResource\Pages;
use App\Models\EntraGroup;
use App\Models\Tenant;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\OperateHub\OperateHubShell;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
@ -17,6 +19,7 @@
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory;
use App\Support\Ui\EnterpriseDetail\SummaryHeaderData;
use BackedEnum;
use Filament\Facades\Filament;
use Filament\Infolists\Components\ViewEntry;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
@ -24,21 +27,35 @@
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
use UnitEnum;
class EntraGroupResource extends Resource
{
use ScopesGlobalSearchToTenant;
protected static bool $isScopedToTenant = false;
protected static ?string $model = EntraGroup::class;
protected static ?string $recordTitleAttribute = 'display_name';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-user-group';
protected static string|UnitEnum|null $navigationGroup = 'Directory';
protected static ?string $navigationLabel = 'Groups';
public static function shouldRegisterNavigation(): bool
{
if (Filament::getCurrentPanel()?->getId() === 'admin') {
return false;
}
return parent::shouldRegisterNavigation();
}
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
@ -75,13 +92,8 @@ public static function table(Table $table): Table
->persistFiltersInSession()
->persistSearchInSession()
->persistSortInSession()
->modifyQueryUsing(function (Builder $query): Builder {
$tenantId = Tenant::current()?->getKey();
return $query->when($tenantId, fn (Builder $q) => $q->where('tenant_id', $tenantId));
})
->recordUrl(static fn (EntraGroup $record): ?string => static::canView($record)
? static::getUrl('view', ['record' => $record])
? static::scopedUrl('view', ['record' => $record], static::panelTenantContext())
: null)
->columns([
Tables\Columns\TextColumn::make('display_name')
@ -176,7 +188,24 @@ public static function table(Table $table): Table
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()->latest('id');
$tenant = static::panelTenantContext();
return parent::getEloquentQuery()
->when(
$tenant instanceof Tenant,
fn (Builder $query): Builder => $query->where('tenant_id', (int) $tenant->getKey()),
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
)
->latest('id');
}
public static function getGlobalSearchResultUrl(Model $record): string
{
$tenant = $record instanceof EntraGroup && $record->tenant instanceof Tenant
? $record->tenant
: static::panelTenantContext();
return static::scopedUrl('view', ['record' => $record], $tenant);
}
public static function getPages(): array
@ -187,6 +216,33 @@ public static function getPages(): array
];
}
public static function panelTenantContext(): ?Tenant
{
if (Filament::getCurrentPanel()?->getId() === 'admin') {
$tenant = app(OperateHubShell::class)->activeEntitledTenant(request());
return $tenant instanceof Tenant ? $tenant : null;
}
$tenant = Tenant::current();
return $tenant instanceof Tenant ? $tenant : null;
}
/**
* @param array<string, mixed> $parameters
*/
public static function scopedUrl(
string $page = 'index',
array $parameters = [],
?Tenant $tenant = null,
?string $panel = null,
): string {
$panelId = $panel ?? Filament::getCurrentOrDefaultPanel()?->getId() ?? 'admin';
return static::getUrl($page, $parameters, panel: $panelId, tenant: $tenant);
}
private static function groupType(EntraGroup $record): string
{
$groupTypes = $record->group_types;

View File

@ -14,20 +14,35 @@
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\UiEnforcement;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Resources\Pages\ListRecords;
class ListEntraGroups extends ListRecords
{
protected static string $resource = EntraGroupResource::class;
public function mount(): void
{
if (
Filament::getCurrentPanel()?->getId() === 'admin'
&& ! EntraGroupResource::panelTenantContext() instanceof Tenant
) {
abort(404);
}
parent::mount();
}
protected function getHeaderActions(): array
{
$tenant = EntraGroupResource::panelTenantContext();
return [
Action::make('view_operations')
->label('Operations')
->icon('heroicon-o-clock')
->url(fn (): string => OperationRunLinks::index(Tenant::current()))
->visible(fn (): bool => (bool) Tenant::current()),
->url(fn (): string => OperationRunLinks::index($tenant))
->visible(fn (): bool => $tenant instanceof Tenant),
UiEnforcement::forAction(
Action::make('sync_groups')
->label('Sync Groups')
@ -35,7 +50,7 @@ protected function getHeaderActions(): array
->color('primary')
->action(function (): void {
$user = auth()->user();
$tenant = Tenant::current();
$tenant = EntraGroupResource::panelTenantContext();
if (! $user instanceof User || ! $tenant instanceof Tenant) {
return;

View File

@ -3,9 +3,43 @@
namespace App\Filament\Resources\EntraGroupResource\Pages;
use App\Filament\Resources\EntraGroupResource;
use App\Models\EntraGroup;
use App\Models\Tenant;
use App\Models\User;
use Filament\Facades\Filament;
use Filament\Resources\Pages\ViewRecord;
class ViewEntraGroup extends ViewRecord
{
protected static string $resource = EntraGroupResource::class;
protected function authorizeAccess(): void
{
$tenant = EntraGroupResource::panelTenantContext();
$record = $this->getRecord();
$user = auth()->user();
if (
Filament::getCurrentPanel()?->getId() === 'admin'
&& ! $tenant instanceof Tenant
) {
abort(404);
}
if (! $user instanceof User || ! $tenant instanceof Tenant || ! $record instanceof EntraGroup) {
abort(404);
}
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
abort(404);
}
if (! $user->canAccessTenant($tenant)) {
abort(404);
}
if (! $user->can('view', $record)) {
abort(403);
}
}
}

View File

@ -196,6 +196,7 @@ public static function table(Table $table): Table
Tables\Filters\SelectFilter::make('type')
->options(function (): array {
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
if ($workspaceId === null) {
return [];
@ -203,6 +204,10 @@ public static function table(Table $table): Table
$types = OperationRun::query()
->where('workspace_id', (int) $workspaceId)
->when(
$activeTenant instanceof Tenant,
fn (Builder $query): Builder => $query->where('tenant_id', (int) $activeTenant->getKey()),
)
->select('type')
->distinct()
->orderBy('type')
@ -228,9 +233,9 @@ public static function table(Table $table): Table
return [];
}
$tenant = Filament::getTenant();
$tenantId = $tenant instanceof Tenant && (int) $tenant->workspace_id === (int) $workspaceId
? (int) $tenant->getKey()
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
$tenantId = $activeTenant instanceof Tenant
? (int) $activeTenant->getKey()
: null;
return OperationRun::query()
@ -554,7 +559,7 @@ private static function verificationReportViewData(OperationRun $record): array
$previousRunUrl = null;
if ($changeIndicator !== null) {
$tenant = Filament::getTenant();
$tenant = app(OperateHubShell::class)->activeEntitledTenant(request());
$previousRunUrl = $tenant instanceof Tenant
? OperationRunLinks::view($changeIndicator['previous_report_id'], $tenant)

View File

@ -6,11 +6,11 @@
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\OperateHub\OperateHubShell;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OpsUx\ActiveRuns;
use Carbon\CarbonInterval;
use Filament\Facades\Filament;
use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
use Illuminate\Support\Collection;
@ -23,7 +23,7 @@ class OperationsKpiHeader extends StatsOverviewWidget
protected function getPollingInterval(): ?string
{
$tenant = Filament::getTenant();
$tenant = $this->activeTenant();
if (! $tenant instanceof Tenant) {
return null;
@ -37,7 +37,7 @@ protected function getPollingInterval(): ?string
*/
protected function getStats(): array
{
$tenant = Filament::getTenant();
$tenant = $this->activeTenant();
if (! $tenant instanceof Tenant) {
return [];
@ -110,6 +110,13 @@ protected function getStats(): array
];
}
private function activeTenant(): ?Tenant
{
$tenant = app(OperateHubShell::class)->activeEntitledTenant(request());
return $tenant instanceof Tenant ? $tenant : null;
}
private static function formatDurationSeconds(int $seconds): string
{
if ($seconds <= 0) {

View File

@ -5,6 +5,8 @@
use App\Models\EntraGroup;
use App\Models\Tenant;
use App\Models\User;
use App\Support\OperateHub\OperateHubShell;
use Filament\Facades\Filament;
use Illuminate\Auth\Access\HandlesAuthorization;
class EntraGroupPolicy
@ -13,7 +15,7 @@ class EntraGroupPolicy
public function viewAny(User $user): bool
{
$tenant = Tenant::current();
$tenant = $this->resolvedTenant();
if (! $tenant) {
return false;
@ -24,7 +26,7 @@ public function viewAny(User $user): bool
public function view(User $user, EntraGroup $group): bool
{
$tenant = Tenant::current();
$tenant = $this->resolvedTenant();
if (! $tenant) {
return false;
@ -36,4 +38,17 @@ public function view(User $user, EntraGroup $group): bool
return (int) $group->tenant_id === (int) $tenant->getKey();
}
private function resolvedTenant(): ?Tenant
{
if (Filament::getCurrentPanel()?->getId() === 'admin') {
$tenant = app(OperateHubShell::class)->activeEntitledTenant(request());
return $tenant instanceof Tenant ? $tenant : null;
}
$tenant = Tenant::current();
return $tenant instanceof Tenant ? $tenant : null;
}
}

View File

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Support\Filament;
use App\Models\Tenant;
use App\Support\OperateHub\OperateHubShell;
use Illuminate\Http\Request;
use Illuminate\Session\Store;
use Illuminate\Support\Arr;
final class CanonicalAdminTenantFilterState
{
private const STATE_PREFIX = 'filament.admin_tenant_filter_state';
public function __construct(private readonly OperateHubShell $operateHubShell) {}
/**
* @param array<int, string> $tenantSensitiveFilters
*/
public function sync(string $filtersSessionKey, array $tenantSensitiveFilters = [], ?Request $request = null): void
{
$session = $this->session($request);
$persistedFilters = $session->get($filtersSessionKey, []);
if (! is_array($persistedFilters)) {
$persistedFilters = [];
}
$activeTenant = $this->operateHubShell->activeEntitledTenant($request);
$resolvedTenantId = $activeTenant instanceof Tenant ? (string) $activeTenant->getKey() : null;
$stateKey = $this->stateKey($filtersSessionKey);
$previousTenantId = $session->get($stateKey);
if ($previousTenantId !== $resolvedTenantId) {
foreach ($tenantSensitiveFilters as $filterName) {
Arr::forget($persistedFilters, $filterName);
}
}
if ($resolvedTenantId !== null) {
data_set($persistedFilters, 'tenant_id.value', $resolvedTenantId);
} else {
Arr::forget($persistedFilters, 'tenant_id');
}
$session->put($filtersSessionKey, $persistedFilters);
$session->put($stateKey, $resolvedTenantId);
}
private function stateKey(string $filtersSessionKey): string
{
return self::STATE_PREFIX.'.'.md5($filtersSessionKey);
}
private function session(?Request $request = null): Store
{
return ($request && $request->hasSession()) ? $request->session() : app('session.store');
}
}

View File

@ -83,6 +83,16 @@ public function activeEntitledTenant(?Request $request = null): ?Tenant
private function resolveActiveTenant(?Request $request = null): ?Tenant
{
$routeTenant = $this->resolveRouteTenant($request);
if ($request?->route()?->hasParameter('tenant')) {
return $routeTenant;
}
if ($routeTenant instanceof Tenant) {
return $routeTenant;
}
$tenant = Filament::getTenant();
if ($tenant instanceof Tenant && $this->isEntitled($tenant, $request)) {
@ -98,16 +108,41 @@ private function resolveActiveTenant(?Request $request = null): ?Tenant
$rememberedTenant = Tenant::query()->whereKey($rememberedTenantId)->first();
if (! $rememberedTenant instanceof Tenant) {
$this->workspaceContext->clearLastTenantId($request);
return null;
}
if (! $this->isEntitled($rememberedTenant, $request)) {
$this->workspaceContext->clearLastTenantId($request);
return null;
}
return $rememberedTenant;
}
private function resolveRouteTenant(?Request $request = null): ?Tenant
{
$route = $request?->route();
if (! $route?->hasParameter('tenant')) {
return null;
}
$routeTenant = $route->parameter('tenant');
$tenant = $routeTenant instanceof Tenant
? $routeTenant
: Tenant::query()->withTrashed()->where('external_id', (string) $routeTenant)->first();
if (! $tenant instanceof Tenant || ! $this->isEntitled($tenant, $request)) {
return null;
}
return $tenant;
}
private function isEntitled(Tenant $tenant, ?Request $request = null): bool
{
if (! $tenant->isActive()) {

View File

@ -72,7 +72,7 @@ public static function related(OperationRun $run, ?Tenant $tenant): array
}
if ($run->type === 'entra_group_sync') {
$links['Directory Groups'] = EntraGroupResource::getUrl('index', panel: 'tenant', tenant: $tenant);
$links['Directory Groups'] = EntraGroupResource::scopedUrl('index', tenant: $tenant);
}
if ($run->type === 'baseline_compare') {

View File

@ -69,7 +69,7 @@ public function resolve(ReferenceDescriptor $descriptor): ResolvedReference
secondaryLabel: $this->groupTypeLabel($group),
linkTarget: new ReferenceLinkTarget(
targetKind: ReferenceClass::Group->value,
url: EntraGroupResource::getUrl('view', ['record' => $group], tenant: $group->tenant),
url: EntraGroupResource::scopedUrl('view', ['record' => $group], $group->tenant),
actionLabel: 'View group',
contextBadge: 'Tenant',
),

View File

@ -0,0 +1,45 @@
# Canonical Tenant Context Resolution
## Canonical Rule
- Tenant-panel and tenant-scoped flows keep panel-native tenant semantics through `Filament::getTenant()` / `Tenant::current()`.
- Workspace-admin and monitoring flows under `/admin/...` resolve the active tenant only through `App\Support\OperateHub\OperateHubShell::activeEntitledTenant(Request)`.
- When both admin sources exist and disagree, the entitled Filament tenant wins for the full request and remembered workspace tenant state remains fallback-only.
- Tenant-owned Entra groups remain tenant-panel-primary navigation. The admin-panel route may stay available for direct links or global search, but it must not appear as a regular workspace-admin sidebar entry.
## Explicit No-Context Outcomes
| Surface | No-context outcome |
| --- | --- |
| `/admin/operations` | Render the workspace-scoped `All tenants` state, clear tenant-default filters, and hide tenant-only KPI cards. |
| `/admin/operations/{run}` | Render only when workspace membership and run-tenant entitlement still pass; otherwise return `404`. |
| Admin Entra group list and view | Return `404` instead of widening scope. |
| Admin Entra group global search | Return no tenant-owned results. |
## Approved Exceptions
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Pages/ChooseTenant.php`
Tenant chooser remains panel-native because it is the explicit tenant-selection surface.
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Http/Controllers/SelectTenantController.php`
Explicit tenant selection persists remembered tenant state after a user choice.
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Middleware/EnsureFilamentTenantSelected.php`
Middleware is allowed to read and set `Filament::getTenant()` because it owns panel bootstrapping and route/query synchronization.
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Resources/EntraGroupResource.php`
Mixed-surface resource keeps one tenant-panel-native branch through `Tenant::current()` while admin panel logic delegates to `OperateHubShell`.
## Residual Inventory
- Compliant admin call sites:
`/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Pages/Monitoring/Operations.php`
`/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Widgets/Operations/OperationsKpiHeader.php`
`/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`
`/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Resources/OperationRunResource.php`
`/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Resources/AlertDeliveryResource.php`
`/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Pages/Monitoring/AuditLog.php`
`/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Resources/EntraGroupResource.php`
- Tenant-panel-native call sites intentionally outside the admin guard:
`/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Pages/InventoryCoverage.php`
`/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Pages/BaselineCompareLanding.php`
tenant-owned resources under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Resources/` that are routed only through tenant context.
- Deferred admin remediation:
none in the Spec 135 operations, OperationRun, alert-delivery reference, or Entra-group admin surfaces after this iteration.

View File

@ -43,6 +43,7 @@
$operateHubShell = app(OperateHubShell::class);
$currentTenant = $operateHubShell->activeEntitledTenant(request());
$currentTenantId = $currentTenant instanceof Tenant ? (int) $currentTenant->getKey() : null;
$currentTenantName = $currentTenant instanceof Tenant ? $currentTenant->getFilamentName() : null;
$hasAnyFilamentTenantContext = Filament::getTenant() instanceof Tenant;
@ -52,17 +53,6 @@
$tenantQuery = request()->query('tenant');
$hasTenantQuery = is_string($tenantQuery) && trim($tenantQuery) !== '';
if ($currentTenantName === null && $hasTenantQuery) {
$queriedTenant = $tenants->first(function ($tenant) use ($tenantQuery): bool {
return $tenant instanceof Tenant
&& ((string) $tenant->external_id === (string) $tenantQuery || (string) $tenant->getKey() === (string) $tenantQuery);
});
if ($queriedTenant instanceof Tenant) {
$currentTenantName = $queriedTenant->getFilamentName();
}
}
$isTenantScopedRoute = $route?->hasParameter('tenant')
|| ($hasTenantQuery && str_starts_with($routeName, 'filament.admin.'));
@ -179,7 +169,7 @@ class="fi-input fi-text-input w-full"
<div class="max-h-48 overflow-auto rounded-lg border border-gray-200 dark:border-gray-700">
@foreach ($tenants as $tenant)
@php
$isActive = $currentTenantName !== null && $tenant->getFilamentName() === $currentTenantName;
$isActive = $currentTenantId !== null && (int) $tenant->getKey() === $currentTenantId;
@endphp
<form method="POST" action="{{ route('admin.select-tenant') }}">

View File

@ -0,0 +1,37 @@
# Specification Quality Checklist: Canonical Tenant Context Resolution
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-03-11
**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
- Validation pass completed on 2026-03-11.
- No unresolved clarification markers remain.
- Architecture-specific policy names are included only where they define the required source-of-truth rule for this feature.
- Spec is ready for `/speckit.plan`.

View File

@ -0,0 +1,120 @@
version: 1
feature: 135-canonical-tenant-context-resolution
title: Canonical Tenant Context Resolution Matrix
type: internal-behavior-contract
canonical_rules:
tenant_panel:
source_of_truth: Filament::getTenant()
fallback_behavior: none
notes:
- Tenant-panel-native routes keep panel semantics.
- Remembered admin tenant state must not override tenant-panel context.
workspace_admin:
source_of_truth: App\Support\OperateHub\OperateHubShell::activeEntitledTenant
priority_order:
- entitled_filament_tenant
- entitled_remembered_workspace_tenant
- none
no_context_outcome_by_surface:
operations_monitoring_index: workspace_scoped_all_tenants_state_with_no_tenant_default_filter_and_no_tenant_only_kpis
tenantless_operation_run_detail: render_only_if_workspace_scope_and_tenant_entitlement_still_pass_otherwise_not_found
admin_entra_group_list_and_view: not_found
admin_entra_group_global_search: no_results_for_tenant_owned_records
request_states:
remembered_only:
filament_tenant: null
remembered_tenant: entitled
resolved_source: remembered
filament_only:
filament_tenant: entitled
remembered_tenant: null
resolved_source: filament
conflict:
filament_tenant: entitled
remembered_tenant: entitled_and_different
resolved_source: filament
no_context:
filament_tenant: null_or_not_entitled
remembered_tenant: null_or_not_entitled
resolved_source: none
surfaces:
- name: operations_monitoring_index
route: /admin/operations
panel: admin
expectations:
- header_scope_label_matches_resolved_tenant
- kpi_cards_match_resolved_tenant
- table_query_matches_resolved_tenant
- filter_defaults_match_resolved_tenant
- no_context_renders_workspace_scoped_all_tenants_state
- no_context_clears_tenant_default_filters
- no_context_hides_tenant_only_kpi_behavior
- name: tenantless_operation_run_detail
route: /admin/operations/{run}
panel: admin
expectations:
- authorization_uses_workspace_and_tenant_entitlement
- detail_visibility_is_not_broader_than_index_scope
- navigation_labels_use_same_resolved_tenant_as_admin_shell
- no_context_renders_only_when_workspace_scope_and_tenant_entitlement_still_pass
- no_context_otherwise_returns_not_found
- name: operation_run_filters
route: /admin/operations with persisted table filters
panel: admin
expectations:
- tenant_filter_options_do_not_exceed_list_scope
- tenant_filter_default_uses_resolved_tenant
- persisted_tenant_sensitive_values_are_revalidated_per_request
- name: entra_group_list_and_view
route: /admin/.../groups and /admin/.../groups/{record}
panel: mixed
expectations:
- tenant_panel_routes_use_panel_native_tenant
- admin_sensitive_access_paths_use_explicit_scope_rule
- direct_record_resolution_matches_list_scope
- admin_no_context_returns_not_found
- out_of_scope_access_returns_not_found
- name: entra_group_global_search
route: global-search
panel: mixed
expectations:
- search_results_follow_same_scope_as_list_and_detail
- non_members_receive_no_hints
- admin_no_context_returns_no_tenant_owned_results
- resource_search_is_disabled_if_scope_parity_cannot_be_guaranteed
- name: alert_delivery_reference_pattern
route: /admin/alert-deliveries
panel: admin
expectations:
- existing_admin_query_and_filter_alignment_is_preserved
- resource_remains_non_regression_reference
authorization_contract:
non_member_or_out_of_scope:
response: not_found
member_missing_capability:
response: forbidden
guardrail:
purpose: prevent_new_admin_panel_raw_panel_native_tenant_reads
forbidden_patterns:
- Filament::getTenant()
- Tenant::current()
enforced_in:
- app/Filament/Pages/Monitoring
- app/Filament/Pages/Operations
- app/Filament/Resources
- app/Filament/Widgets/Operations
allowed_exceptions:
- app/Filament/Pages/ChooseTenant.php
- app/Http/Controllers/SelectTenantController.php
- app/Support/Middleware/EnsureFilamentTenantSelected.php
- app/Filament/Resources/EntraGroupResource.php

View File

@ -0,0 +1,147 @@
# Data Model: Spec 135 Canonical Tenant Context Resolution
## Overview
This feature introduces no new database tables or persisted domain objects. Its data model is request-time and behavioral: it formalizes how existing workspace, tenant, filter, and record-access state combine into one canonical tenant-context outcome.
## Entity: Tenant Panel Context
**Purpose**: Represents the panel-native tenant context for tenant-panel flows.
**Source fields**:
- `panel_id`
- `tenant_route_parameter`
- `filament_tenant`
- `tenant_membership_status`
**Relationships**:
- belongs to one current panel
- resolves to one entitled tenant or null
**Validation rules**:
- Only valid on tenant-panel-native surfaces.
- Must not be substituted with admin remembered-tenant fallback semantics.
- Non-members must receive deny-as-not-found behavior.
## Entity: Admin Operational Tenant Context
**Purpose**: Represents the canonical active tenant for workspace-admin flows.
**Source fields**:
- `workspace_id`
- `filament_tenant_id` nullable
- `remembered_tenant_id` nullable
- `resolved_tenant_id` nullable
- `resolution_source` enum: `filament`, `remembered`, `none`
- `is_entitled` boolean
**Relationships**:
- belongs to one current workspace
- may resolve to one tenant in that workspace
- is used by headers, widgets, filters, queries, record links, and search
**Validation rules**:
- `resolved_tenant_id` must be null when no entitled tenant exists.
- If both `filament_tenant_id` and `remembered_tenant_id` exist and disagree, `filament_tenant_id` wins.
- Any resolved tenant must belong to the active workspace and pass tenant entitlement.
## Entity: Tenant Context Conflict
**Purpose**: Captures the request state where more than one tenant source exists for the same admin request.
**Fields**:
- `filament_tenant_id`
- `remembered_tenant_id`
- `workspace_id`
- `winning_source`
- `losing_source`
**Validation rules**:
- Conflict only exists when both source values are present and different.
- Conflict resolution must be deterministic for the entire request.
- Losing source must not leak into filters, widgets, counts, or navigation labels.
## Entity: Tenant-Sensitive Filter State
**Purpose**: Represents persisted or defaulted filter state whose valid values depend on the canonical tenant context.
**Fields**:
- `filter_name`
- `raw_value`
- `canonical_tenant_id` nullable
- `workspace_id`
- `is_valid_for_context` boolean
- `resolution_action` enum: `apply`, `reset`, `ignore`, `replace`
**Relationships**:
- belongs to one request flow
- may constrain one resource list or widget drill-down
**Validation rules**:
- Filter values must be revalidated whenever canonical tenant context changes.
- Filter option lists must never be broader than the underlying list/query scope.
- Invalid persisted tenant-sensitive values must not silently remain active after a tenant switch.
## Entity: Scoped Record Access Path
**Purpose**: Represents any route or UI affordance that can reveal a tenant-sensitive record.
**Fields**:
- `surface_type` enum: `list`, `detail`, `direct_url`, `deep_link`, `global_search`
- `resource_name`
- `workspace_id`
- `canonical_tenant_id` nullable
- `record_tenant_id` nullable
- `authorization_outcome` enum: `ok`, `not_found`, `forbidden`
**Relationships**:
- points to one resource class and optional record
- uses one context resolver appropriate to its panel
**Validation rules**:
- Detail/direct/search access must never be broader than the corresponding list scope.
- Out-of-scope or missing-context access must produce deterministic bounded behavior.
- Membership failures remain `not_found`; capability failures after membership is established remain `forbidden`.
## Entity: Admin Surface Guardrail Exception
**Purpose**: Documents files that are allowed to use panel-native tenant reads without violating the admin guardrail.
**Fields**:
- `file_path`
- `reason`
- `panel_semantics` enum: `tenant_native`, `approved_panel_native_surface`
- `review_owner`
**Validation rules**:
- Every exception must be explicit and stable.
- Admin-only files are not valid exceptions.
- Guardrail output must clearly distinguish violations from approved exceptions.
## State Transitions
### Admin request context state
1. `no_context`
- No valid Filament tenant and no valid remembered tenant.
- Outcome: safe empty/not-found bounded behavior.
2. `remembered_only`
- No valid Filament tenant, valid remembered tenant.
- Outcome: remembered tenant becomes canonical admin tenant.
3. `filament_only`
- Valid Filament tenant, no remembered tenant.
- Outcome: Filament tenant becomes canonical admin tenant.
4. `conflict`
- Valid Filament tenant and valid remembered tenant differ.
- Outcome: Filament tenant wins for the full request.
## Invariants
- Tenant-panel-native files use panel-native tenant semantics.
- Workspace-admin files use the canonical admin resolver when resolving an operational tenant.
- Visible tenant context and effective query scope must always match within the same request.
- Persisted tenant-sensitive filter state is never trusted without revalidation.
- Direct record URLs and search results cannot bypass the same tenant boundary as the list surface.

View File

@ -0,0 +1,223 @@
# Implementation Plan: Spec 135 Canonical Tenant Context Resolution
**Branch**: `135-canonical-tenant-context-resolution` | **Date**: 2026-03-11 | **Spec**: `specs/135-canonical-tenant-context-resolution/spec.md`
**Spec (absolute)**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/135-canonical-tenant-context-resolution/spec.md`
**Input**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/135-canonical-tenant-context-resolution/spec.md`
## Summary
Establish and enforce one explicit tenant-context rule across tenant-sensitive admin flows: tenant-panel screens continue to use panel-native tenant context, while workspace-admin screens use `OperateHubShell::activeEntitledTenant(Request $request): ?Tenant` as the only public canonical operational tenant resolver, with Filament tenant taking precedence over remembered tenant state when both exist.
Implementation focuses on three concrete remediation targets already visible in the codebase:
1. the workspace-admin Operations monitoring shell and KPI widget,
2. the OperationRun resource filters and detail navigation behavior used by Monitoring,
3. the Entra group list, direct-record, and global-search access path.
The work also adds one lightweight regression guard so new admin-panel code does not reintroduce raw tenant-panel-native tenant reads, while preserving tenant-panel-native behavior in approved tenant-context files.
This iteration remediates the known high-risk surfaces named in the spec and records a residual inventory for remaining admin call sites that are already compliant or intentionally deferred.
## Technical Context
**Language/Version**: PHP 8.4 on Laravel 12
**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, Laravel Sail
**Storage**: PostgreSQL application database
**Testing**: Pest feature tests via `vendor/bin/sail artisan test --compact`
**Target Platform**: Web application with Filament admin and tenant panels
**Project Type**: Laravel monolith with Filament resources, pages, widgets, policies, and support-layer helpers
**Performance Goals**: Maintain DB-only render behavior for monitoring/admin pages, avoid broader-than-visible query scopes, and keep context resolution deterministic per request without extra remote calls
**Constraints**:
- No dependency changes.
- No new Graph calls, queued jobs, or write workflows.
- No generic “universal tenancy resolver” that erases the admin versus tenant-panel distinction.
- Direct URLs, deep links, and global search must fail safe when context is missing or out of scope.
- Existing Filament action surfaces stay structurally unchanged unless a safe-state affordance is required.
**Scale/Scope**: Targeted architecture hardening across existing support helpers, 3 in-scope admin surfaces, 1 reference pattern, 1 lightweight guardrail, and a focused regression matrix covering remembered-only, Filament-only, conflict, and no-context states
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- **Inventory-first / snapshots**: PASS — this feature changes read-time scope resolution only; inventory, backups, and snapshots remain unchanged.
- **Read/write separation**: PASS — no new mutating flow is introduced; this is read, filter, navigation, and authorization hardening.
- **Graph contract path**: PASS — no Graph calls or contract-registry changes are involved.
- **Deterministic capabilities**: PASS — no capability registry changes; existing policies and capability helpers remain authoritative.
- **RBAC-UX planes**: PASS — `/admin` admin flows and `/admin/t/{tenant}/...` tenant-context flows stay explicitly separate; non-members remain 404, in-scope capability denials remain 403.
- **Workspace isolation**: PASS — workspace-admin routes stay tenant-safe even without tenant in the URL; missing workspace context continues to collapse to safe empty/not-found behavior.
- **Tenant isolation**: PASS — all in-scope queries, filter options, direct-record lookups, and search results are planned to remain bounded by tenant entitlement.
- **Global search safety**: PASS — tenant-sensitive resources will either adopt admin-safe scoped search behavior or disable global search where parity cannot be guaranteed.
- **Run observability**: PASS — no new operational run type or lifecycle behavior is introduced; Monitoring remains DB-only at render time.
- **Ops-UX lifecycle / summary counts / notifications**: PASS — unchanged.
- **BADGE-001**: PASS — badge semantics stay centralized; only the tenant context feeding those surfaces is normalized.
- **UI-NAMING-001**: PASS — plan preserves operator-facing language such as “Filtered by tenant” and explicit safe-state wording.
- **Filament Action Surface Contract**: PASS WITH EXEMPTION — affected resources/pages remain read-only or existing-action surfaces; the feature changes scoping and safe outcomes, not action inventory.
- **Filament UX-001**: PASS WITH EXEMPTION — no layout redesign is required beyond explicit safe no-context messaging.
- **Livewire v4.0+ compliance**: PASS — Filament v5 + Livewire v4 remain unchanged and are the required stack for all affected screens.
- **Provider registration location**: PASS — no panel-provider changes are needed; Laravel 12 provider registration remains in `bootstrap/providers.php`.
## Phase 0 — Research Summary
Research findings are recorded in `specs/135-canonical-tenant-context-resolution/research.md`.
Key decisions:
- Keep the two-context model explicit instead of abstracting it into a single cross-panel resolver.
- Treat `OperateHubShell::activeEntitledTenant()` as the admin-panel source of truth because it already implements the desired priority order: entitled Filament tenant first, remembered workspace tenant second, otherwise null.
- Use the existing alert-delivery resource as the reference pattern for admin-scope query, filter default, and filter-option alignment.
- Harden Entra group list/view/search behavior because the current table path scopes with `Tenant::current()` while `getEloquentQuery()` remains broader, creating direct-record and potential search drift.
- Implement a lightweight architectural guard as a focused test that flags `Filament::getTenant()` / `Tenant::current()` reads inside approved admin directories, with an allowlist for tenant-panel-native files.
## Phase 1 — Design & Contracts
### Data Model
Design details are recorded in `specs/135-canonical-tenant-context-resolution/data-model.md`.
Key design points:
- No schema changes are required.
- The feature introduces a request-time domain model around context state, not persisted business data.
- Canonical admin context is derived from workspace membership, current Filament tenant, remembered tenant session state, and entitlement checks.
- Filter state becomes explicitly revalidated view state, not trusted persisted state.
### Contracts
Internal behavior contracts are recorded in `specs/135-canonical-tenant-context-resolution/contracts/context-resolution-matrix.yaml`.
Contract scope:
- `/admin/operations`
- tenantless operation-run detail flows under `/admin/operations/{run}`
- admin-scoped OperationRun filters and related option lists
- Entra group list/view/global-search access paths
- alert-delivery admin resource as the non-regression reference pattern
### Quickstart
Implementation and verification steps are recorded in `specs/135-canonical-tenant-context-resolution/quickstart.md`.
### Post-Design Constitution Re-check
- **Inventory-first / snapshots**: PASS — unchanged.
- **Read/write separation**: PASS — only read-path behavior and regression guards are added.
- **Graph contract path**: PASS — still no Graph activity.
- **RBAC-UX**: PASS — design explicitly keeps 404 for out-of-scope access, 403 for capability denial after scope establishment, and server-side authorization for detail/search/list paths.
- **Workspace isolation**: PASS — admin routes without workspace or tenant context stay bounded and deterministic.
- **Tenant isolation**: PASS — detail URLs and search results are planned to use the same tenant-bound query contract as lists.
- **Global search safety**: PASS — design requires parity or explicit disablement for tenant-sensitive admin resources.
- **Run observability / Ops-UX lifecycle**: PASS — no changes.
- **BADGE-001 / UI-NAMING-001**: PASS — existing centralized badge and operator language rules remain intact.
- **Filament Action Surface Contract**: PASS WITH EXEMPTION — no new destructive actions, no action-surface expansion.
- **Livewire v4.0+ compliance**: PASS — all affected Filament screens remain on the supported Filament v5 / Livewire v4 stack.
- **Provider registration location**: PASS — no provider changes; `bootstrap/providers.php` remains the correct Laravel 12 location.
## Project Structure
### Documentation (this feature)
```text
specs/135-canonical-tenant-context-resolution/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── context-resolution-matrix.yaml
└── tasks.md
```
### Source Code (repository root)
```text
app/
├── Filament/
│ ├── Concerns/
│ ├── Pages/
│ │ ├── Monitoring/
│ │ └── Operations/
│ ├── Resources/
│ │ ├── EntraGroupResource/
│ │ └── AlertDeliveryResource/
│ └── Widgets/
│ └── Operations/
├── Models/
├── Policies/
├── Support/
│ ├── OperateHub/
│ ├── Workspaces/
│ └── Navigation/
└── Http/Controllers/
resources/views/
└── filament/
tests/
├── Feature/
│ ├── Monitoring/
│ ├── Operations/
│ ├── Filament/
│ └── Spec085/
└── Unit/
```
**Structure Decision**: Keep implementation inside the existing Laravel / Filament monolith. Reuse the support-layer admin resolver in `app/Support/OperateHub/`, harden the affected Filament pages/resources/widgets in place, and add focused Pest regression tests rather than introducing new infrastructure.
## Phase 2 — Implementation Planning
Implementation should be delivered in the following slices so `/speckit.tasks` can break work cleanly.
1. **Document and centralize the canonical rule**
- Refine and document `OperateHubShell::activeEntitledTenant(Request $request): ?Tenant` as the only public admin-panel tenant resolver.
- Any helper introduced for implementation convenience must remain internal-only and delegate to `OperateHubShell::activeEntitledTenant(Request)` rather than becoming a second public resolver.
- Ensure callers can distinguish admin-panel canonical context from tenant-panel-native context without introducing a universal cross-panel API.
- Preserve remembered-only support and explicit null/no-context outcomes.
2. **Remediate Monitoring shell consistency**
- Update `OperationsKpiHeader` to use the same canonical admin tenant as `Monitoring\Operations`.
- Verify header label, KPI cards, table query, and “Show all tenants” behavior stay aligned in remembered-only, Filament-only, conflict, and no-context states.
- Make the operations-index no-context outcome explicit: show the workspace-scoped `All tenants` state only for entitled tenants in the active workspace, clear tenant-default filters, and hide tenant-only KPI behavior instead of inferring a tenant.
3. **Harden OperationRun resource filter parity**
- Ensure tenant-sensitive filter options and defaults use the canonical admin resolver, not raw `Filament::getTenant()`.
- Revalidate persisted filter state whenever canonical tenant context changes between requests.
- Keep direct record/detail access bounded by the same workspace + tenant entitlement rules as the list, with explicit `404` for non-members or out-of-scope tenant access and `403` only after scope membership is established where capability gates apply.
- Make the run-detail no-context outcome explicit: allow rendering only for active-workspace records that still pass tenant entitlement checks when tenant-bound; otherwise respond as not found.
4. **Harden Entra group list, detail, and search behavior**
- Replace raw `Tenant::current()` assumptions for admin-sensitive access paths with explicit panel-aware resolution.
- Align `table()` scoping, `getEloquentQuery()`, record URL resolution, and view page behavior so direct record URLs cannot exceed list scope.
- Apply admin-safe global search scoping or disable global search for this resource if parity is not maintainable.
- Make the admin Entra-group no-context outcome explicit: admin list/detail requests respond as not found, and admin global search returns no tenant-owned Entra-group results when no canonical admin tenant exists.
- Preserve tenant-panel-native behavior in explicitly approved tenant-context files.
5. **Retain alert-delivery as the reference pattern**
- Confirm the alert-delivery resource continues to model the intended admin query and filter alignment.
- Refactor only if needed to extract a reusable pattern without regressing current behavior.
6. **Add the lightweight guardrail**
- Implement a focused regression/architecture test that scans approved admin Filament directories for new raw `Filament::getTenant()` / `Tenant::current()` patterns.
- Maintain an explicit allowlist for tenant-panel-native files and equivalent approved panel-native surfaces.
- Fail with actionable file references so future regressions are cheap to fix.
7. **Record residual scope explicitly**
- Document remaining admin tenant-resolution call sites outside the remediation set.
- Mark each residual call site as already compliant, intentionally excepted, or deferred beyond this iteration.
8. **Expand the regression matrix**
- Extend existing Spec 085 monitoring coverage for remembered-only, Filament-only, conflict, and no-context states.
- Add focused tests for OperationRun filter default/option parity, stale persisted filter handling, and positive/negative authorization semantics.
- Add Entra group list/view/search regression tests for direct URLs, out-of-scope access, no-context safety, and non-member-safe search behavior.
- Reuse existing factories, workspace-session helpers, and authorization helpers; avoid bespoke test harnesses.
## Testing Strategy
- Extend existing monitoring tests in `tests/Feature/Spec085/` rather than replacing them.
- Add focused feature coverage for:
- workspace-admin operations page and KPI consistency,
- tenantless operation-run detail access parity,
- OperationRun filter default and option scoping,
- Entra group list/view/global-search tenant safety,
- explicit `404` vs `403` authorization semantics for in-scope admin access paths,
- guardrail enforcement for new admin-only raw tenant reads.
- Run only the affected Pest files with Sail during implementation.
- Finish with `vendor/bin/sail bin pint --dirty --format agent` and the minimal related `vendor/bin/sail artisan test --compact ...` commands.
## Complexity Tracking
No constitution violations or complexity exemptions are required for this plan.

View File

@ -0,0 +1,90 @@
# Quickstart: Spec 135 Canonical Tenant Context Resolution
## Goal
Implement the canonical tenant-context rule for workspace-admin flows, preserve tenant-panel-native semantics, and leave the feature ready for direct test-driven implementation.
## Expected implementation slices
1. Refine or document the admin resolver contract in the support layer.
2. Align the Operations monitoring shell and KPI widget to the same canonical admin tenant.
3. Revalidate OperationRun tenant-sensitive filter defaults, options, and persisted state.
4. Harden Entra group list, detail, and search behavior to the same scope contract.
5. Preserve alert delivery as the reference admin pattern.
6. Add the architecture guardrail and regression tests.
## Recommended implementation order
1. Update the support-layer context contract and any small helper extraction first.
2. Fix Operations page plus KPI parity and extend existing monitoring tests.
3. Fix OperationRun filter option/default parity and stale persisted filter handling.
4. Fix Entra group query, record-resolution, and search behavior.
5. Add the guardrail test with explicit allowlist entries.
6. Run formatting and the minimal affected Pest suite.
## Focused verification commands
Run all commands from the repository root.
```bash
vendor/bin/sail artisan test --compact \
tests/Feature/Monitoring/OperationsKpiHeaderTenantContextTest.php \
tests/Feature/Monitoring/OperationsTenantScopeTest.php \
tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php \
tests/Feature/Spec085/OperationsIndexHeaderTest.php \
tests/Feature/Spec085/RunDetailBackAffordanceTest.php \
tests/Feature/Filament/OperationRunListFiltersTest.php \
tests/Feature/Filament/EntraGroupAdminScopeTest.php \
tests/Feature/Filament/EntraGroupGlobalSearchScopeTest.php \
tests/Feature/DirectoryGroups/BrowseGroupsTest.php \
tests/Feature/Filament/EntraGroupEnterpriseDetailPageTest.php \
tests/Feature/Filament/PolicyVersionResolvedReferenceLinksTest.php \
tests/Feature/Filament/EntraGroupResolvedReferencePresentationTest.php \
tests/Feature/Guards/AdminTenantResolverGuardTest.php \
tests/Feature/OpsUx/OperateHubShellTest.php \
tests/Feature/Filament/Alerts/AlertsKpiHeaderTest.php \
tests/Feature/Alerts/AlertDeliveryDeepLinkFiltersTest.php
vendor/bin/sail artisan test --compact \
tests/Feature/Filament/TableStatePersistenceTest.php \
tests/Feature/Filament/TenantScopingTest.php \
tests/Feature/Filament/Alerts/AlertDeliveryViewerTest.php \
tests/Unit/Support/References/CapabilityAwareReferenceResolverTest.php
vendor/bin/sail bin pint --dirty --format agent
```
## Scenario matrix to cover in tests
### Admin monitoring flows
- remembered-only request resolves one tenant across header, KPIs, and table
- Filament-only request resolves one tenant across header, KPIs, and table
- conflicting request prefers Filament tenant everywhere
- no-context request renders the workspace-scoped `All tenants` state, clears tenant-default filters, and suppresses tenant-only KPI behavior
### OperationRun filters and detail flows
- tenant filter defaults match canonical tenant
- tenant filter options never exceed current canonical tenant scope
- stale persisted filter state is reset, ignored, or replaced after tenant switch
- direct detail view does not reveal a broader record than the list would show
- no-context detail rendering is allowed only when the record still satisfies workspace scope and tenant entitlement; otherwise the response is not found
### Entra groups
- list query matches canonical tenant scope
- direct record URL obeys the same tenant boundary as the list
- admin list and direct record requests without canonical tenant context return not found
- out-of-scope requests return not found
- admin global search returns no tenant-owned Entra-group results without canonical tenant context, or is explicitly disabled
### Guardrail
- a new admin-only `Filament::getTenant()` or `Tenant::current()` read fails the architecture test
- approved tenant-panel-native files remain explicitly allowed
## Out of scope during implementation
- broad tenancy refactors outside the inconsistency class named in the spec
- dependency additions
- unrelated tenant-panel resource rewrites for style only
- new user-facing flows outside the existing admin and tenant-panel surfaces

View File

@ -0,0 +1,109 @@
# Research: Spec 135 Canonical Tenant Context Resolution
## Decision 1: Preserve explicit split semantics between panels
**Decision**: Keep tenant-panel and workspace-admin tenant resolution as two explicit concepts. Tenant-panel flows continue to use panel-native tenant context. Workspace-admin flows continue to use `OperateHubShell::activeEntitledTenant(Request $request): ?Tenant`.
**Rationale**:
- The spec explicitly forbids an unspecified universal resolver.
- Existing code already encodes the correct admin behavior in `OperateHubShell`.
- Tenant-panel screens legitimately use `Filament::getTenant()` and `Tenant::current()` because the tenant is part of panel-native routing semantics.
**Alternatives considered**:
- Build a single app-wide tenant resolver service: rejected because it would blur the admin-versus-tenant distinction the spec wants to preserve.
- Convert all existing tenant reads to admin semantics: rejected because tenant-panel pages are intentionally panel-native.
## Decision 2: Treat `OperateHubShell` as the canonical admin priority rule
**Decision**: Use `OperateHubShell::activeEntitledTenant(Request $request): ?Tenant` as the single admin source of truth and document its priority order as:
1. entitled Filament tenant,
2. entitled remembered workspace tenant,
3. null when neither is valid.
**Rationale**:
- `OperateHubShell` already resolves the conflict the spec describes.
- It already checks entitlement before returning a tenant.
- It already drives visible admin UI labels such as “Filtered by tenant” and tenant return affordances.
**Alternatives considered**:
- Resolve directly from session in each page/resource: rejected because the bug class is inconsistent per-surface resolution.
- Prefer remembered tenant over Filament tenant: rejected because existing admin code and the specs conflict scenario require Filament tenant to win.
## Decision 3: Use alert delivery as the admin reference pattern
**Decision**: Use `AlertDeliveryResource` as the “already correct” admin reference pattern for query scoping, tenant filter defaults, and tenant filter option narrowing.
**Rationale**:
- The resource already scopes by workspace and tenant entitlement.
- It already uses `OperateHubShell::activeEntitledTenant()` for both query narrowing and filter default behavior.
- Its record URL and read-only list structure already fit the features target class of admin monitoring flows.
**Alternatives considered**:
- Use Operations as the reference pattern: rejected because Operations is one of the surfaces that still has an inconsistency in its KPI widget.
- Use a tenant-panel resource as reference: rejected because the feature is specifically about admin-panel canonical behavior.
## Decision 4: Treat Operations page and KPI widget as one remediation unit
**Decision**: Plan the Operations page shell and `OperationsKpiHeader` widget as one remediation slice.
**Rationale**:
- `Monitoring\Operations` already uses `OperateHubShell` for header actions and table query narrowing.
- `OperationsKpiHeader` still reads `Filament::getTenant()` directly, which can disagree with the page header and table when only remembered tenant context exists.
- The spec requires visible tenant context, counts, widgets, filters, and links to resolve from the same canonical tenant.
**Alternatives considered**:
- Fix only the page query: rejected because the current inconsistency class is specifically page shell versus widget mismatch.
- Leave the widget workspace-wide while the table is tenant-scoped: rejected because that violates visible-scope parity.
## Decision 5: Harden OperationRun filters against stale persisted state
**Decision**: Revalidate tenant-sensitive persisted filters against the current canonical admin tenant on every relevant request.
**Rationale**:
- `OperationRunResource` persists filters in session.
- The tenant filter default already uses `OperateHubShell`, but some other filter option builders still reference raw `Filament::getTenant()`.
- The spec explicitly calls out persisted tenant-sensitive filter state as a regression risk when switching tenants.
**Alternatives considered**:
- Disable filter persistence entirely: rejected because persistence is a broader UX choice outside this feature.
- Trust the persisted filter if it still parses: rejected because syntactic validity is not the same as tenant-scope validity.
## Decision 6: Entra groups need record-resolution hardening, not just table scoping
**Decision**: Treat Entra groups as a list-plus-record-resolution problem. Scope must be aligned in `table()`, `getEloquentQuery()`, direct record/view access, and global search.
**Rationale**:
- `EntraGroupResource::table()` narrows using `Tenant::current()`.
- `EntraGroupResource::getEloquentQuery()` currently returns the broader base query.
- The resource has a view page, so direct record URLs can bypass table-only constraints.
- The spec explicitly names Entra group record-resolution and search as in-scope remediation.
**Alternatives considered**:
- Scope only the table query: rejected because the feature exists to fix list/detail/search parity.
- Remove the view page: rejected because the spec focuses on safe access, not reducing product capability.
## Decision 7: Use a panel-aware search rule or disable admin global search for Entra groups
**Decision**: Prefer a panel-aware scoped global-search query for Entra groups. If implementation cannot guarantee parity cheaply, disable global search for that resource in admin contexts.
**Rationale**:
- The constitution and spec both require tenant-safe, non-member-safe search behavior.
- The repo already has a tenant-scoped search concern based on `Filament::getTenant()`, but that is not sufficient for admin remembered-tenant semantics.
- The spec allows disabling global search where safe parity cannot be guaranteed.
**Alternatives considered**:
- Leave current search behavior implicit: rejected because the spec explicitly calls out search entry points.
- Build a broad search-only exception: rejected because it would reintroduce scope drift.
## Decision 8: The guardrail should be a focused architecture test, not a new lint tool
**Decision**: Implement the lightweight guardrail as a Pest architecture/regression test that scans selected admin Filament paths for raw `Filament::getTenant()` and `Tenant::current()` patterns, with a documented allowlist for tenant-panel-native files.
**Rationale**:
- The repo already relies on Pest for regression guards.
- This keeps the feature self-contained and avoids adding external tooling.
- The test can fail with actionable file paths and still permit explicit documented exceptions.
**Alternatives considered**:
- Add a custom PHPStan rule or external linter: rejected because it is heavier than the spec requires.
- Rely on code review discipline only: rejected because the feature explicitly asks for a maintainable guardrail.

View File

@ -0,0 +1,207 @@
# Feature Specification: Canonical Tenant Context Resolution
**Feature Branch**: `135-canonical-tenant-context-resolution`
**Created**: 2026-03-11
**Status**: Draft
**Input**: User description: "Spec 135 — Canonical Tenant Context Resolution for Admin and Tenant Panel Flows"
## Spec Scope Fields *(mandatory)*
- **Scope**: canonical-view
- **Primary Routes**:
- Workspace-admin monitoring and operations flows under `/admin/...`
- Workspace-admin resource detail and list flows that surface tenant-sensitive data under `/admin/...`
- Tenant-context operational flows under `/admin/t/{tenant}/...` that already rely on panel-native tenant context
- Direct record URLs, deep links, and global-search entry points that can open tenant-sensitive resources without first visiting a list page
- **Data Ownership**:
- Existing workspace-owned monitoring, operation, alert, and governance records remain the system of record
- Existing tenant-owned or tenant-associated data remains unchanged; this feature standardizes how active tenant context is resolved before data is shown, filtered, or linked
- No new business domain is introduced; the feature defines and enforces a single resolution rule for the known in-scope Filament screens, searches, and detail routes named below, and leaves a documented residual inventory for remaining admin call sites that are already compliant or intentionally out of scope for this iteration
- **RBAC**:
- Workspace membership remains the prerequisite for workspace-admin monitoring flows
- Tenant entitlement remains required before tenant-scoped or tenant-sensitive data may be shown inside admin or tenant-panel journeys
- Capability checks continue to gate protected actions, links, and resource access
- Non-members and out-of-scope tenant access remain deny-as-not-found, while in-scope users without the needed capability continue to receive explicit forbidden behavior on protected targets
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: Workspace-admin flows that support an active operational tenant must default filters, filter options, widgets, badges, counts, and linked destinations from the same canonical tenant context. Tenant-panel flows remain panel-native and must not inherit fallback admin semantics.
- **Explicit entitlement checks preventing cross-tenant leakage**: Every in-scope admin query, filter option set, record lookup, and global-search result must enforce workspace and tenant entitlement checks before rendering data, labels, or navigation. No deep link, global-search path, or direct record URL may broaden scope beyond the canonical tenant context and the user's entitled access.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Trust the visible tenant context (Priority: P1)
As an enterprise operator, I want every admin monitoring surface to use one visible and actual tenant context so that headers, counts, tables, filters, actions, and links never contradict each other.
**Why this priority**: The feature exists to remove the core architectural inconsistency that can otherwise mislead operators and weaken safety boundaries.
**Independent Test**: Can be fully tested by loading the same monitoring flow under remembered-only, Filament-only, conflicting, and no-context request states and verifying that every visible surface resolves the same tenant or the same safe no-context outcome.
**Acceptance Scenarios**:
1. **Given** an admin request with only a remembered current tenant, **When** the operator opens a tenant-sensitive monitoring flow, **Then** the header, KPIs, table data, filter defaults, and filter options all resolve from that same tenant.
2. **Given** an admin request with both a Filament tenant and a different remembered tenant, **When** the flow renders, **Then** the Filament tenant wins consistently for every visible and query-driven surface.
3. **Given** an admin request with no valid tenant context, **When** the operator opens a tenant-sensitive flow, **Then** the page returns a defined safe outcome instead of silently broadening scope.
---
### User Story 2 - Use filters and detail links safely (Priority: P1)
As an enterprise operator, I want tenant-sensitive filters, detail pages, and direct links to respect the same scope as the list I came from so that I never see cross-tenant options or open records that should not be reachable.
**Why this priority**: Filter options, direct record resolution, and deep links are the most likely places for scope drift to become a real safety or data-leak problem.
**Independent Test**: Can be fully tested by loading tenant-sensitive resources from list pages, direct URLs, and persisted filter state while switching tenant context between requests.
**Acceptance Scenarios**:
1. **Given** a tenant-sensitive resource list in the admin panel, **When** the operator opens filter dropdowns, **Then** every option is drawn from the same canonical tenant scope as the list results.
2. **Given** a persisted tenant-sensitive filter from Tenant A, **When** the operator later opens the same flow in Tenant B, **Then** invalid persisted filter state is reset, replaced, or ignored rather than silently reusing the old tenant value.
3. **Given** a direct record URL or a global-search result for a tenant-sensitive record, **When** the current context is missing or out of scope, **Then** the request returns the same safe bounded behavior as the list flow rather than a broader record lookup.
---
### User Story 3 - Preserve separate panel semantics (Priority: P2)
As a product team member, I want tenant-panel and workspace-admin semantics to stay explicitly separate so that future changes do not reintroduce hidden resolver conflicts or generic context magic.
**Why this priority**: The system has two valid but different tenant concepts, and long-term stability depends on documenting and enforcing that separation.
**Independent Test**: Can be fully tested by validating that tenant-panel flows continue to use panel-native tenant context while admin-panel flows use the canonical admin resolver, with no mixed behavior in in-scope screens.
**Acceptance Scenarios**:
1. **Given** a tenant-panel page, **When** it resolves active tenant context, **Then** it continues to use the panel-native tenant model.
2. **Given** an admin-panel monitoring page, **When** it resolves active tenant context, **Then** it uses only the canonical admin resolver and not tenant-panel-native behavior.
3. **Given** a tenant-owned Entra group surface, **When** the product renders primary sidebar navigation, **Then** the entry appears only in tenant-panel navigation while any admin-panel access remains a secondary direct-link or global-search path under the canonical admin tenant contract.
---
### User Story 4 - Prevent regressions cheaply (Priority: P3)
As a maintainer, I want a lightweight guardrail and regression matrix around tenant-context resolution so that new admin-panel code cannot quietly reintroduce the same class of inconsistency.
**Why this priority**: The architectural risk is recurring drift, not just the currently known defects.
**Independent Test**: Can be fully tested by running the focused regression suite plus the agreed lightweight guardrail and verifying that new direct admin-panel tenant reads are flagged before merge.
**Acceptance Scenarios**:
1. **Given** a new admin-panel tenant-sensitive flow, **When** it introduces an unauthorized direct tenant read pattern, **Then** the guardrail or regression process flags it before release.
2. **Given** the known in-scope flows, **When** the regression suite runs, **Then** remembered-only, Filament-only, conflict, and no-context cases remain covered.
### Edge Cases
- A request may carry both a panel tenant and a remembered tenant, and those two values may disagree.
- A remembered tenant may be the only available tenant context in admin flows and must remain a first-class supported state.
- Persisted filter state may contain tenant-sensitive values that no longer match the current tenant context after a switch.
- A list page may be correctly scoped while the corresponding direct record resolution or global-search path is broader.
- A deep link may open a tenant-sensitive detail page before any explicit tenant choice was made in the current browser session.
- Some tenant-panel files are allowed to use panel-native tenant semantics and must not be treated as admin-panel violations.
### Explicit No-Context Outcomes
- **Operations monitoring index `/admin/operations`**: No admin tenant context resolves to an explicit workspace-scoped `All tenants` state that is bounded to entitled tenants in the active workspace, clears tenant-default filters, and hides tenant-only KPI behavior instead of inferring a tenant.
- **Operation run detail `/admin/operations/{run}`**: No admin tenant context still permits detail rendering only when the run belongs to the active workspace and the operator is entitled to the run's tenant when one exists; otherwise the response is deny-as-not-found.
- **Admin Entra group list/detail paths**: No admin tenant context results in deny-as-not-found for admin list and detail access rather than a broader workspace-wide group view.
- **Admin Entra group global search**: No admin tenant context returns no results for tenant-owned Entra group records.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature introduces no new Microsoft Graph calls, no new write workflow, and no new scheduled or queued job. It is an architecture-consistency, safety, and regression-hardening feature over existing admin and tenant-panel read and navigation flows. Existing operational data remains authoritative.
**Constitution alignment (OPS-UX):** This feature does not create or mutate `OperationRun` records. It may affect how existing monitoring and operation-run data is filtered or displayed, but it does not change run ownership, lifecycle transitions, or notification contracts.
**Constitution alignment (RBAC-UX):** This feature affects workspace-admin `/admin/...` flows and tenant-context `/admin/t/{tenant}/...` flows. Cross-plane access remains deny-as-not-found. Users outside workspace or tenant entitlement remain 404-style not-found. In-scope users lacking a protected capability continue to receive forbidden behavior on protected targets. Authorization remains server-side for list queries, filter option population, direct record resolution, global search, and any protected navigation. No raw capability strings or role-name checks may be introduced. Global-search behavior must remain tenant-safe and non-member-safe.
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. No authentication handshake or outbound auth exception behavior is introduced.
**Constitution alignment (BADGE-001):** If in-scope pages show tenant-related badges, counts, or status surfaces, those semantics must continue to come from centralized badge rules rather than page-local mappings. This feature standardizes the tenant context behind those surfaces, not their domain meanings.
**Constitution alignment (UI-NAMING-001):** Operator-facing wording must consistently describe the active tenant context using the same domain language across headers, badges, filters, links, and safe-state messaging. Implementation-first wording must not become the primary operator language.
**Constitution alignment (Filament Action Surfaces):** This feature modifies existing Filament pages, widgets, and resources. The Action Surface Contract remains satisfied because the feature is primarily read, filter, and navigation hardening. No new destructive action is introduced. Existing destructive actions remain subject to confirmation and authorization rules.
**Constitution alignment (UX-001 — Layout & Information Architecture):** In-scope Filament screens must keep their established list, widget, and detail layouts while making visible tenant context and safe no-context outcomes clearer and more consistent. Empty or no-context states must be explicit and operator-safe, not silent broad-scope fallbacks.
### Functional Requirements
- **FR-135-01 Canonical architecture rule**: The product must define one explicit, durable architecture rule that states which tenant-context source applies in the tenant panel and which tenant-context source applies in the workspace-admin panel.
- **FR-135-02 Separate panel semantics**: Tenant-panel context and workspace-admin context must remain intentionally separate concepts and must not be merged behind an unspecified universal resolver.
- **FR-135-03 Tenant-panel source of truth**: Tenant-panel flows must continue to treat `Filament::getTenant()` as the canonical tenant-context source.
- **FR-135-04 Admin-panel source of truth**: Workspace-admin and monitoring flows must treat `OperateHubShell::activeEntitledTenant(Request $request): ?Tenant` as the only canonical API for active operational tenant context.
- **FR-135-05 Admin fallback behavior**: In workspace-admin flows, remembered or current tenant state may act only as fallback behind an available Filament tenant and must never override it.
- **FR-135-06 Conflict priority rule**: When both admin-context tenant sources exist and disagree, the product must apply one hard priority rule so every in-scope surface resolves the same tenant.
- **FR-135-07 Scope follows visible context**: Headers, badges, KPIs, table queries, filter defaults, filter options, actions, and links within the same request flow must use the same tenant-context resolution.
- **FR-135-08 Remembered-only support**: Workspace-admin flows must support remembered-only tenant context as a first-class valid state and must not collapse into empty or misleading output when that is the only available context.
- **FR-135-09 No-context safety**: Missing tenant context in tenant-sensitive workspace-admin flows must produce a deterministic safe outcome rather than a broader query scope.
- **FR-135-10 Filter option parity**: Tenant-sensitive filter option lists must never be broader than the list or view scope they control.
- **FR-135-11 Filter persistence safety**: Persisted tenant-sensitive filter state must be revalidated against the current canonical tenant context on each relevant request.
- **FR-135-12 Consistent default filters**: Tenant-sensitive filter defaults must resolve from the same canonical tenant context as the rest of the page.
- **FR-135-13 Widget consistency**: Monitoring widgets and page-level summaries must use the same tenant-context resolver as the surrounding page header and table.
- **FR-135-14 Resource query hardening**: Tenant- or workspace-dependent resources must not rely on table-only scoping when those resources can also be reached by direct record URLs, deep links, or global-search results.
- **FR-135-15 Record-resolution parity**: Direct record resolution must never be broader than the visible list or detail scope for the same resource.
- **FR-135-16 Global-search safety**: Global-search behavior for tenant-sensitive resources must either follow the same scope rules as list and detail flows or be disabled where that cannot be guaranteed.
- **FR-135-17 In-scope remediation set**: The known inconsistency class must be corrected at minimum for the operations monitoring KPI flow, the tenant-sensitive operation-run filter flow, and the tenant-sensitive Entra group record-resolution and search flow.
- **FR-135-18 Reference pattern retention**: The already-correct alert-delivery pattern must remain a valid reference example for the same bug class and must not regress.
- **FR-135-19 Lightweight guardrail**: The product must add one lightweight and maintainable guardrail against new direct admin-panel tenant reads through tenant-panel-native patterns.
- **FR-135-20 Documented exceptions**: Allowed exceptions to the admin guardrail must be explicitly documented for tenant-panel-native files and equivalent panel-native tenant surfaces.
- **FR-135-21 Regression matrix**: The regression suite for this feature must cover remembered-only, Filament-only, conflict, and no-context request states across the in-scope flows.
- **FR-135-22 Deep-link determinism**: Direct links into tenant-sensitive admin pages and detail pages must behave deterministically and safely even when no prior list page or tenant-selection step was visited.
- **FR-135-23 Authorization semantics parity**: In-scope admin list, detail, deep-link, and search paths must preserve deny-as-not-found for non-members or out-of-scope tenant access and preserve forbidden responses only after scope membership is established.
- **FR-135-24 Residual inventory clarity**: This iteration must leave a documented inventory of remaining admin tenant-context call sites that are already compliant or intentionally out of scope so completion does not imply undocumented repo-wide remediation.
### Non-Goals
- Reworking the full workspace-scoping model
- Redesigning navigation or information architecture beyond what is needed for consistent tenant context
- Introducing new tenancy features or a new universal context service for unrelated domains
- Performing a full security audit of every resource in the codebase
- Refactoring correctly functioning tenant-panel resources only for stylistic consistency
- Broadly rewriting all current resources that are already outside the inconsistency class addressed here
### Assumptions
- The product already has a remembered or current tenant concept in workspace-admin flows that operators rely on today.
- Tenant-panel flows already use valid panel-native tenant context and should remain unchanged unless they participate directly in an inconsistency addressed by this feature.
- Alert delivery behavior already demonstrates the intended scope-alignment pattern for this bug class.
- Deep-link, record-resolution, and global-search paths are part of the operator experience and therefore must follow the same safety rule as list pages.
### Dependencies
- Existing workspace membership and tenant entitlement enforcement
- Existing admin monitoring and operation resources that already surface tenant-sensitive data
- Existing remembered current-tenant behavior in workspace-admin flows
- Existing global-search and direct-record routing behavior for in-scope resources
- Existing focused tests around alerts, operations, monitoring, and resource authorization that can be extended for regression coverage
## UI Action Matrix *(mandatory when Filament is changed)*
| 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 |
|---|---|---|---|---|---|---|---|---|---|---|
| Operations monitoring page | Workspace-admin monitoring flows under `/admin/...` | Existing non-destructive monitoring actions only | Header tenant context, KPI widget, and table remain the primary inspect affordances | Existing inspect actions only | None new | Safe no-context state may expose one operator-safe next step when appropriate | Existing view actions only | N/A | No new audit event | This feature aligns tenant context across header, KPIs, filters, and table data; it introduces no new destructive action. |
| Operations KPI widget | Workspace-admin monitoring widget surface | None | KPI cards remain the inspect affordance | Linked drill-downs only if already present and authorized | None | N/A | N/A | N/A | No | Widget behavior must remain aligned with the surrounding monitoring page and its polling or refresh behavior. |
| Operation runs resource | Workspace-admin list and detail flows | Existing non-destructive header actions only | Table and filter UI remain the primary inspect affordance | Existing inspect actions only | Existing grouped bulk actions, unchanged | Existing empty state remains | Existing view actions only | Existing create or edit semantics, unchanged if present | No new audit event | Tenant-sensitive filter defaults and options must remain no broader than the underlying list and detail scope. |
| Entra groups resource | Any in-scope admin resource list, detail, or search entry point | Existing non-destructive header actions only | List, direct record URL, and global-search entry remain in scope | Existing inspect actions only | Existing grouped bulk actions, unchanged | Existing empty state remains | Existing view actions only | Existing create or edit semantics, unchanged if present | No new audit event | The critical change is scope hardening so direct record resolution and search paths cannot exceed list scope. |
### Key Entities *(include if feature involves data)*
- **Tenant Panel Context**: The panel-native tenant context used inside tenant-panel flows where tenant is part of the panel model itself.
- **Admin Operational Tenant Context**: The active tenant context used in workspace-admin monitoring and tenant-sensitive admin flows to align visible context with query scope.
- **Tenant Context Conflict**: A request state where more than one tenant source is present and the product must apply a documented priority rule.
- **Tenant-Sensitive Filter State**: Persisted or defaulted filter input whose valid values depend on the canonical tenant context.
- **Scoped Record Access Path**: Any list, detail, deep link, or global-search route that must obey the same tenant and entitlement boundaries.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-135-01 Context consistency**: In focused acceptance and regression coverage, 100% of in-scope remembered-only, Filament-only, conflict, and no-context scenarios produce one consistent tenant-context outcome per request flow.
- **SC-135-02 Filter safety**: In regression tests for tenant-sensitive filters, 100% of covered filter option sets stay within the same tenant scope as the underlying list results.
- **SC-135-03 Deep-link safety**: In direct record, deep-link, and global-search regression coverage for in-scope resources, 100% of covered out-of-scope or no-context requests avoid broader record access than the corresponding list flow.
- **SC-135-04 Remediation coverage**: The known inconsistency class is eliminated for the operations KPI flow, operation-run tenant-sensitive filter flow, and Entra group record-resolution or search flow in all covered test states.
- **SC-135-05 Guardrail effectiveness**: The agreed lightweight guardrail clearly distinguishes allowed tenant-panel-native usage from disallowed new admin-panel usage of tenant-panel-native tenant reads.
- **SC-135-06 Architecture clarity**: A maintainer can determine the correct tenant-context source, conflict rule, and safe behavior for missing context directly from the feature specification and referenced architecture rule without reading implementation code first.
- **SC-135-07 Surface-specific no-context clarity**: The operations list, operation-run detail flow, admin Entra group list/detail flow, and admin Entra group global search each have one explicitly documented and testable no-context outcome.
- **SC-135-08 Residual scope inventory**: The architecture note for this feature lists remaining admin tenant-resolution call sites outside the remediation set and identifies them as compliant, exception-based, or deferred.

View File

@ -0,0 +1,239 @@
# Tasks: Canonical Tenant Context Resolution
**Input**: Design documents from `/specs/135-canonical-tenant-context-resolution/`
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `contracts/context-resolution-matrix.yaml`, `quickstart.md`
**Tests**: Tests are REQUIRED for this feature because it changes runtime behavior across existing Filament admin flows, filter persistence, direct-record access, and search safety.
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Create the focused documentation and regression entry points used by the implementation slices.
- [X] T001 Create the focused regression test entry points in `tests/Feature/Monitoring/OperationsKpiHeaderTenantContextTest.php`, `tests/Feature/Filament/EntraGroupAdminScopeTest.php`, `tests/Feature/Filament/EntraGroupGlobalSearchScopeTest.php`, and `tests/Feature/Guards/AdminTenantResolverGuardTest.php`
- [X] T002 Create the durable architecture note and residual admin call-site inventory entry point in `docs/research/canonical-tenant-context-resolution.md`
- [X] T003 [P] Align the verification references in `specs/135-canonical-tenant-context-resolution/quickstart.md` and `specs/135-canonical-tenant-context-resolution/contracts/context-resolution-matrix.yaml` with the final implementation slices
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Lock in the single public admin resolver rule, explicit per-surface no-context behavior, shared context display, and reusable admin reference pattern before story-specific remediation begins.
**⚠️ CRITICAL**: No user story work can begin until this phase is complete.
- [X] T004 Define `app/Support/OperateHub/OperateHubShell.php::activeEntitledTenant(request())` as the only public admin resolver, keep any helpers internal-only, and codify Filament-wins conflict precedence plus explicit no-context outcomes in `app/Support/OperateHub/OperateHubShell.php` and `app/Support/Workspaces/WorkspaceContext.php`
- [X] T005 [P] Add remembered-only, Filament-only, conflict, and no-context resolver coverage in `tests/Feature/OpsUx/OperateHubShellTest.php`
- [X] T006 [P] Align shared admin context display with the canonical resolver in `resources/views/filament/partials/context-bar.blade.php` and `tests/Feature/Monitoring/HeaderContextBarTest.php`
- [X] T007 [P] Document the admin-versus-tenant panel rule, exact no-context outcomes per in-scope surface, approved exception inventory, and residual admin call-site inventory in `docs/research/canonical-tenant-context-resolution.md`
- [X] T008 Preserve `app/Filament/Resources/AlertDeliveryResource.php` and `app/Filament/Resources/AlertDeliveryResource/Pages/ListAlertDeliveries.php` as the non-regression admin reference pattern, extracting only the smallest reusable filter-persistence helper if a minimal extraction is necessary
**Checkpoint**: The canonical rule, shared resolver behavior, and reusable admin reference pattern are ready; user-story work can now begin.
---
## Phase 3: User Story 1 - Trust The Visible Tenant Context (Priority: P1) 🎯 MVP
**Goal**: Make the workspace-admin operations shell, KPI widget, and related detail affordances resolve one tenant context per request.
**Independent Test**: Load the operations monitoring flow under remembered-only, Filament-only, conflict, and no-context request states and verify that the header, KPIs, table scope, and run-detail affordances all resolve the same tenant or the same safe bounded outcome.
### Tests for User Story 1
- [X] T009 [P] [US1] Extend operations-shell parity coverage in `tests/Feature/Spec085/OperationsIndexHeaderTest.php` and `tests/Feature/Monitoring/OperationsTenantScopeTest.php`, including the explicit workspace-scoped `All tenants` no-context state
- [X] T010 [P] [US1] Add KPI tenant-context coverage in `tests/Feature/Monitoring/OperationsKpiHeaderTenantContextTest.php` and `tests/Feature/078/KpiHeaderTenantlessTest.php`
- [X] T011 [P] [US1] Extend run-detail context-affordance and authorization coverage in `tests/Feature/Spec085/RunDetailBackAffordanceTest.php` and `tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php`, including positive access plus `404` for out-of-scope tenant-bound runs
### Implementation for User Story 1
- [X] T012 [US1] Update `app/Filament/Widgets/Operations/OperationsKpiHeader.php` to resolve tenant context only through `app/Support/OperateHub/OperateHubShell.php` and keep polling safe when no canonical tenant exists
- [X] T013 [US1] Update `app/Filament/Pages/Monitoring/Operations.php` so header actions, scope labels, tab queries, table filter state, and the no-context `All tenants` view all use the same canonical admin tenant contract
- [X] T014 [US1] Update `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` and `app/Filament/Resources/OperationRunResource.php` so run-detail affordances stay aligned with the canonical operations context and enforce the documented no-context detail rule
**Checkpoint**: User Story 1 is complete when the operations monitoring shell and its KPI widget no longer drift from each other across all four request states.
---
## Phase 4: User Story 2 - Use Filters And Detail Links Safely (Priority: P1)
**Goal**: Keep tenant-sensitive filters, persisted filter state, direct record access, and global-search entry points within the same scope contract as the visible list.
**Independent Test**: Use the operations and Entra group flows from list pages, persisted filters, direct URLs, and search entry points while switching tenant context between requests, and verify that filters, records, and search results never exceed the current canonical scope.
### Tests for User Story 2
- [X] T015 [P] [US2] Add OperationRun filter default, filter-option, persisted-state, and positive or negative admin detail-authorization coverage in `tests/Feature/Filament/OperationRunListFiltersTest.php` and `tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php`
- [X] T016 [P] [US2] Add Entra group admin list and detail scope coverage plus explicit `404` versus `403` authorization coverage in `tests/Feature/Filament/EntraGroupAdminScopeTest.php` and `tests/Feature/Filament/EntraGroupEnterpriseDetailPageTest.php`
- [X] T017 [P] [US2] Add Entra group global-search parity coverage and non-member-safe no-result coverage in `tests/Feature/Filament/EntraGroupGlobalSearchScopeTest.php` and `tests/Feature/Filament/EntraGroupResolvedReferencePresentationTest.php`
### Implementation for User Story 2
- [X] T018 [US2] Harden tenant-sensitive filter defaults, option builders, and workspace-bounded list queries in `app/Filament/Resources/OperationRunResource.php`
- [X] T019 [US2] Apply the alert-delivery persisted-filter seeding pattern from `app/Filament/Resources/AlertDeliveryResource.php` and `app/Filament/Resources/AlertDeliveryResource/Pages/ListAlertDeliveries.php` to `app/Filament/Pages/Monitoring/Operations.php` and `app/Filament/Resources/OperationRunResource.php`
- [X] T020 [US2] Align tenantless operation-run detail authorization and deep-link behavior in `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` and `app/Filament/Resources/OperationRunResource.php`, using `404` for out-of-scope tenant-bound records and no broader no-context lookup path
- [X] T021 [US2] Harden admin list, direct-record, and view scoping in `app/Filament/Resources/EntraGroupResource.php`, `app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php`, and `app/Filament/Resources/EntraGroupResource/Pages/ViewEntraGroup.php`, with deny-as-not-found as the explicit admin no-context outcome
- [X] T022 [US2] Make Entra group global search tenant-safe or explicitly disable it in `app/Filament/Resources/EntraGroupResource.php` and `app/Filament/Concerns/ScopesGlobalSearchToTenant.php`, returning no tenant-owned admin search results when no canonical admin tenant exists
- [X] T023 [US2] Preserve canonical related links after Entra group scope hardening in `app/Support/References/Resolvers/EntraGroupReferenceResolver.php` and `app/Support/OperationRunLinks.php`
**Checkpoint**: User Story 2 is complete when OperationRun and Entra group list, detail, filter, and search paths all obey one bounded scope contract.
---
## Phase 5: User Story 3 - Preserve Separate Panel Semantics (Priority: P2)
**Goal**: Keep tenant-panel-native behavior intact while enforcing the canonical admin resolver only in workspace-admin flows.
**Independent Test**: Verify that tenant-panel files continue to use panel-native tenant behavior while admin-panel flows use the canonical admin resolver, with no cross-plane mutation or hidden fallback semantics.
### Tests for User Story 3
- [X] T024 [P] [US3] Add panel-semantics non-regression coverage in `tests/Feature/Monitoring/HeaderContextBarTest.php`, `tests/Feature/Spec085/CanonicalMonitoringDoesNotMutateTenantContextTest.php`, and `tests/Feature/OpsUx/OperateHubShellTest.php`
### Implementation for User Story 3
- [X] T025 [US3] Preserve tenant-panel-native selection and remember-last-tenant flows in `app/Filament/Pages/ChooseTenant.php`, `app/Http/Controllers/SelectTenantController.php`, and `app/Support/Middleware/EnsureFilamentTenantSelected.php`
- [X] T026 [US3] Preserve panel-aware Entra group navigation semantics in `app/Support/References/Resolvers/EntraGroupReferenceResolver.php` and `app/Support/OperationRunLinks.php`
- [X] T027 [US3] Keep tenant-panel global-search and record-resolution semantics explicit while admin paths stay canonical in `app/Filament/Concerns/ScopesGlobalSearchToTenant.php` and `app/Filament/Resources/EntraGroupResource.php`
**Checkpoint**: User Story 3 is complete when admin hardening no longer risks changing legitimate tenant-panel semantics.
---
## Phase 6: User Story 4 - Prevent Regressions Cheaply (Priority: P3)
**Goal**: Add a lightweight architectural guard and a focused non-regression suite so new admin-panel code cannot quietly reintroduce raw tenant-panel-native tenant reads.
**Independent Test**: Run the new architecture guard plus the focused alert-delivery and operations regression suite and verify that disallowed admin-panel raw tenant reads fail with actionable output while approved tenant-panel exceptions stay allowed.
### Tests for User Story 4
- [X] T028 [P] [US4] Implement the raw admin-tenant-read guard in `tests/Feature/Guards/AdminTenantResolverGuardTest.php` for `app/Filament/Pages/Monitoring`, `app/Filament/Pages/Operations`, `app/Filament/Resources`, and `app/Filament/Widgets/Operations`
- [X] T029 [P] [US4] Add non-regression coverage for the alert-delivery reference pattern in `tests/Feature/Filament/Alerts/AlertsKpiHeaderTest.php` and `tests/Feature/Alerts/AlertDeliveryDeepLinkFiltersTest.php`
### Implementation for User Story 4
- [X] T030 [US4] Maintain the executable allowlist and reviewer-facing exception notes in `tests/Feature/Guards/AdminTenantResolverGuardTest.php` so it stays aligned with the documented exception inventory
**Checkpoint**: User Story 4 is complete when new disallowed admin-panel tenant reads fail CI cheaply and the approved exception set stays explicit.
---
## Phase 7: Polish & Cross-Cutting Concerns
**Purpose**: Finalize wording, run the focused verification pack, and format touched files.
- [X] T031 [P] Reconcile operator-facing scope-label and safe-state copy in `app/Support/OperateHub/OperateHubShell.php`, `app/Filament/Pages/Monitoring/Operations.php`, and `resources/views/filament/partials/context-bar.blade.php`
- [X] T032 Run the focused validation commands documented in `specs/135-canonical-tenant-context-resolution/quickstart.md`
- [X] T033 Run formatting on touched files with `vendor/bin/sail bin pint --dirty --format agent` from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas`
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies; can start immediately.
- **Foundational (Phase 2)**: Depends on Setup completion; blocks all user-story implementation.
- **User Stories (Phases 3-6)**: Depend on Foundational completion.
- **Polish (Phase 7)**: Depends on the desired user stories being complete.
### User Story Dependencies
- **User Story 1 (P1)**: Starts after Foundational and delivers the MVP by aligning the operations monitoring shell and KPI widget.
- **User Story 2 (P1)**: Starts after Foundational and can run in parallel with US1, but it should land after T004-T008 so it reuses the canonical resolver and alert-delivery reference pattern.
- **User Story 3 (P2)**: Starts after Foundational and should land before release so tenant-panel semantics remain explicitly protected.
- **User Story 4 (P3)**: Depends on the in-scope remediation work from US1-US3 so the final guardrail can reflect the intended allowlist and forbidden admin paths.
### Within Each User Story
- Write or update the story tests first and confirm they fail against pre-change behavior.
- Land shared resolver and scoping logic before adjusting page or resource affordances that consume it.
- Keep list, detail, deep-link, and global-search behavior aligned before closing the story.
- Finish story-level validation before moving to the next priority.
### Parallel Opportunities
- T003 can run in parallel with T001-T002 once the feature directory exists.
- T005-T007 can run in parallel after T004, while T008 completes the reusable admin reference pattern.
- In US1, T009-T011 can run in parallel before T012-T014.
- In US2, T015-T017 can run in parallel before T018-T023.
- In US3, T024 can run before T025-T027, and T026 can run in parallel with T025 once the test expectations are fixed.
- In US4, T028 and T029 can run in parallel before T030.
- T031 can run in parallel with T032 after the story phases are complete.
---
## Parallel Example: User Story 1
```bash
# Launch the US1 regression updates together:
Task: "Extend operations-shell parity coverage in tests/Feature/Spec085/OperationsIndexHeaderTest.php and tests/Feature/Monitoring/OperationsTenantScopeTest.php"
Task: "Add KPI tenant-context coverage in tests/Feature/Monitoring/OperationsKpiHeaderTenantContextTest.php and tests/Feature/078/KpiHeaderTenantlessTest.php"
Task: "Extend run-detail context-affordance coverage in tests/Feature/Spec085/RunDetailBackAffordanceTest.php and tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php"
```
---
## Parallel Example: User Story 2
```bash
# Launch the US2 regression updates together:
Task: "Add OperationRun filter default, filter-option, and persisted-state coverage in tests/Feature/Filament/OperationRunListFiltersTest.php and tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php"
Task: "Add Entra group admin list and detail scope coverage in tests/Feature/Filament/EntraGroupAdminScopeTest.php and tests/Feature/Filament/EntraGroupEnterpriseDetailPageTest.php"
Task: "Add Entra group global-search parity coverage in tests/Feature/Filament/EntraGroupGlobalSearchScopeTest.php and tests/Feature/Filament/EntraGroupResolvedReferencePresentationTest.php"
```
---
## Parallel Example: User Story 3
```bash
# Launch the US3 protection work together:
Task: "Add panel-semantics non-regression coverage in tests/Feature/Monitoring/HeaderContextBarTest.php, tests/Feature/Spec085/CanonicalMonitoringDoesNotMutateTenantContextTest.php, and tests/Feature/OpsUx/OperateHubShellTest.php"
Task: "Preserve tenant-panel-native selection and remember-last-tenant flows in app/Filament/Pages/ChooseTenant.php, app/Http/Controllers/SelectTenantController.php, and app/Support/Middleware/EnsureFilamentTenantSelected.php"
Task: "Preserve panel-aware Entra group navigation semantics in app/Support/References/Resolvers/EntraGroupReferenceResolver.php and app/Support/OperationRunLinks.php"
```
---
## Parallel Example: User Story 4
```bash
# Launch the US4 guardrail work together:
Task: "Implement the raw admin-tenant-read guard in tests/Feature/Guards/AdminTenantResolverGuardTest.php for app/Filament/Pages/Monitoring, app/Filament/Pages/Operations, app/Filament/Resources, and app/Filament/Widgets/Operations"
Task: "Add non-regression coverage for the alert-delivery reference pattern in tests/Feature/Filament/Alerts/AlertsKpiHeaderTest.php and tests/Feature/Alerts/AlertDeliveryDeepLinkFiltersTest.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. Validate the operations monitoring shell across remembered-only, Filament-only, conflict, and no-context states before moving on.
### Incremental Delivery
1. Finish Setup and Foundational shared resolver work.
2. Deliver User Story 1 to establish canonical operations context parity.
3. Deliver User Story 2 to harden filters, persisted state, direct record access, and global search.
4. Deliver User Story 3 to preserve tenant-panel semantics while admin behavior is tightened.
5. Deliver User Story 4 to lock in the lightweight guardrail.
6. Finish with wording cleanup, focused validation, and formatting.
### Parallel Team Strategy
1. One contributor handles the support-layer resolver contract and shared context-bar behavior while another prepares the new regression files.
2. After Foundation is ready, split US1 and US2 between monitoring-shell parity and resource-scope hardening.
3. Reserve one contributor for tenant-panel non-regression and the guardrail so the exception inventory stays coherent while implementation lands.
---
## Notes
- `[P]` tasks touch different files and can be executed in parallel.
- User-story labels map directly to the prioritized stories in `spec.md`.
- Tests are mandatory in this repo for every runtime change in the resulting implementation.
- The suggested MVP scope is Phase 3 only after Setup and Foundational are complete.

View File

@ -8,6 +8,7 @@
use App\Models\AlertRule;
use App\Models\Tenant;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
@ -149,6 +150,70 @@ function alertDeliveryFilterIndicatorLabels($component): array
->assertCanNotSeeTableRecords([$deliveryB]);
});
it('replaces the persisted tenant filter when canonical tenant context changes', function (): void {
$tenantA = Tenant::factory()->create();
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
$tenantB = Tenant::factory()->create([
'workspace_id' => (int) $tenantA->workspace_id,
]);
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
$workspaceId = (int) $tenantA->workspace_id;
$destination = AlertDestination::factory()->create([
'workspace_id' => $workspaceId,
'is_enabled' => true,
]);
$rule = AlertRule::factory()->create([
'workspace_id' => $workspaceId,
]);
$deliveryA = AlertDelivery::factory()->create([
'workspace_id' => $workspaceId,
'tenant_id' => (int) $tenantA->getKey(),
'alert_rule_id' => (int) $rule->getKey(),
'alert_destination_id' => (int) $destination->getKey(),
'status' => AlertDelivery::STATUS_SENT,
]);
$deliveryB = AlertDelivery::factory()->create([
'workspace_id' => $workspaceId,
'tenant_id' => (int) $tenantB->getKey(),
'alert_rule_id' => (int) $rule->getKey(),
'alert_destination_id' => (int) $destination->getKey(),
'status' => AlertDelivery::STATUS_SENT,
]);
$this->actingAs($user);
Filament::setTenant(null, true);
session()->put(WorkspaceContext::SESSION_KEY, $workspaceId);
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
(string) $workspaceId => (int) $tenantA->getKey(),
]);
$component = Livewire::test(ListAlertDeliveries::class)
->filterTable('status', AlertDelivery::STATUS_SENT);
expect(data_get(session()->get($component->instance()->getTableFiltersSessionKey()), 'tenant_id.value'))
->toBe((string) $tenantA->getKey());
expect(data_get(session()->get($component->instance()->getTableFiltersSessionKey()), 'status.value'))
->toBe(AlertDelivery::STATUS_SENT);
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
(string) $workspaceId => (int) $tenantB->getKey(),
]);
Livewire::test(ListAlertDeliveries::class)
->assertSet('tableFilters.tenant_id.value', (string) $tenantB->getKey())
->assertSet('tableFilters.status.value', AlertDelivery::STATUS_SENT)
->assertCanSeeTableRecords([$deliveryB])
->assertCanNotSeeTableRecords([$deliveryA]);
});
it('includes tenantless test deliveries in the list', function (): void {
[$user] = createUserWithTenant(role: 'owner');
$this->actingAs($user);

View File

@ -61,6 +61,14 @@
return $items->pluck('display_name');
};
$forgetPersistedFilters = function (): void {
$component = Livewire::test(ListEntraGroups::class)->instance();
session()->forget($component->getTableFiltersSessionKey());
session()->forget($component->getTableSearchSessionKey());
session()->forget($component->getTableSortSessionKey());
};
$names = $extractNames(Livewire::test(ListEntraGroups::class));
expect($names)->toContain('Alpha Team');
expect($names)->toContain('Beta Unified');
@ -73,6 +81,8 @@
expect($names)->toContain('Beta Unified');
expect($names)->not->toContain('Alpha Team');
$forgetPersistedFilters();
$names = $extractNames(
Livewire::test(ListEntraGroups::class)
->set('tableFilters.stale.value', 1)
@ -80,6 +90,8 @@
expect($names)->toContain('Beta Unified');
expect($names)->not->toContain('Alpha Team');
$forgetPersistedFilters();
$names = $extractNames(
Livewire::test(ListEntraGroups::class)
->set('tableFilters.group_type.value', 'security')
@ -88,7 +100,7 @@
expect($names)->not->toContain('Beta Unified');
});
test('group detail is tenant-scoped and cross-tenant access is forbidden (403)', function () {
test('group detail is tenant-scoped and cross-tenant access is not found (404)', function () {
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create([
'workspace_id' => $tenantA->workspace_id,
@ -109,5 +121,17 @@
$this->actingAs($user)
->get(EntraGroupResource::getUrl('view', ['record' => $groupB], tenant: $tenantA))
->assertForbidden();
->assertNotFound();
});
test('keeps Entra groups out of admin sidebar navigation while preserving tenant-panel navigation', function () {
Filament::setCurrentPanel(Filament::getPanel('admin'));
expect(EntraGroupResource::shouldRegisterNavigation())->toBeFalse();
Filament::setCurrentPanel(Filament::getPanel('tenant'));
expect(EntraGroupResource::shouldRegisterNavigation())->toBeTrue();
Filament::setCurrentPanel(null);
});

View File

@ -9,8 +9,21 @@
use App\Models\Tenant;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Filament\Widgets\StatsOverviewWidget\Stat;
use Livewire\Livewire;
function alertsKpiValues($component): array
{
$method = new ReflectionMethod(AlertsKpiHeader::class, 'getStats');
$method->setAccessible(true);
return collect($method->invoke($component->instance()))
->mapWithKeys(fn (Stat $stat): array => [
(string) $stat->getLabel() => (string) $stat->getValue(),
])
->all();
}
it('filters KPI deliveries by tenant when context is set via lastTenantId fallback only', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
@ -50,9 +63,12 @@
(string) $workspaceId => (int) $tenant->getKey(),
]);
Livewire::test(AlertsKpiHeader::class)
->assertSee('Deliveries (24h)')
->assertSee('1');
$values = alertsKpiValues(Livewire::test(AlertsKpiHeader::class));
expect($values)->toMatchArray([
'Deliveries (24h)' => '1',
'Failed (7d)' => '0',
]);
})->group('ops-ux');
it('filters KPI deliveries by tenant when context is set via Filament setTenant', function (): void {
@ -91,9 +107,12 @@
session()->put(WorkspaceContext::SESSION_KEY, $workspaceId);
Livewire::test(AlertsKpiHeader::class)
->assertSee('Deliveries (24h)')
->assertSee('1');
$values = alertsKpiValues(Livewire::test(AlertsKpiHeader::class));
expect($values)->toMatchArray([
'Deliveries (24h)' => '1',
'Failed (7d)' => '0',
]);
})->group('ops-ux');
it('shows workspace-wide KPI counts when no tenant context is active', function (): void {
@ -132,7 +151,57 @@
session()->put(WorkspaceContext::SESSION_KEY, $workspaceId);
Livewire::test(AlertsKpiHeader::class)
->assertSee('Deliveries (24h)')
->assertSee('2');
$values = alertsKpiValues(Livewire::test(AlertsKpiHeader::class));
expect($values)->toMatchArray([
'Deliveries (24h)' => '2',
'Failed (7d)' => '0',
]);
})->group('ops-ux');
it('prefers the Filament tenant over remembered tenant fallback in KPI scope conflicts', function (): void {
[$user, $tenantA] = createUserWithTenant(role: 'owner');
$workspaceId = (int) $tenantA->workspace_id;
$tenantB = Tenant::factory()->create(['workspace_id' => $workspaceId]);
$user->tenants()->syncWithoutDetaching([
$tenantB->getKey() => ['role' => 'owner'],
]);
$rule = AlertRule::factory()->create(['workspace_id' => $workspaceId]);
$destination = AlertDestination::factory()->create(['workspace_id' => $workspaceId]);
AlertDelivery::factory()->create([
'workspace_id' => $workspaceId,
'tenant_id' => (int) $tenantA->getKey(),
'alert_rule_id' => (int) $rule->getKey(),
'alert_destination_id' => (int) $destination->getKey(),
'status' => AlertDelivery::STATUS_SENT,
'created_at' => now()->subHour(),
]);
AlertDelivery::factory()->create([
'workspace_id' => $workspaceId,
'tenant_id' => (int) $tenantB->getKey(),
'alert_rule_id' => (int) $rule->getKey(),
'alert_destination_id' => (int) $destination->getKey(),
'status' => AlertDelivery::STATUS_FAILED,
'created_at' => now()->subHour(),
]);
$this->actingAs($user);
Filament::setTenant($tenantB, true);
session()->put(WorkspaceContext::SESSION_KEY, $workspaceId);
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
(string) $workspaceId => (int) $tenantA->getKey(),
]);
$values = alertsKpiValues(Livewire::test(AlertsKpiHeader::class));
expect($values)->toMatchArray([
'Deliveries (24h)' => '1',
'Failed (7d)' => '1',
]);
})->group('ops-ux');

View File

@ -190,6 +190,100 @@ function auditLogPageTestRecord(?Tenant $tenant, array $attributes = []): AuditL
->assertCanNotSeeTableRecords([$hidden]);
});
it('preselects the remembered tenant as the default audit filter when the filament tenant is absent', function (): void {
$tenantA = Tenant::factory()->create([
'name' => 'Phoenicon',
'environment' => 'dev',
]);
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
$tenantB = Tenant::factory()->create([
'workspace_id' => (int) $tenantA->workspace_id,
'name' => 'YPTW2',
'environment' => 'dev',
]);
createUserWithTenant($tenantB, $user, role: 'owner');
$visible = auditLogPageTestRecord($tenantA, [
'resource_id' => '301',
'summary' => 'Phoenicon verification completed',
'action' => 'verification.completed',
]);
$hidden = auditLogPageTestRecord($tenantB, [
'resource_id' => '302',
'summary' => 'YPTW2 verification completed',
'action' => 'verification.completed',
]);
$this->actingAs($user);
Filament::setTenant(null, true);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
(string) $tenantA->workspace_id => (int) $tenantA->getKey(),
]);
Livewire::actingAs($user)->test(AuditLogPage::class)
->assertSet('tableFilters.tenant_id.value', (string) $tenantA->getKey())
->assertCanSeeTableRecords([$visible])
->assertCanNotSeeTableRecords([$hidden]);
});
it('replaces a stale persisted audit tenant filter when the remembered tenant context changes', function (): void {
$tenantA = Tenant::factory()->create([
'name' => 'YPTW2',
'environment' => 'dev',
]);
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
$tenantB = Tenant::factory()->create([
'workspace_id' => (int) $tenantA->workspace_id,
'name' => 'Phoenicon',
'environment' => 'dev',
]);
createUserWithTenant($tenantB, $user, role: 'owner');
$tenantARecord = auditLogPageTestRecord($tenantA, [
'resource_id' => '401',
'summary' => 'YPTW2 verification completed',
]);
$tenantBRecord = auditLogPageTestRecord($tenantB, [
'resource_id' => '402',
'summary' => 'Phoenicon verification completed',
]);
$this->actingAs($user);
Filament::setTenant(null, true);
$workspaceId = (int) $tenantA->workspace_id;
session()->put(WorkspaceContext::SESSION_KEY, $workspaceId);
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
(string) $workspaceId => (int) $tenantA->getKey(),
]);
$component = Livewire::actingAs($user)->test(AuditLogPage::class)
->assertSet('tableFilters.tenant_id.value', (string) $tenantA->getKey())
->assertCanSeeTableRecords([$tenantARecord])
->assertCanNotSeeTableRecords([$tenantBRecord]);
expect(data_get(session()->get($component->instance()->getTableFiltersSessionKey()), 'tenant_id.value'))
->toBe((string) $tenantA->getKey());
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
(string) $workspaceId => (int) $tenantB->getKey(),
]);
Livewire::actingAs($user)->test(AuditLogPage::class)
->assertSet('tableFilters.tenant_id.value', (string) $tenantB->getKey())
->assertCanSeeTableRecords([$tenantBRecord])
->assertCanNotSeeTableRecords([$tenantARecord]);
});
it('shows a clear-filters empty state when no audit rows match the current view', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');

View File

@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\EntraGroupResource;
use App\Models\EntraGroup;
use App\Models\Tenant;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('returns not found for the admin group list when no canonical tenant context exists', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
Filament::setTenant(null, true);
$this->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
])->get(EntraGroupResource::getUrl(panel: 'admin'))
->assertNotFound();
});
it('scopes the admin group list to the remembered tenant context', function (): void {
$tenantA = Tenant::factory()->create();
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
$tenantB = Tenant::factory()->create([
'workspace_id' => (int) $tenantA->workspace_id,
]);
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
$groupA = EntraGroup::factory()->for($tenantA)->create([
'display_name' => 'Remembered tenant group',
]);
EntraGroup::factory()->for($tenantB)->create([
'display_name' => 'Other tenant group',
]);
$this->actingAs($user);
Filament::setTenant(null, true);
$this->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id,
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
(string) $tenantA->workspace_id => (int) $tenantA->getKey(),
],
])->get(EntraGroupResource::getUrl(panel: 'admin'))
->assertOk()
->assertSee((string) $groupA->display_name)
->assertDontSee('Other tenant group');
});
it('returns not found for admin direct group detail outside the canonical tenant scope', function (): void {
$tenantA = Tenant::factory()->create();
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
$tenantB = Tenant::factory()->create([
'workspace_id' => (int) $tenantA->workspace_id,
]);
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
$groupA = EntraGroup::factory()->for($tenantA)->create();
$groupB = EntraGroup::factory()->for($tenantB)->create();
$this->actingAs($user);
Filament::setTenant(null, true);
$session = [
WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id,
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
(string) $tenantA->workspace_id => (int) $tenantA->getKey(),
],
];
$this->withSession($session)
->get(EntraGroupResource::getUrl('view', ['record' => $groupA], panel: 'admin'))
->assertOk();
$this->withSession($session)
->get(EntraGroupResource::getUrl('view', ['record' => $groupB], panel: 'admin'))
->assertNotFound();
});

View File

@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\EntraGroupResource;
use App\Models\EntraGroup;
use App\Models\Tenant;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
function entraGroupSearchTitles($results): array
{
return collect($results)->map(fn ($result): string => (string) $result->title)->all();
}
it('returns no admin global-search results without canonical tenant context', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
EntraGroup::factory()->for($tenant)->create([
'display_name' => 'Admin search group',
]);
$this->actingAs($user);
Filament::setCurrentPanel('admin');
Filament::setTenant(null, true);
Filament::bootCurrentPanel();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
expect(EntraGroupResource::getGlobalSearchResults('Admin search'))
->toHaveCount(0);
});
it('scopes admin global-search results to the remembered tenant context', function (): void {
$tenantA = Tenant::factory()->create();
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
$tenantB = Tenant::factory()->create([
'workspace_id' => (int) $tenantA->workspace_id,
]);
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
$groupA = EntraGroup::factory()->for($tenantA)->create([
'display_name' => 'Remembered search group',
]);
EntraGroup::factory()->for($tenantB)->create([
'display_name' => 'Other search group',
]);
$this->actingAs($user);
Filament::setCurrentPanel('admin');
Filament::setTenant(null, true);
Filament::bootCurrentPanel();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
(string) $tenantA->workspace_id => (int) $tenantA->getKey(),
]);
$results = EntraGroupResource::getGlobalSearchResults('search group');
expect(entraGroupSearchTitles($results))->toBe([
'Remembered search group',
]);
expect($results->first()?->url)
->toBe(EntraGroupResource::getUrl('view', ['record' => $groupA], panel: 'admin', tenant: $tenantA));
});
it('keeps tenant-panel global-search results panel-native', function (): void {
$tenantA = Tenant::factory()->create();
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
$tenantB = Tenant::factory()->create([
'workspace_id' => (int) $tenantA->workspace_id,
]);
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
$groupA = EntraGroup::factory()->for($tenantA)->create([
'display_name' => 'Tenant panel group',
]);
EntraGroup::factory()->for($tenantB)->create([
'display_name' => 'Tenant panel outsider',
]);
$this->actingAs($user);
Filament::setCurrentPanel('tenant');
Filament::setTenant($tenantA, true);
Filament::bootCurrentPanel();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
$results = EntraGroupResource::getGlobalSearchResults('Tenant panel');
expect(entraGroupSearchTitles($results))->toBe([
'Tenant panel group',
]);
expect($results->first()?->url)
->toBe(EntraGroupResource::getUrl('view', ['record' => $groupA], panel: 'tenant', tenant: $tenantA));
});

View File

@ -122,3 +122,95 @@ function operationRunFilterIndicatorLabels($component): array
->set('tableFilters.created_at.until', null)
->assertCanSeeTableRecords([$recent, $old]);
});
it('scopes operation type filter options to the remembered tenant context', function (): void {
$tenantA = Tenant::factory()->create();
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
$tenantB = Tenant::factory()->create([
'workspace_id' => (int) $tenantA->workspace_id,
]);
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
OperationRun::factory()->create([
'tenant_id' => (int) $tenantA->getKey(),
'workspace_id' => (int) $tenantA->workspace_id,
'type' => 'policy.sync',
]);
OperationRun::factory()->create([
'tenant_id' => (int) $tenantB->getKey(),
'workspace_id' => (int) $tenantB->workspace_id,
'type' => 'inventory_sync',
]);
$this->actingAs($user);
Filament::setTenant(null, true);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
(string) $tenantA->workspace_id => (int) $tenantA->getKey(),
]);
$component = Livewire::test(Operations::class);
/** @var SelectFilter|null $filter */
$filter = $component->instance()->getTable()->getFilter('type');
expect($filter)->not->toBeNull();
expect($filter?->getOptions())->toBe([
'policy.sync' => 'Policy sync',
]);
});
it('clears tenant-sensitive persisted filters when the canonical tenant context changes', function (): void {
$tenantA = Tenant::factory()->create();
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
$tenantB = Tenant::factory()->create([
'workspace_id' => (int) $tenantA->workspace_id,
]);
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
OperationRun::factory()->create([
'tenant_id' => (int) $tenantA->getKey(),
'workspace_id' => (int) $tenantA->workspace_id,
'type' => 'policy.sync',
'initiator_name' => 'Alpha',
]);
OperationRun::factory()->create([
'tenant_id' => (int) $tenantB->getKey(),
'workspace_id' => (int) $tenantB->workspace_id,
'type' => 'inventory_sync',
'initiator_name' => 'Bravo',
]);
$this->actingAs($user);
Filament::setTenant(null, true);
$workspaceId = (int) $tenantA->workspace_id;
session()->put(WorkspaceContext::SESSION_KEY, $workspaceId);
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
(string) $workspaceId => (int) $tenantA->getKey(),
]);
$component = Livewire::test(Operations::class)
->filterTable('type', 'policy.sync')
->filterTable('initiator_name', 'Alpha');
expect(data_get(session()->get($component->instance()->getTableFiltersSessionKey()), 'type.value'))->toBe('policy.sync');
expect(data_get(session()->get($component->instance()->getTableFiltersSessionKey()), 'initiator_name.value'))->toBe('Alpha');
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
(string) $workspaceId => (int) $tenantB->getKey(),
]);
Livewire::test(Operations::class)
->assertSet('tableFilters.tenant_id.value', (string) $tenantB->getKey())
->assertSet('tableFilters.type.value', null)
->assertSet('tableFilters.initiator_name.value', null);
});

View File

@ -7,10 +7,13 @@
use App\Models\EntraGroup;
use App\Models\Policy;
use App\Models\PolicyVersion;
use Filament\Facades\Filament;
it('renders canonical group links for resolved assignment targets', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
Filament::setCurrentPanel('tenant');
Filament::bootCurrentPanel();
$groupId = '33333333-4444-5555-6666-777777777777';
@ -34,8 +37,8 @@
],
]);
$this->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant))
$this->get(PolicyVersionResource::getUrl('view', ['record' => $version], panel: 'tenant', tenant: $tenant))
->assertOk()
->assertSee(EntraGroupResource::getUrl('view', ['record' => $group], tenant: $tenant), false)
->assertSee(EntraGroupResource::getUrl('view', ['record' => $group], panel: 'tenant', tenant: $tenant), false)
->assertSee('Scoped group');
});

View File

@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
function adminTenantResolverGuardedFiles(): array
{
return [
'app/Filament/Pages/Monitoring/Operations.php',
'app/Filament/Pages/Operations/TenantlessOperationRunViewer.php',
'app/Filament/Widgets/Operations/OperationsKpiHeader.php',
'app/Filament/Resources/OperationRunResource.php',
'app/Filament/Resources/AlertDeliveryResource.php',
'app/Filament/Resources/AlertDeliveryResource/Pages/ListAlertDeliveries.php',
'app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php',
'app/Filament/Resources/EntraGroupResource/Pages/ViewEntraGroup.php',
];
}
function adminTenantResolverExceptionFiles(): array
{
return [
'app/Filament/Pages/ChooseTenant.php',
'app/Http/Controllers/SelectTenantController.php',
'app/Support/Middleware/EnsureFilamentTenantSelected.php',
'app/Filament/Resources/EntraGroupResource.php',
];
}
it('keeps raw panel-native tenant reads out of canonical admin resolver surfaces', function (): void {
$forbiddenPatterns = [
'/\bFilament::getTenant\s*\(/',
'/\bTenant::current\s*\(/',
];
$violations = [];
foreach (adminTenantResolverGuardedFiles() as $relativePath) {
$contents = file_get_contents(base_path($relativePath));
expect($contents)->not->toBeFalse();
foreach ($forbiddenPatterns as $pattern) {
if (preg_match($pattern, (string) $contents) === 1) {
$violations[] = $relativePath;
break;
}
}
}
expect($violations)
->toBeEmpty('Canonical admin surfaces must delegate tenant resolution to OperateHubShell. Offenders: '.implode(', ', $violations));
});
it('documents the approved panel-native exception inventory', function (): void {
$notes = file_get_contents(base_path('docs/research/canonical-tenant-context-resolution.md'));
expect($notes)->not->toBeFalse();
foreach (adminTenantResolverExceptionFiles() as $relativePath) {
expect($notes)->toContain($relativePath);
}
});
it('keeps the mixed-surface entra group resource explicit about admin and tenant-panel resolution', function (): void {
$contents = file_get_contents(base_path('app/Filament/Resources/EntraGroupResource.php'));
expect($contents)->not->toBeFalse()
->and($contents)->toContain('activeEntitledTenant(request())')
->and($contents)->toContain('Tenant::current()');
});

View File

@ -82,6 +82,30 @@
->assertSee('Operation run');
});
it('returns not found for operation detail when the active tenant context does not match the run tenant', function (): void {
$tenantA = Tenant::factory()->create();
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
$tenantB = Tenant::factory()->create([
'workspace_id' => (int) $tenantA->workspace_id,
]);
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
$runB = OperationRun::factory()->create([
'tenant_id' => (int) $tenantB->getKey(),
'workspace_id' => (int) $tenantB->workspace_id,
'type' => 'inventory_sync',
]);
Filament::setTenant($tenantA, true);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id])
->get(route('admin.operations.view', ['run' => (int) $runB->getKey()]))
->assertNotFound();
});
it('defaults the tenant filter from tenant context and can be cleared', function (): void {
$tenantA = Tenant::factory()->create();
[$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner');

View File

@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
use App\Filament\Widgets\Operations\OperationsKpiHeader;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Filament\Widgets\StatsOverviewWidget\Stat;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
function operationsKpiValues($component): array
{
$method = new ReflectionMethod(OperationsKpiHeader::class, 'getStats');
$method->setAccessible(true);
return collect($method->invoke($component->instance()))
->mapWithKeys(fn (Stat $stat): array => [
(string) $stat->getLabel() => (string) $stat->getValue(),
])
->all();
}
it('filters operations KPI stats by remembered tenant when filament tenant is absent', function (): void {
$tenantA = Tenant::factory()->create();
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
$tenantB = Tenant::factory()->create([
'workspace_id' => (int) $tenantA->workspace_id,
]);
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
OperationRun::factory()->create([
'tenant_id' => (int) $tenantA->getKey(),
'workspace_id' => (int) $tenantA->workspace_id,
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
]);
OperationRun::factory()->create([
'tenant_id' => (int) $tenantA->getKey(),
'workspace_id' => (int) $tenantA->workspace_id,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
'started_at' => now()->subMinutes(20),
'completed_at' => now()->subMinutes(10),
]);
OperationRun::factory()->create([
'tenant_id' => (int) $tenantB->getKey(),
'workspace_id' => (int) $tenantB->workspace_id,
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
]);
$this->actingAs($user);
Filament::setTenant(null, true);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
(string) $tenantA->workspace_id => (int) $tenantA->getKey(),
]);
$values = operationsKpiValues(Livewire::test(OperationsKpiHeader::class));
expect($values)->toMatchArray([
'Total Runs (30 days)' => '2',
'Active Runs' => '1',
'Failed/Partial (7 days)' => '1',
]);
});
it('prefers the filament tenant over remembered tenant in conflicting KPI context', function (): void {
$tenantA = Tenant::factory()->create();
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
$tenantB = Tenant::factory()->create([
'workspace_id' => (int) $tenantA->workspace_id,
]);
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
OperationRun::factory()->create([
'tenant_id' => (int) $tenantA->getKey(),
'workspace_id' => (int) $tenantA->workspace_id,
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
]);
OperationRun::factory()->create([
'tenant_id' => (int) $tenantB->getKey(),
'workspace_id' => (int) $tenantB->workspace_id,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'started_at' => now()->subMinutes(15),
'completed_at' => now()->subMinutes(5),
]);
$this->actingAs($user);
Filament::setTenant($tenantB, true);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
(string) $tenantA->workspace_id => (int) $tenantA->getKey(),
]);
$values = operationsKpiValues(Livewire::test(OperationsKpiHeader::class));
expect($values)->toMatchArray([
'Total Runs (30 days)' => '1',
'Active Runs' => '0',
'Failed/Partial (7 days)' => '0',
]);
});

View File

@ -185,7 +185,7 @@
->test(ManagedTenantOnboardingWizard::class)
->call('updateSelectedProviderConnectionInline', (int) $connection->getKey(), [
'display_name' => 'Updated name',
'client_id' => 'new-client-id',
'client_id' => 'old-client-id',
])
->assertSuccessful();
@ -195,7 +195,7 @@
$credential = $connection->credential;
expect($credential)->not->toBeNull();
expect($credential?->payload['client_id'] ?? null)->toBe('new-client-id');
expect($credential?->payload['client_id'] ?? null)->toBe('old-client-id');
expect($credential?->payload['client_secret'] ?? null)->toBe($secret);
$session->refresh();
@ -217,6 +217,152 @@
expect($encodedMetadata)->not->toContain($secret);
});
it('requires a new secret when changing the client id inline during onboarding', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => '66666666-6666-6666-6666-666666666666',
'status' => Tenant::STATUS_ONBOARDING,
]);
$connection = ProviderConnection::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'Acme connection',
'is_default' => true,
]);
ProviderCredential::factory()->create([
'provider_connection_id' => (int) $connection->getKey(),
'payload' => [
'client_id' => 'old-client-id',
'client_secret' => 'top-secret-client-secret',
],
]);
TenantOnboardingSession::create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'entra_tenant_id' => (string) $tenant->tenant_id,
'current_step' => 'connection',
'state' => [
'provider_connection_id' => (int) $connection->getKey(),
],
'started_by_user_id' => (int) $user->getKey(),
'updated_by_user_id' => (int) $user->getKey(),
]);
$this->actingAs($user);
Livewire::actingAs($user)
->test(ManagedTenantOnboardingWizard::class)
->call('updateSelectedProviderConnectionInline', (int) $connection->getKey(), [
'display_name' => 'Updated name',
'client_id' => 'new-client-id',
'client_secret' => '',
])
->assertHasErrors([
'client_secret' => 'Enter a new client secret when changing the App (client) ID.',
]);
$connection->refresh();
expect($connection->display_name)->toBe('Acme connection');
expect($connection->credential?->payload['client_id'] ?? null)->toBe('old-client-id');
});
it('rotates the secret inline when a new client secret is provided during onboarding edit', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => '77777777-7777-7777-7777-777777777777',
'status' => Tenant::STATUS_ONBOARDING,
]);
$connection = ProviderConnection::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'Acme connection',
'is_default' => true,
]);
$oldSecret = 'top-secret-client-secret';
$newSecret = 'brand-new-client-secret';
ProviderCredential::factory()->create([
'provider_connection_id' => (int) $connection->getKey(),
'payload' => [
'client_id' => 'old-client-id',
'client_secret' => $oldSecret,
],
]);
TenantOnboardingSession::create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'entra_tenant_id' => (string) $tenant->tenant_id,
'current_step' => 'connection',
'state' => [
'provider_connection_id' => (int) $connection->getKey(),
],
'started_by_user_id' => (int) $user->getKey(),
'updated_by_user_id' => (int) $user->getKey(),
]);
$this->actingAs($user);
Livewire::actingAs($user)
->test(ManagedTenantOnboardingWizard::class)
->call('updateSelectedProviderConnectionInline', (int) $connection->getKey(), [
'display_name' => 'Updated name',
'client_id' => 'new-client-id',
'client_secret' => $newSecret,
])
->assertSuccessful();
$connection->refresh();
expect($connection->display_name)->toBe('Updated name');
expect($connection->credential?->payload['client_id'] ?? null)->toBe('new-client-id');
expect($connection->credential?->payload['client_secret'] ?? null)->toBe($newSecret);
$audit = AuditLog::query()
->where('tenant_id', (int) $tenant->getKey())
->where('action', 'provider_connection.updated')
->latest('id')
->first();
expect($audit)->not->toBeNull();
$encodedMetadata = json_encode($audit?->metadata, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
expect($encodedMetadata)->not->toContain($oldSecret);
expect($encodedMetadata)->not->toContain($newSecret);
});
it('returns 404 when attempting to inline-edit a connection belonging to a different tenant', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();

View File

@ -13,8 +13,10 @@
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\OperateHub\OperateHubShell;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Bus;
it('renders operate hub pages DB-only with no outbound HTTP and no queued jobs', function (): void {
@ -229,6 +231,91 @@
$response->assertSessionHas(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, $lastTenantMap);
})->group('ops-ux');
it('prefers the filament tenant over remembered workspace tenant state when both are entitled', function (): void {
$tenantA = Tenant::factory()->create();
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
$tenantB = Tenant::factory()->create([
'workspace_id' => (int) $tenantA->workspace_id,
]);
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
$this->actingAs($user);
Filament::setTenant($tenantB, true);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
(string) $tenantA->workspace_id => (int) $tenantA->getKey(),
]);
$resolved = app(OperateHubShell::class)->activeEntitledTenant();
expect($resolved?->is($tenantB))->toBeTrue();
})->group('ops-ux');
it('clears stale remembered tenant ids when the remembered tenant is no longer entitled', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$staleTenant = Tenant::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$this->actingAs($user);
Filament::setTenant(null, true);
$workspaceId = (int) $tenant->workspace_id;
session()->put(WorkspaceContext::SESSION_KEY, $workspaceId);
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
(string) $workspaceId => (int) $staleTenant->getKey(),
]);
$resolved = app(OperateHubShell::class)->activeEntitledTenant();
expect($resolved)->toBeNull();
expect(session()->get(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, []))
->not->toHaveKey((string) $workspaceId);
})->group('ops-ux');
it('prefers the routed tenant over remembered workspace tenant state when a tenant route parameter is present', function (): void {
$rememberedTenant = Tenant::factory()->create([
'name' => 'YPTW2',
'environment' => 'dev',
'status' => 'active',
]);
[$user, $rememberedTenant] = createUserWithTenant(tenant: $rememberedTenant, role: 'owner');
$routedTenant = Tenant::factory()->create([
'workspace_id' => (int) $rememberedTenant->workspace_id,
'name' => 'Phoenicon',
'environment' => 'dev',
'status' => 'active',
]);
createUserWithTenant(tenant: $routedTenant, user: $user, role: 'owner');
$this->actingAs($user);
Filament::setTenant(null, true);
$workspaceId = (int) $rememberedTenant->workspace_id;
session()->put(WorkspaceContext::SESSION_KEY, $workspaceId);
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
(string) $workspaceId => (int) $rememberedTenant->getKey(),
]);
$request = Request::create("/admin/tenants/{$routedTenant->external_id}/required-permissions");
$request->setLaravelSession(app('session.store'));
$route = app('router')->getRoutes()->match($request);
$request->setRouteResolver(static fn () => $route);
$resolved = app(OperateHubShell::class)->activeEntitledTenant($request);
expect($resolved?->is($routedTenant))->toBeTrue();
})->group('ops-ux');
it('shows tenant filter label when tenant context is active', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');

View File

@ -144,8 +144,8 @@
$response->assertOk();
// The page resolves $scopedTenant from route param and uses it for data display.
// Verify the page title is present (static title: "Required permissions")
// The page resolves $scopedTenant from route param and uses it for visible page context.
$response->assertSee($tenant->getFilamentName(), false);
$response->assertSee('Required permissions', false);
// The page uses $scopedTenant for links (e.g., "Re-run verification" links back to TenantResource)
@ -158,3 +158,33 @@
// Tenant nav absent (sidebar fix working)
$response->assertDontSee('>Inventory</span>', false);
});
it('shows the routed tenant context even when another tenant is current', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
$currentTenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'name' => 'YPTW2',
'environment' => 'dev',
'status' => 'active',
]);
$routedTenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'name' => 'Phoenicon',
'environment' => 'dev',
'status' => 'active',
]);
createUserWithTenant($currentTenant, $user, role: 'owner');
createUserWithTenant($routedTenant, $user, role: 'owner');
$currentTenant->makeCurrent();
$this->actingAs($user)
->get("/admin/tenants/{$routedTenant->external_id}/required-permissions")
->assertOk()
->assertSee($routedTenant->getFilamentName(), false)
->assertSee('Required permissions', false);
});