feat(spec-085): tenant operate hub

This commit is contained in:
Ahmed Darrazi 2026-02-11 01:02:42 +01:00
parent 0e2adeab71
commit 6dab6297f8
34 changed files with 1788 additions and 34 deletions

View File

@ -24,6 +24,9 @@ ## Active Technologies
- PHP 8.4.x + Laravel 12, Filament v5, Livewire v4 (082-action-surface-contract)
- PHP 8.4 (Laravel 12) + Filament v5 (Livewire v4), Queue/Jobs (Laravel), Microsoft Graph via `GraphClientInterface` (084-verification-surfaces-unification)
- PostgreSQL (JSONB-backed `OperationRun.context`) (084-verification-surfaces-unification)
- PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, Laravel Sail (085-tenant-operate-hub)
- PostgreSQL (primary) + session (workspace context + last-tenant memory) (085-tenant-operate-hub)
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Sail, Tailwind CSS v4 (085-tenant-operate-hub)
- PHP 8.4.15 (feat/005-bulk-operations)
@ -43,8 +46,9 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## Recent Changes
- 084-verification-surfaces-unification: Added PHP 8.4 (Laravel 12) + Filament v5 (Livewire v4), Queue/Jobs (Laravel), Microsoft Graph via `GraphClientInterface`
- 082-action-surface-contract: Added PHP 8.4.x + Laravel 12, Filament v5, Livewire v4
- 085-tenant-operate-hub: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Sail, Tailwind CSS v4
- 085-tenant-operate-hub: Added PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, Laravel Sail
- 085-tenant-operate-hub: Added [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
<!-- MANUAL ADDITIONS START -->

View File

@ -4,7 +4,9 @@
namespace App\Filament\Pages\Monitoring;
use App\Support\OperateHub\OperateHubShell;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Pages\Page;
use UnitEnum;
@ -25,4 +27,15 @@ class Alerts extends Page
protected static ?string $title = 'Alerts';
protected string $view = 'filament.pages.monitoring.alerts';
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
return app(OperateHubShell::class)->headerActions(
scopeActionName: 'operate_hub_scope_alerts',
returnActionName: 'operate_hub_return_alerts',
);
}
}

View File

@ -4,7 +4,9 @@
namespace App\Filament\Pages\Monitoring;
use App\Support\OperateHub\OperateHubShell;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Pages\Page;
use UnitEnum;
@ -25,4 +27,15 @@ class AuditLog extends Page
protected static ?string $title = 'Audit Log';
protected string $view = 'filament.pages.monitoring.audit-log';
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
return app(OperateHubShell::class)->headerActions(
scopeActionName: 'operate_hub_scope_audit_log',
returnActionName: 'operate_hub_return_audit_log',
);
}
}

View File

@ -7,10 +7,14 @@
use App\Filament\Resources\OperationRunResource;
use App\Filament\Widgets\Operations\OperationsKpiHeader;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\OperateHub\OperateHubShell;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Pages\Page;
@ -50,6 +54,46 @@ protected function getHeaderWidgets(): array
];
}
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
$operateHubShell = app(OperateHubShell::class);
$actions = [
Action::make('operate_hub_scope_operations')
->label($operateHubShell->scopeLabel(request()))
->color('gray')
->disabled(),
];
$activeTenant = $operateHubShell->activeEntitledTenant(request());
if ($activeTenant instanceof Tenant) {
$actions[] = Action::make('operate_hub_back_to_tenant_operations')
->label('Back to '.$activeTenant->name)
->icon('heroicon-o-arrow-left')
->color('gray')
->url(\App\Filament\Pages\TenantDashboard::getUrl(panel: 'tenant', tenant: $activeTenant));
$actions[] = Action::make('operate_hub_show_all_tenants')
->label('Show all tenants')
->color('gray')
->action(function (): void {
Filament::setTenant(null, true);
app(WorkspaceContext::class)->clearLastTenantId(request());
$this->removeTableFilter('tenant_id');
$this->redirect('/admin/operations');
});
}
return $actions;
}
public function updatedActiveTab(): void
{
$this->resetPage();
@ -61,6 +105,8 @@ public function table(Table $table): Table
->query(function (): Builder {
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
$query = OperationRun::query()
->with('user')
->latest('id')
@ -71,6 +117,10 @@ public function table(Table $table): Table
->when(
! $workspaceId,
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
)
->when(
$activeTenant instanceof Tenant,
fn (Builder $query): Builder => $query->where('tenant_id', (int) $activeTenant->getKey()),
);
return $this->applyActiveTab($query);

View File

@ -9,17 +9,20 @@
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Support\OperateHub\OperateHubShell;
use App\Support\OperationRunLinks;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Pages\Page;
use Filament\Schemas\Components\EmbeddedSchema;
use Filament\Schemas\Schema;
use Illuminate\Support\Facades\Gate;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Str;
class TenantlessOperationRunViewer extends Page
{
use AuthorizesRequests;
protected static bool $shouldRegisterNavigation = false;
protected static bool $isDiscovered = false;
@ -37,16 +40,42 @@ class TenantlessOperationRunViewer extends Page
*/
protected function getHeaderActions(): array
{
$operateHubShell = app(OperateHubShell::class);
$actions = [
Action::make('refresh')
->label('Refresh')
->icon('heroicon-o-arrow-path')
Action::make('operate_hub_scope_run_detail')
->label($operateHubShell->scopeLabel(request()))
->color('gray')
->url(fn (): string => isset($this->run)
? route('admin.operations.view', ['run' => (int) $this->run->getKey()])
: route('admin.operations.index')),
->disabled(),
];
$activeTenant = $operateHubShell->activeEntitledTenant(request());
if ($activeTenant instanceof Tenant) {
$actions[] = Action::make('operate_hub_back_to_tenant_run_detail')
->label('← Back to '.$activeTenant->name)
->color('gray')
->url(\App\Filament\Pages\TenantDashboard::getUrl(panel: 'tenant', tenant: $activeTenant));
$actions[] = Action::make('operate_hub_show_all_operations')
->label('Show all operations')
->color('gray')
->url(fn (): string => route('admin.operations.index'));
} else {
$actions[] = Action::make('operate_hub_back_to_operations')
->label('Back to Operations')
->color('gray')
->url(fn (): string => route('admin.operations.index'));
}
$actions[] = Action::make('refresh')
->label('Refresh')
->icon('heroicon-o-arrow-path')
->color('gray')
->url(fn (): string => isset($this->run)
? route('admin.operations.view', ['run' => (int) $this->run->getKey()])
: route('admin.operations.index'));
if (! isset($this->run)) {
return $actions;
}
@ -87,7 +116,7 @@ public function mount(OperationRun $run): void
abort(403);
}
Gate::forUser($user)->authorize('view', $run);
$this->authorize('view', $run);
$this->run = $run->loadMissing(['workspace', 'tenant', 'user']);
}

View File

@ -10,6 +10,7 @@
use App\Models\VerificationCheckAcknowledgement;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\OperateHub\OperateHubShell;
use App\Support\OperationCatalog;
use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome;
@ -317,6 +318,14 @@ public static function table(Table $table): Table
Tables\Filters\SelectFilter::make('tenant_id')
->label('Tenant')
->options(function (): array {
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
if ($activeTenant instanceof Tenant) {
return [
(string) $activeTenant->getKey() => $activeTenant->getFilamentName(),
];
}
$user = auth()->user();
if (! $user instanceof User) {
@ -330,19 +339,19 @@ public static function table(Table $table): Table
->all();
})
->default(function (): ?string {
$tenant = Filament::getTenant();
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
if (! $tenant instanceof Tenant) {
if (! $activeTenant instanceof Tenant) {
return null;
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
if ($workspaceId === null || (int) $tenant->workspace_id !== (int) $workspaceId) {
if ($workspaceId === null || (int) $activeTenant->workspace_id !== (int) $workspaceId) {
return null;
}
return (string) $tenant->getKey();
return (string) $activeTenant->getKey();
})
->searchable(),
Tables\Filters\SelectFilter::make('type')

View File

@ -17,6 +17,14 @@ public function __invoke(Request $request): RedirectResponse
app(WorkspaceContext::class)->clearLastTenantId($request);
return redirect()->to('/admin/operations');
$previousUrl = url()->previous();
$previousHost = parse_url((string) $previousUrl, PHP_URL_HOST);
if ($previousHost !== null && $previousHost !== $request->getHost()) {
return redirect()->to('/admin/operations');
}
return redirect()->to((string) $previousUrl);
}
}

View File

@ -65,6 +65,10 @@ public function handle(Request $request, Closure $next): Response
$canCreateWorkspace = Gate::forUser($user)->check('create', Workspace::class);
if (! $hasAnyActiveMembership && $this->isOperateHubPath($path)) {
abort(404);
}
if (! $hasAnyActiveMembership && str_starts_with($path, '/admin/tenants')) {
abort(404);
}
@ -101,4 +105,13 @@ private function isWorkspaceOptionalPath(Request $request, string $path): bool
return preg_match('#^/admin/operations/[^/]+$#', $path) === 1;
}
private function isOperateHubPath(string $path): bool
{
return in_array($path, [
'/admin/operations',
'/admin/alerts',
'/admin/audit-log',
], true);
}
}

View File

@ -124,6 +124,7 @@ public function panel(Panel $panel): Panel
SubstituteBindings::class,
'ensure-correct-guard:web',
'ensure-workspace-selected',
'ensure-filament-tenant-selected',
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
])

View File

@ -11,6 +11,7 @@
use Filament\Http\Middleware\AuthenticateSession;
use Filament\Http\Middleware\DisableBladeIconComponents;
use Filament\Http\Middleware\DispatchServingFilamentEvent;
use Filament\Navigation\NavigationItem;
use Filament\Panel;
use Filament\PanelProvider;
use Filament\Support\Colors\Color;
@ -40,6 +41,23 @@ public function panel(Panel $panel): Panel
->colors([
'primary' => Color::Amber,
])
->navigationItems([
NavigationItem::make('Runs')
->url(fn (): string => route('admin.operations.index'))
->icon('heroicon-o-queue-list')
->group('Monitoring')
->sort(10),
NavigationItem::make('Alerts')
->url(fn (): string => route('admin.monitoring.alerts'))
->icon('heroicon-o-bell-alert')
->group('Monitoring')
->sort(20),
NavigationItem::make('Audit Log')
->url(fn (): string => route('admin.monitoring.audit-log'))
->icon('heroicon-o-clipboard-document-list')
->group('Monitoring')
->sort(30),
])
->renderHook(
PanelsRenderHook::HEAD_END,
fn () => view('filament.partials.livewire-intercept-shim')->render()

View File

@ -34,6 +34,12 @@ public function handle(Request $request, Closure $next): Response
$existingTenant = Filament::getTenant();
if ($existingTenant instanceof Tenant && $workspaceId !== null && (int) $existingTenant->workspace_id !== (int) $workspaceId) {
Filament::setTenant(null, true);
$existingTenant = null;
}
$user = $request->user();
if ($existingTenant instanceof Tenant && $user instanceof User && ! $user->canAccessTenant($existingTenant)) {
Filament::setTenant(null, true);
}
if ($path === '/livewire/update') {
@ -129,8 +135,6 @@ public function handle(Request $request, Closure $next): Response
return $next($request);
}
$user = $request->user();
if (! $user instanceof User) {
$this->configureNavigationForRequest($panel);

View File

@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace App\Support\OperateHub;
use App\Filament\Pages\TenantDashboard;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Illuminate\Http\Request;
final class OperateHubShell
{
public function __construct(
private WorkspaceContext $workspaceContext,
private CapabilityResolver $capabilityResolver,
) {}
public function scopeLabel(?Request $request = null): string
{
$activeTenant = $this->activeEntitledTenant($request);
if ($activeTenant instanceof Tenant) {
return 'Scope: Tenant — '.$activeTenant->name;
}
return 'Scope: Workspace — all tenants';
}
/**
* @return array{label: string, url: string}|null
*/
public function returnAffordance(?Request $request = null): ?array
{
$activeTenant = $this->activeEntitledTenant($request);
if ($activeTenant instanceof Tenant) {
return [
'label' => 'Back to '.$activeTenant->name,
'url' => TenantDashboard::getUrl(panel: 'tenant', tenant: $activeTenant),
];
}
return null;
}
/**
* @return array<Action>
*/
public function headerActions(
string $scopeActionName = 'operate_hub_scope',
string $returnActionName = 'operate_hub_return',
?Request $request = null,
): array {
$actions = [
Action::make($scopeActionName)
->label($this->scopeLabel($request))
->color('gray')
->disabled(),
];
$returnAffordance = $this->returnAffordance($request);
if (is_array($returnAffordance)) {
$actions[] = Action::make($returnActionName)
->label($returnAffordance['label'])
->icon('heroicon-o-arrow-left')
->color('gray')
->url($returnAffordance['url']);
}
return $actions;
}
public function activeEntitledTenant(?Request $request = null): ?Tenant
{
return $this->resolveActiveTenant($request);
}
private function resolveActiveTenant(?Request $request = null): ?Tenant
{
$tenant = Filament::getTenant();
if ($tenant instanceof Tenant && $this->isEntitled($tenant, $request)) {
return $tenant;
}
$rememberedTenantId = $this->workspaceContext->lastTenantId($request);
if ($rememberedTenantId === null) {
return null;
}
$rememberedTenant = Tenant::query()->whereKey($rememberedTenantId)->first();
if (! $rememberedTenant instanceof Tenant) {
return null;
}
if (! $this->isEntitled($rememberedTenant, $request)) {
return null;
}
return $rememberedTenant;
}
private function isEntitled(Tenant $tenant, ?Request $request = null): bool
{
if (! $tenant->isActive()) {
return false;
}
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
$workspaceId = $this->workspaceContext->currentWorkspaceId($request);
if ($workspaceId !== null && (int) $tenant->workspace_id !== (int) $workspaceId) {
return false;
}
return $this->capabilityResolver->isMember($user, $tenant);
}
}

View File

@ -1,6 +1,7 @@
<div class="space-y-2">
<div class="text-sm text-gray-600 dark:text-gray-300">
Alerts is reserved for future work.
<x-filament-panels::page>
<div class="space-y-2">
<div class="text-sm text-gray-600 dark:text-gray-300">
Alerts is reserved for future work.
</div>
</div>
</div>
</x-filament-panels::page>

View File

@ -1,6 +1,7 @@
<div class="space-y-2">
<div class="text-sm text-gray-600 dark:text-gray-300">
Audit Log is reserved for future work.
<x-filament-panels::page>
<div class="space-y-2">
<div class="text-sm text-gray-600 dark:text-gray-300">
Audit Log is reserved for future work.
</div>
</div>
</div>
</x-filament-panels::page>

View File

@ -5,6 +5,7 @@
use App\Models\WorkspaceMembership;
use App\Services\Auth\WorkspaceRoleCapabilityMap;
use App\Support\Auth\Capabilities;
use App\Support\OperateHub\OperateHubShell;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
@ -40,9 +41,12 @@
}
}
$currentTenant = Filament::getTenant();
$operateHubShell = app(OperateHubShell::class);
$currentTenant = $operateHubShell->activeEntitledTenant(request());
$currentTenantName = $currentTenant instanceof Tenant ? $currentTenant->getFilamentName() : null;
$hasAnyFilamentTenantContext = Filament::getTenant() instanceof Tenant;
$path = '/'.ltrim(request()->path(), '/');
$route = request()->route();
$routeName = (string) ($route?->getName() ?? '');
@ -65,7 +69,7 @@
|| ($hasTenantQuery && str_starts_with($routeName, 'filament.admin.'));
$lastTenantId = $workspaceContext->lastTenantId(request());
$canClearTenantContext = $currentTenantName !== null || $lastTenantId !== null;
$canClearTenantContext = $hasAnyFilamentTenantContext || $lastTenantId !== null;
@endphp
<div class="flex items-center gap-3">
@ -174,7 +178,7 @@ class="w-full px-3 py-2 text-left text-sm hover:bg-gray-50 dark:hover:bg-gray-80
<form method="POST" action="{{ route('admin.clear-tenant-context') }}">
@csrf
<x-filament::button color="gray" size="sm" outlined>
<x-filament::button type="submit" color="gray" size="sm" outlined>
Clear tenant context
</x-filament::button>
</form>

View File

@ -143,6 +143,7 @@
DispatchServingFilamentEvent::class,
FilamentAuthenticate::class,
'ensure-workspace-selected',
'ensure-filament-tenant-selected',
])
->get('/admin/operations', \App\Filament\Pages\Monitoring\Operations::class)
->name('admin.operations.index');
@ -155,6 +156,7 @@
DispatchServingFilamentEvent::class,
FilamentAuthenticate::class,
'ensure-workspace-selected',
'ensure-filament-tenant-selected',
])
->get('/admin/alerts', \App\Filament\Pages\Monitoring\Alerts::class)
->name('admin.monitoring.alerts');
@ -167,6 +169,7 @@
DispatchServingFilamentEvent::class,
FilamentAuthenticate::class,
'ensure-workspace-selected',
'ensure-filament-tenant-selected',
])
->get('/admin/audit-log', \App\Filament\Pages\Monitoring\AuditLog::class)
->name('admin.monitoring.audit-log');
@ -179,6 +182,7 @@
DispatchServingFilamentEvent::class,
FilamentAuthenticate::class,
'ensure-workspace-selected',
'ensure-filament-tenant-selected',
])
->get('/admin/operations/{run}', \App\Filament\Pages\Operations\TenantlessOperationRunViewer::class)
->name('admin.operations.view');

View File

@ -0,0 +1,34 @@
# Specification Quality Checklist: Tenant Operate Hub / Tenant Overview IA
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-02-09
**Feature**: [specs/085-tenant-operate-hub/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
- Dependencies/assumptions used: canonical monitoring surfaces exist at `/admin/operations`, `/admin/operations/{run}`, `/admin/alerts`, `/admin/audit-log`; tenant plane exists at `/admin/t/{tenant}`; tenant context can be active or absent; authorization semantics remain consistent (deny-as-not-found vs forbidden); Monitoring views are view-only render surfaces that must not initiate outbound calls on render.

View File

@ -0,0 +1,76 @@
openapi: 3.0.3
info:
title: Tenant Operate Hub / Central Monitoring (UI Route Contracts)
version: 0.1.0
description: |
Internal documentation of canonical central Monitoring surfaces.
These are Filament page routes (not a public API). The contract is used to
pin down URL shapes and security semantics (404 vs 403) for acceptance.
servers:
- url: /
paths:
/admin/operations:
get:
summary: Central Monitoring - Operations index
description: |
Canonical operations list. Must render without outbound calls.
Scope semantics:
- If tenant context is active AND entitled: page is tenant-filtered by default and shows tenant-scoped header/CTAs.
- If tenant context is absent: page is workspace-wide.
- If tenant context is active but not entitled: page behaves workspace-wide and must not reveal tenant identity.
responses:
'200':
description: OK
'302':
description: Redirect to choose workspace if none selected
'403':
description: Authenticated but forbidden (capability denial after membership)
'404':
description: Deny-as-not-found when not entitled to workspace scope
/admin/clear-tenant-context:
post:
summary: Exit tenant context (Monitoring)
description: |
Clears the active tenant context for the current session.
Used by “Show all tenants” on central Monitoring pages.
responses:
'302':
description: Redirect back to a canonical Monitoring page
'404':
description: Deny-as-not-found when not entitled to workspace scope
/admin/operations/{run}:
get:
summary: Central Monitoring - Run detail
parameters:
- in: path
name: run
required: true
schema:
type: integer
responses:
'200':
description: OK
'403':
description: Authenticated but forbidden (policy denies view)
'404':
description: Deny-as-not-found when run is outside entitled scope
/admin/alerts:
get:
summary: Central Monitoring - Alerts
responses:
'200':
description: OK
'404':
description: Deny-as-not-found when not entitled to workspace scope
/admin/audit-log:
get:
summary: Central Monitoring - Audit log
responses:
'200':
description: OK
'404':
description: Deny-as-not-found when not entitled to workspace scope

View File

@ -0,0 +1,63 @@
# Data Model: Tenant Operate Hub / Tenant Overview IA
**Date**: 2026-02-09
**Branch**: 085-tenant-operate-hub
This feature is primarily UI/IA + navigation behavior. It introduces **no new database tables**.
## Entities (existing)
### Workspace
- Purpose: primary isolation boundary and monitoring scope.
- Source of truth: `workspaces` + membership.
### Tenant
- Purpose: managed environment; tenant-plane routes live under `/admin/t/{tenant}`.
- Access: entitlement-based.
### OperationRun
- Purpose: canonical run tracking for all operational workflows.
- Surface:
- Index: `/admin/operations`
- Detail: `/admin/operations/{run}`
### Alert (placeholder)
- Purpose: future operator signals.
- Surface: `/admin/alerts`.
### Audit Event / Audit Log (placeholder)
- Purpose: immutable record of sensitive actions.
- Surface: `/admin/audit-log`.
## Session / Context State (existing)
### Workspace context
- Key: `WorkspaceContext::SESSION_KEY` (`current_workspace_id`)
- Meaning: selected workspace id for the current session.
### Last tenant per workspace (session-based)
- Key: `WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY` (`workspace_last_tenant_ids`)
- Shape:
- Map keyed by workspace id string → tenant id int
- Example:
- `{"12": 345}`
- APIs:
- `WorkspaceContext::rememberLastTenantId(int $workspaceId, int $tenantId, Request $request)`
- `WorkspaceContext::lastTenantId(Request $request): ?int`
- `WorkspaceContext::clearLastTenantId(Request $request)`
### Filament tenant context
- Source: `Filament::getTenant()` (may persist across panels depending on Filament tenancy configuration).
- Used to determine “active tenant context” for Monitoring UX.
**Spec 085 scope note**: Monitoring may use session-based last-tenant memory as a tenant-context signal when Filament tenant context is absent (e.g., when navigating from the tenant panel into central Monitoring). It must not be inferred from arbitrary deep links.
### Stale tenant context behavior (no entitlement)
- If tenant context is active but the user is not entitled, Monitoring pages behave as workspace-wide views and must not display tenant identity.
## Validation / Rules
- Tenant context MUST NOT be implicitly mutated by canonical monitoring pages.
- Deny-as-not-found (404) applies when the actor is not entitled to tenant/workspace scope.
- Forbidden (403) applies only after membership is established but capability is missing.

View File

@ -0,0 +1,123 @@
# Implementation Plan: Spec 085 — Tenant Operate Hub / Tenant Overview IA
**Branch**: `085-tenant-operate-hub` | **Date**: 2026-02-09 | **Spec**: specs/085-tenant-operate-hub/spec.md
**Input**: specs/085-tenant-operate-hub/spec.md
## Summary
Make central Monitoring pages feel context-aware when entered from the tenant panel, without introducing tenant-scoped monitoring routes and without implicit tenant switching.
Key outcomes:
- Tenant panel sidebar replaces “Operations” with a “Monitoring” group of shortcuts (Runs/Alerts/Audit Log) that open central Monitoring surfaces.
- `/admin/operations` becomes context-aware when tenant context is active: scope label shows tenant, table defaults to tenant filter, and header includes `Back to <tenant>` + `Show all tenants` (clears tenant context).
- `/admin/operations/{run}` adds deterministic “back” affordances: tenant back link when tenant context is active + entitled, plus secondary `Show all operations`; otherwise `Back to Operations`.
- Monitoring page render remains DB-only: no outbound calls and no background work triggered by view-only GET.
## Technical Context
**Language/Version**: PHP 8.4 (Laravel 12)
**Primary Dependencies**: Filament v5, Livewire v4, Laravel Sail, Tailwind CSS v4
**Storage**: PostgreSQL (Sail)
**Testing**: Pest v4 (`vendor/bin/sail artisan test`)
**Target Platform**: Web (enterprise SaaS admin UI)
**Project Type**: Laravel monolith (Filament panels + Livewire)
**Performance Goals**: Monitoring page renders are DB-only, low-latency, and avoid N+1 regressions
**Constraints**:
- Canonical monitoring URLs must not change (`/admin/operations`, `/admin/operations/{run}`)
- No new tenant-scoped monitoring routes
- No implicit tenant switching (tenant selection remains explicit POST)
- Deny-as-not-found (404) for non-members/non-entitled; 403 only after membership established
- No outbound calls on render; no render-time side effects (jobs/notifications)
**Scale/Scope**: Small-to-medium UX change touching tenant navigation + 2 monitoring pages + Pest tests
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Inventory-first / snapshots: Not applicable (read-only monitoring UX).
- Read/write separation: PASS (changes are navigation + view-only rendering; the only mutation is explicit “clear tenant context” action).
- Graph contract path: PASS (no new Graph calls).
- Deterministic capabilities: PASS (uses existing membership/entitlement checks; no new capability strings).
- Workspace isolation: PASS (non-member workspace access remains 404).
- Tenant isolation: PASS (no tenant identity leaks when not entitled; tenant pages remain 404).
- Run observability: PASS (view-only pages do not start operations; Monitoring stays DB-only).
- RBAC-UX destructive confirmation: PASS (no destructive actions added).
- Filament UI Action Surface Contract: PASS (were modifying Pages; we will provide explicit header actions and table/default filter behavior; no new list resources are added).
## Project Structure
### Documentation (this feature)
```text
specs/085-tenant-operate-hub/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── openapi.yaml
└── tasks.md
```
### Source Code (repository root)
```text
app/
├── Filament/
│ ├── Pages/
│ ├── Resources/
│ └── ...
├── Http/
│ ├── Controllers/
│ └── Middleware/
├── Providers/
└── Support/
resources/views/
tests/Feature/
routes/web.php
```
**Structure Decision**: Laravel monolith with Filament panels. Changes will be localized to existing panel providers, page classes, shared helpers (if present), and feature tests.
## Phase Plan
### Phase 0 — Research (complete)
Outputs:
- specs/085-tenant-operate-hub/research.md (decisions + alternatives)
### Phase 1 — Design & Contracts (complete)
Outputs:
- specs/085-tenant-operate-hub/data-model.md (no schema changes; context rules)
- specs/085-tenant-operate-hub/contracts/openapi.yaml (canonical routes + clear-tenant-context POST)
- specs/085-tenant-operate-hub/quickstart.md (manual verification)
### Phase 2 — Implementation Planning (next)
Implementation will be executed as small, test-driven slices:
1) Tenant panel navigation IA
- Replace tenant-panel “Operations” entry with “Monitoring” group.
- Add 3 shortcut items (Runs/Alerts/Audit Log).
- Verify no new tenant-scoped monitoring routes are introduced.
2) Operations index context-aware header + default scope
- If tenant context active + entitled: show scope `Tenant — <name>`, default table filter = tenant, CTAs `Back to <tenant>` and `Show all tenants`.
- If no tenant context: show scope `Workspace — all tenants`.
- If tenant context active but not entitled: behave workspace-wide (no tenant name, no back-to-tenant).
3) Run detail deterministic back affordances
- If tenant context active + entitled: `← Back to <tenant>` plus secondary `Show all operations`.
- Else: `Back to Operations`.
4) Pest tests (security + UX)
- OperationsIndexScopeTest (tenant vs workspace scope labels + CTAs)
- RunDetailBackToTenantTest (tenant-context vs no-context actions)
- Deny-as-not-found coverage for non-entitled tenant pages
- “No outbound calls on render” guard for `/admin/operations` and `/admin/operations/{run}`
## Complexity Tracking
No constitution violations expected.

View File

@ -0,0 +1,39 @@
# Quickstart: Tenant Operate Hub / Tenant Overview IA
**Date**: 2026-02-09
**Branch**: 085-tenant-operate-hub
## Local setup
- Start containers: `vendor/bin/sail up -d`
- Install deps (if needed): `vendor/bin/sail composer install`
## Manual verification steps (happy path)
1. Sign in.
2. Select a workspace (via the context bar).
3. Enter a tenant context (e.g., go to `/admin/t/{tenant}` via the tenant panel).
4. In the tenant panel sidebar, open the **Monitoring** group and click:
- Runs → lands on `/admin/operations`
5. Verify `/admin/operations` shows:
- Header scope: `Scope: Tenant — <tenant name>`
- CTAs: `Back to <tenant name>` and `Show all tenants`
- The table default scope is tenant-filtered to the active tenant.
6. Click `Show all tenants`.
7. Verify you stay on `/admin/operations` and scope becomes `Scope: Workspace — all tenants`.
8. Open an operation run detail at `/admin/operations/{run}`.
9. Verify the header shows:
- `← Back to <tenant name>`
- secondary `Show all operations``/admin/operations`
10. Click `← Back to <tenant name>` and verify it lands on the tenant dashboard (`/admin/t/{tenant}`).
## Negative verification (security)
- With tenant context active, revoke tenant entitlement for the test user.
- Reload `/admin/operations`.
- Verify scope is workspace-wide and no tenant name / “Back to tenant” affordance appears.
- Request the tenant dashboard URL directly (`/admin/t/{tenant}`) and verify deny-as-not-found.
## Test commands (to be added in Phase 2)
- Targeted suite: `vendor/bin/sail artisan test --compact --filter=OperationsIndexScopeTest`

View File

@ -0,0 +1,70 @@
# Research: Tenant Operate Hub / Tenant Overview IA (Spec 085)
**Date**: 2026-02-09
**Branch**: 085-tenant-operate-hub
**Spec**: specs/085-tenant-operate-hub/spec.md
This research consolidates repo evidence + the final clarification decisions, so the implementation plan and tests can be deterministic.
## Repository Evidence (high-signal)
- Canonical monitoring pages already exist in the Admin panel:
- Operations index: app/Filament/Pages/Monitoring/Operations.php
- Run detail (tenantless viewer): app/Filament/Pages/Operations/TenantlessOperationRunViewer.php
- Alerts: app/Filament/Pages/Monitoring/Alerts.php
- Audit log: app/Filament/Pages/Monitoring/AuditLog.php
- Tenant selection + clear tenant context already exist (UI + route/controller):
- Context bar partial: resources/views/filament/partials/context-bar.blade.php
- Tenant select controller: app/Http/Controllers/SelectTenantController.php
## Decisions (resolved)
### Decision: Tenant context may use last-tenant memory for cross-panel flows
- Decision: “Tenant context is active” on Monitoring pages is resolved from the active Filament tenant when present, otherwise from the remembered last-tenant id for the current workspace.
- Rationale: Central Monitoring routes are canonical and tenantless, but users enter them from the tenant panel and expect consistent scoping.
- Alternatives considered:
- Query param `?tenant=`: rejected (would introduce an implicit switching vector).
### Decision: “Show all tenants” explicitly exits tenant context
- Decision: The CTA “Show all tenants” clears tenant context (single meaning) and returns the user to workspace-wide Monitoring.
- Rationale: Prevents the confusing state where tenant context is still active but filters are reset.
- Alternatives considered:
- Only reset table filter: rejected (context remains active and confuses scope semantics).
### Decision: Stale tenant context is handled without leaks
- Decision: If tenant context is active but the user is no longer entitled to that tenant, Monitoring pages behave as workspace-wide:
- Scope shows `Workspace — all tenants`
- No tenant name is shown
- No “Back to tenant” is rendered
- Tenant pages remain deny-as-not-found
- Rationale: Preserves deny-as-not-found and avoids tenant existence hints.
- Alternatives considered:
- 404 the Monitoring page: rejected (feels like being “thrown out”).
- Auto-clear tenant context implicitly: rejected (implicit context mutation).
### Decision: Run detail shows an explicit tenant return + secondary escape hatch
- Decision: When tenant context is active and entitled, run detail shows:
- `← Back to <tenant name>` (tenant dashboard)
- secondary `Show all operations``/admin/operations`
- Rationale: “Back to tenant” is deterministic; the secondary link provides a canonical escape hatch.
- Alternatives considered:
- Only show `Back to tenant`: rejected by clarification (secondary escape hatch approved).
### Decision: Tenant panel navigation uses a “Monitoring” group with central shortcuts
- Decision: Replace the tenant-panel “Operations” item with group “Monitoring” containing shortcuts:
- Runs → `/admin/operations`
- Alerts → `/admin/alerts`
- Audit Log → `/admin/audit-log`
- Rationale: Keep tenant sidebar labels minimal while still providing correct central Monitoring entry points, while preserving canonical URLs and avoiding tenant-scoped monitoring routes.
- Alternatives considered:
- New tenant-scoped monitoring routes: rejected (explicitly forbidden).
### Decision: “No outbound calls on render” is enforced by tests
- Decision: Monitoring GET renders must not trigger outbound network calls or start background work as a side effect.
- Rationale: Aligns with constitution (“Monitoring pages MUST be DB-only at render time”).
- Alternatives considered:
- Rely on convention: rejected; this needs regression protection.
## Open Questions
None remaining for Phase 0. The spec clarifications cover all scope-affecting ambiguities.

View File

@ -0,0 +1,194 @@
# Feature Specification: Tenant Operate Hub / Tenant Overview IA
**Feature Branch**: `085-tenant-operate-hub`
**Created**: 2026-02-09
**Status**: Draft
**Input**: User description: "Make central Monitoring surfaces feel context-aware when entered from a tenant, without changing canonical URLs, and without weakening deny-as-not-found security boundaries."
## Clarifications
### Session 2026-02-09 (work order alignment)
- Q: What is the source of truth for “Back to tenant”? → A: The active entitled tenant context (Filament tenant if present, otherwise the remembered last-tenant id for the current workspace).
- Q: Should “Back to last tenant” be implemented as a separate feature? → A: No; remembered tenant context is used only to preserve context when navigating from a tenant into central Monitoring.
- Q: What does “Show all tenants” do? → A: It explicitly exits tenant context to return to workspace-wide monitoring (no mixed behavior with filter resets).
- Q: How is Monitoring reached from tenant context? → A: Tenant navigation offers a “Monitoring” group with shortcuts that open central Monitoring surfaces.
- Q: How should stale tenant context (tenant context active but user no longer entitled) behave on Monitoring pages? → A: Monitoring renders workspace-wide (no tenant name, no “Back to tenant”), preserving deny-as-not-found for tenant pages.
- Q: Should run detail offer a secondary escape hatch when tenant context is active? → A: Yes — show a secondary “Show all operations” link to `/admin/operations`.
- Q: How should tenant Monitoring shortcuts indicate “opens central monitoring”? → A: Keep labels minimal (no “↗ Central” suffix).
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Monitoring feels context-aware (Priority: P1)
As an operator, when I open central Monitoring from within a tenant, I immediately understand:
1) whether the Monitoring view is scoped to the current tenant or to all tenants, and
2) how to get back to the tenant I came from.
**Why this priority**: This is the core usability and safety problem: monitoring and tenant work should not feel like different apps, but they must not blur security boundaries.
**Independent Test**: With a tenant context active, a test user can open the Operations index and a run detail, see an explicit scope indicator and a deterministic “Back to tenant”, and exit to workspace-wide monitoring intentionally.
**Acceptance Scenarios**:
1. **Given** a user is a member of a workspace and has access to at least one tenant, **When** they open central Monitoring (Operations), **Then** the page clearly shows whether tenant context is active.
2. **Given** a tenant context is active, **When** the user navigates to a canonical monitoring detail page, **Then** the UI provides a single, clear “Back to tenant” affordance that returns to that tenant dashboard.
3. **Given** a user is not entitled to the current tenant, **When** they try to access tenant-scoped pages via a direct link, **Then** they receive a not-found experience (deny-as-not-found), without any tenant existence hints.
---
### User Story 2 - Canonical URLs with explicit scope (Priority: P2)
As an operator, I can use canonical Monitoring URLs at all times. When tenant context is active, Monitoring views can be tenant-filtered by default, but they must not implicitly change tenant selection.
**Why this priority**: Avoids mistakes and misinterpretation of data by preventing silent scoping changes.
**Independent Test**: With a tenant selected, open monitoring index and detail views and verify the scope is consistent and clearly communicated.
**Acceptance Scenarios**:
1. **Given** a tenant context is active, **When** the user opens the monitoring index, **Then** the default view is tenant-scoped (or clearly offers a one-click tenant scope), and the UI visibly indicates the scope.
2. **Given** no tenant context is active, **When** the user opens monitoring, **Then** the view is workspace-wide and does not imply a tenant is selected.
---
### User Story 3 - Deep links are safe and recoverable (Priority: P3)
As an operator working inside a tenant, when I land on a canonical run detail via a deep link, I can safely return to the tenant if tenant context is still active and I am still entitled.
**Why this priority**: These workflows are frequent. Deep links are where users most often “lose” tenant context.
**Independent Test**: With a tenant context active, open a canonical run detail and verify the “Back to tenant” affordance is present and correct.
**Acceptance Scenarios**:
1. **Given** a tenant context is active and the user is still entitled, **When** they open a canonical run detail, **Then** they see a “Back to tenant” affordance.
2. **Given** tenant context is not active, **When** the user opens a canonical run detail, **Then** they see only a “Back to Operations” affordance.
### Edge Cases
- User has no workspace selected: Monitoring must not show cross-workspace data; user must select a workspace first.
- User has workspace access but zero tenant access: Monitoring must still work in workspace-wide mode, without tenant selection.
- Users tenant access is revoked while they have a deep link open: subsequent tenant-scoped navigation must be deny-as-not-found.
- User opens a bookmarked canonical run detail directly: the UI must provide a deterministic “Back” behavior without inventing tenant context.
- Tenant context is active, but entitlement was revoked: Monitoring must not leak tenant identity; tenant return affordance must not appear (or must be safe).
- Monitoring views must remain view-only render surfaces: rendering must not trigger outbound calls.
## Requirements *(mandatory)*
### Target State (hard decision)
This spec adopts a single, deterministic interpretation:
- Monitoring URLs are canonical and do not change with tenant context.
- Tenant context makes Monitoring *feel* scoped (scope indicators, default filters, and deterministic exits) without implicit tenant switching.
- Operations index: `/admin/operations`
- Operations run detail: `/admin/operations/{run}`
- Alerts: `/admin/alerts`
- Audit log: `/admin/audit-log`
Tenant plane remains under `/admin/t/{tenant}` for tenant dashboards and workflows. Monitoring views are central, but when tenant context is active they become tenant-filtered by default and provide a deterministic “Back to tenant” affordance.
**Constitution alignment (required):** This feature is information architecture + navigation behavior. It MUST NOT introduce new outbound calls for monitoring pages. If it introduces or changes any write/change behavior (e.g., starting workflows), it MUST maintain existing safety gates (preview/confirmation/audit), tenant isolation, run observability, and tests.
**Constitution alignment (RBAC-UX):** This feature changes how users reach surfaces; it MUST preserve and test authorization semantics:
- Non-member / not entitled to workspace scope OR tenant scope → deny-as-not-found (404 semantics)
- Member but missing capability → forbidden (403 semantics)
**Constitution alignment (OPS-EX-AUTH-001):** Authentication handshakes may perform synchronous outbound communication on auth endpoints. This MUST NOT be used for Monitoring pages.
**Constitution alignment (BADGE-001):** If any status/severity/outcome badges are added or changed on hub pages, their meaning MUST be centralized and covered by tests.
**Constitution alignment (UI Action Surfaces):** If this feature adds/modifies any admin or tenant UI surfaces, the “UI Action Matrix” MUST be updated and action gating MUST remain consistent (confirmation for destructive-like actions; server-side authorization for mutations).
### Functional Requirements
- **FR-085-001**: Tenant navigation MUST offer a “Monitoring” group with shortcuts to central Monitoring surfaces:
- Runs (Operations) → `/admin/operations`
- Alerts → `/admin/alerts`
- Audit Log → `/admin/audit-log`
These shortcuts MUST NOT introduce new tenant-scoped monitoring URLs.
- **FR-085-002**: The Operations index (`/admin/operations`) MUST show a clear scope indicator in the page header:
- `Scope: Workspace — all tenants` when no tenant context is active
- `Scope: Tenant — <tenant name>` when tenant context is active
- **FR-085-003**: Canonical Monitoring URLs MUST NOT implicitly change tenant context. Tenant context MAY influence default filters on Monitoring views.
- **FR-085-004**: When tenant context is active on the Operations index, the default tenant filter MUST be set to the current tenant, and the UI MUST make this tenant scoping obvious.
- **FR-085-005**: The Operations index MUST provide two explicit CTAs when tenant context is active:
- `Show all tenants` (explicitly exits tenant context and returns to workspace-wide monitoring)
- `Back to <tenant name>` (navigates to tenant dashboard)
- **FR-085-006**: The run detail (`/admin/operations/{run}`) MUST provide a deterministic “Back” affordance:
- If tenant context is active AND the user is still entitled: `← Back to <tenant name>` (tenant dashboard) AND a secondary `Show all operations` (to `/admin/operations`)
- Else: `Back to Operations` (Operations index)
- **FR-085-007**: “Back to tenant” MUST be based only on active entitled tenant context (Filament tenant, or remembered tenant for the current workspace). It MUST NOT be inferred from arbitrary deep-link parameters.
- **FR-085-008**: Deny-as-not-found MUST remain: users not entitled to workspace or tenant scope MUST receive a not-found experience (404 semantics), with no tenant existence hints.
- **FR-085-009**: Monitoring views (`/admin/operations` and `/admin/operations/{run}`) MUST remain view-only render surfaces and MUST NOT trigger outbound calls during render.
- **FR-085-010**: If tenant context is active but the user is not entitled to that tenant, Monitoring pages MUST behave as workspace-wide views:
- Scope indicator MUST show `Scope: Workspace — all tenants`
- No tenant name MUST be displayed
- No “Back to <tenant>” affordance MUST be rendered
- Direct access to tenant pages MUST continue to be deny-as-not-found
## UI Action Matrix *(mandatory when UI surfaces are 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 |
|---|---|---|---|---|---|---|---|---|---|---|
| Central Operations (index) | `/admin/operations` | Scope indicator; `Show all tenants` (when tenant context active); deterministic back affordance | Linked run rows to open run detail | N/A | N/A | N/A | N/A | N/A | No | Must not implicitly change tenant context; default tenant filter when tenant context active |
| Central Operations (run detail) | `/admin/operations/{run}` | `← Back to <tenant>` (when tenant context active + entitled) OR `Back to Operations`; secondary `Show all operations` allowed when tenant context active + entitled | N/A | N/A | N/A | N/A | N/A | N/A | No | Must not reveal tenant identity when user is not entitled |
| Tenant navigation shortcuts | Tenant sidebar | N/A | N/A | N/A | N/A | N/A | N/A | N/A | No | “Monitoring” group with central shortcuts |
### Key Entities *(include if feature involves data)*
- **Workspace**: A security and organizational boundary for operations and monitoring.
- **Tenant**: A managed environment within a workspace; access is entitlement-based.
- **Monitoring (Operations)**: Central monitoring views that can be workspace-wide or tenant-scoped when tenant context is active.
- **Operation Run**: A tracked execution of an operational workflow; viewable via canonical run detail.
- **Alert**: An operator-facing signal about an issue or state requiring attention.
- **Audit Event**: An immutable record of important user-triggered actions and sensitive operations.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-085-001**: In a usability walkthrough, 90% of operators can correctly identify whether Operations is scoped to a tenant or to all tenants within 10 seconds of opening `/admin/operations`.
- **SC-085-002**: With tenant context active, operators can return to the tenant dashboard from `/admin/operations` and `/admin/operations/{run}` in ≤ 1 click.
- **SC-085-003**: Support tickets tagged “lost tenant context / where am I?” decrease by 30% within 30 days after rollout.
- **SC-085-004**: Authorization regression checks show zero cases where a non-entitled user can infer existence of a tenant or view tenant-scoped monitoring data.
### Engineering Acceptance Outcomes
- **SC-085-005**: When tenant context is active, `/admin/operations` and `/admin/operations/{run}` clearly show tenant scope and a “Back to <tenant>” affordance.
- **SC-085-006**: When tenant context is not active, `/admin/operations/{run}` shows “Back to Operations” and no “Back to tenant”.
- **SC-085-007**: Viewing Monitoring pages does not initiate outbound network requests or start background work as a side effect of rendering.
## Test Plan *(mandatory)*
1. **Operations index scope label + CTAs (tenant context)**
- With tenant context active and user entitled, request `/admin/operations`.
- Assert the page indicates `Scope: Tenant — <tenant name>`.
- Assert `Show all tenants` and `Back to <tenant name>` are available.
2. **Operations index scope label (no tenant context)**
- With no tenant context active, request `/admin/operations`.
- Assert the page indicates `Scope: Workspace — all tenants`.
3. **Run detail back affordance (tenant context)**
- With tenant context active and user entitled, request `/admin/operations/{run}`.
- Assert `← Back to <tenant name>` is available.
- Assert secondary `Show all operations` is available and links to `/admin/operations`.
4. **Run detail back affordance (no tenant context)**
- With no tenant context active, request `/admin/operations/{run}`.
- Assert only `Back to Operations` is available.
5. **Deny-as-not-found regression**
- As a user without tenant entitlement, request `/admin/t/{tenant}`.
- Assert deny-as-not-found behavior (404 semantics) and that no tenant identity hints are revealed via Monitoring CTAs.
6. **Stale tenant context behaves workspace-wide**
- With tenant context active but user not entitled, request `/admin/operations`.
- Assert scope indicates workspace-wide and no tenant name or “Back to tenant” is present.
7. **No outbound calls on render**
- Assert rendering `/admin/operations` and `/admin/operations/{run}` does not initiate outbound network calls and does not start background work from a view-only GET.

View File

@ -0,0 +1,192 @@
---
description: "Task list for Spec 085 — Tenant Operate Hub / Tenant Overview IA"
---
# Tasks: Spec 085 — Tenant Operate Hub / Tenant Overview IA
**Input**: Design documents from `/specs/085-tenant-operate-hub/`
**Required**:
- `specs/085-tenant-operate-hub/plan.md`
- `specs/085-tenant-operate-hub/spec.md`
**Additional docs present**:
- `specs/085-tenant-operate-hub/research.md`
- `specs/085-tenant-operate-hub/data-model.md`
- `specs/085-tenant-operate-hub/contracts/openapi.yaml`
- `specs/085-tenant-operate-hub/quickstart.md`
**Tests**: REQUIRED (runtime UX + security semantics; Pest)
---
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Confirm the existing code touchpoints and test harness for Spec 085.
- [X] T001 Confirm canonical Monitoring routes + existing clear-context endpoint in routes/web.php and app/Http/Controllers/ClearTenantContextController.php
- [X] T002 Confirm the Monitoring pages exist and are canonical: app/Filament/Pages/Monitoring/Operations.php, app/Filament/Pages/Operations/TenantlessOperationRunViewer.php, app/Filament/Pages/Monitoring/Alerts.php, app/Filament/Pages/Monitoring/AuditLog.php
- [X] T003 Confirm Tenant panel provider is the entry point for tenant sidebar Monitoring shortcuts in app/Providers/Filament/TenantPanelProvider.php
- [X] T004 Confirm Laravel 11+/12 panel provider registration is in bootstrap/providers.php (not bootstrap/app.php)
- [X] T005 [P] Identify existing monitoring/tenant scoping tests to extend (tests/Feature/Monitoring/OperationsTenantScopeTest.php, tests/Feature/Operations/TenantlessOperationRunViewerTest.php)
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Shared helper behavior must match Spec 085 semantics before story work.
- [X] T006 Update scope-label copy and semantics in app/Support/OperateHub/OperateHubShell.php (MUST match FR-085-002 exactly: "Scope: Workspace — all tenants" / "Scope: Tenant — <tenant name>")
- [X] T007 Ensure OperateHubShell resolves active entitled tenant context safely (Filament tenant when present, otherwise remembered last-tenant id for the current workspace)
- [X] T008 Update OperateHubShell return affordance label to include tenant name ("Back to <tenant name>") in app/Support/OperateHub/OperateHubShell.php
- [X] T009 Add a helper method to resolve “active tenant AND still entitled” in app/Support/OperateHub/OperateHubShell.php (used by Operations index + run detail to implement stale-tenant-context behavior)
- [X] T010 Ensure Monitoring renders remain DB-only (no outbound calls / no side effects) by standardizing test guards with Http::preventStrayRequests() in tests/Feature/Spec085/*.php and existing coverage tests/Feature/Monitoring/OperationsTenantScopeTest.php and tests/Feature/Operations/TenantlessOperationRunViewerTest.php
**Checkpoint**: Shared semantics locked; user story work can begin.
---
## Phase 3: User Story 1 — Monitoring feels context-aware (Priority: P1) 🎯 MVP
**Goal**: When tenant context is active, Monitoring clearly shows tenant scope + deterministic “Back to tenant” and offers explicit “Show all tenants” to exit.
**Independent Test**: With tenant context active + entitled, GET `/admin/operations` shows `Scope: Tenant — <tenant>` and buttons `Back to <tenant>` and `Show all tenants`; clicking “Show all tenants” clears tenant context and returns to workspace-wide operations.
### Tests for User Story 1 (write first)
- [X] T011 [P] [US1] Add Spec 085 operations header tests in tests/Feature/Spec085/OperationsIndexHeaderTest.php (tenant scope label + both CTAs)
- [X] T012 [P] [US1] Add stale-tenant-context test in tests/Feature/Spec085/OperationsIndexHeaderTest.php (tenant context set but user not entitled → workspace scope + no tenant name + no back-to-tenant)
- [X] T013 [P] [US1] Add explicit-exit behavior test in tests/Feature/Spec085/OperationsIndexHeaderTest.php (POST /admin/clear-tenant-context clears Filament tenant + last tenant id)
- [X] T014 [P] [US1] Add tenant navigation shortcuts test in tests/Feature/Spec085/TenantNavigationMonitoringShortcutsTest.php (tenant sidebar shows “Monitoring” group with Runs/Alerts/Audit Log)
- [X] T015 [P] [US1] Add “deny-as-not-found” regression tests for canonical Monitoring access in tests/Feature/Spec085/DenyAsNotFoundSemanticsTest.php (non-workspace-member → 404 for /admin/operations and /admin/operations/{run})
- [X] T016 [P] [US1] Add “deny-as-not-found” regression test for tenant dashboard direct access in tests/Feature/Spec085/DenyAsNotFoundSemanticsTest.php (non-entitled to tenant → 404 for /admin/t/{tenant})
### Implementation for User Story 1
- [X] T017 [US1] Replace Tenant sidebar "Operations" item with "Monitoring" group shortcuts in app/Providers/Filament/TenantPanelProvider.php (Runs→/admin/operations, Alerts→/admin/alerts, Audit Log→/admin/audit-log)
- [X] T018 [US1] Implement Operations index scope indicator per Spec 085 in app/Filament/Pages/Monitoring/Operations.php (workspace vs tenant; stale context treated as workspace)
- [X] T019 [US1] Implement Operations index CTAs per Spec 085 in app/Filament/Pages/Monitoring/Operations.php (Back to <tenant> using App\Filament\Pages\TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant); Show all tenants exits tenant context)
- [X] T020 [US1] Ensure “Show all tenants” uses an explicit server-side action (no implicit GET mutation) in app/Filament/Pages/Monitoring/Operations.php (perform the same mutations as app/Http/Controllers/ClearTenantContextController.php: Filament::setTenant(null, true) + WorkspaceContext::clearLastTenantId(); then redirect to /admin/operations)
**Checkpoint**: US1 fully testable and meets FR-085-001/002/005/007/010.
---
## Phase 4: User Story 2 — Canonical URLs with explicit scope (Priority: P2)
**Goal**: Canonical Monitoring URLs never implicitly change tenant context; tenant context may only affect default filtering and must be obvious.
**Independent Test**: With tenant context active, GET `/admin/operations` does not change tenant context and defaults the list to the active tenant (or otherwise clearly shows its tenant-scoped by default).
### Tests for User Story 2
- [X] T021 [P] [US2] Add non-mutation test in tests/Feature/Spec085/CanonicalMonitoringDoesNotMutateTenantContextTest.php (GET /admin/operations does not set/clear tenant context)
- [X] T022 [P] [US2] Add scope label test in tests/Feature/Spec085/CanonicalMonitoringDoesNotMutateTenantContextTest.php (no tenant context → "Scope: Workspace — all tenants")
- [X] T023 [P] [US2] Add default-tenant-filter test in tests/Feature/Monitoring/OperationsTenantScopeTest.php (tenant context active → list defaults to active tenant)
### Implementation for User Story 2
- [X] T024 [US2] Ensure Operations index query applies workspace scoping and (when tenant context is active + entitled) tenant scoping without mutating tenant context in app/Filament/Pages/Monitoring/Operations.php
- [X] T025 [US2] Ensure any default tenant filter is applied as a query/filter default only (no calls to Filament::setTenant() during GET) in app/Filament/Pages/Monitoring/Operations.php
**Checkpoint**: US2 meets FR-085-003/004/009.
---
## Phase 5: User Story 3 — Deep links are safe and recoverable (Priority: P3)
**Goal**: On `/admin/operations/{run}`, tenant-context users get a deterministic “Back to <tenant>” plus a secondary “Show all operations”; otherwise only “Back to Operations”.
**Independent Test**: With tenant context active + entitled, GET `/admin/operations/{run}` shows `← Back to <tenant>` and `Show all operations`; without tenant context it shows `Back to Operations` only.
### Tests for User Story 3
- [X] T026 [P] [US3] Add run detail header-action tests in tests/Feature/Spec085/RunDetailBackAffordanceTest.php (tenant context vs no context)
- [X] T027 [P] [US3] Add stale-tenant-context run detail test in tests/Feature/Spec085/RunDetailBackAffordanceTest.php (tenant context set but not entitled → no tenant name, no back-to-tenant)
### Implementation for User Story 3
- [X] T028 [US3] Implement deterministic back affordances for run detail in app/Filament/Pages/Operations/TenantlessOperationRunViewer.php (tenant-context+entitled → “← Back to <tenant>” + “Show all operations”; else “Back to Operations”)
- [X] T029 [US3] Ensure run detail never reveals tenant identity when the viewer is not entitled (stale tenant context treated as workspace-wide) in app/Filament/Pages/Operations/TenantlessOperationRunViewer.php
**Checkpoint**: US3 meets FR-085-006/008/010.
---
## Phase 6: Polish & Cross-Cutting Concerns
- [X] T030 [P] Confirm Spec 085 UI Action Matrix matches implemented header actions in specs/085-tenant-operate-hub/spec.md
- [X] T031 [P] Validate manual verification steps in specs/085-tenant-operate-hub/quickstart.md against actual behavior (update doc only if it drifted)
- [X] T037 Ensure “Show all tenants” clears Operations table tenant filter state (prevents stale Livewire table filter state from keeping the list scoped)
- [X] T032 Run formatting on changed files under app/ and tests/ using vendor/bin/sail bin pint --dirty
- [X] T033 Run focused test suite: vendor/bin/sail artisan test --compact tests/Feature/Spec085 tests/Feature/Monitoring/OperationsTenantScopeTest.php tests/Feature/Operations/TenantlessOperationRunViewerTest.php
- [X] T034 Fix Filament auth-pattern guard compliance by removing Gate:: usage in app/Filament/Pages/Operations/TenantlessOperationRunViewer.php (use $this->authorize(...))
- [X] T035 Ensure canonical Operate Hub routes sanitize stale/non-entitled tenant context by applying ensure-filament-tenant-selected middleware to /admin/operations, /admin/alerts, /admin/audit-log, and /admin/operations/{run}
- [X] T036 Harden Spec 085-related tests to match final copy/semantics and avoid brittle Livewire DOM assertions (tests/Feature/OpsUx/OperateHubShellTest.php, tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php)
---
## Dependencies & Execution Order
### Dependency Graph
```mermaid
graph TD
P1[Phase 1: Setup] --> P2[Phase 2: Foundational]
P2 --> US1[US1 (P1): Context-aware Monitoring entry]
US1 --> US2[US2 (P2): Canonical URLs + explicit scope]
US1 --> US3[US3 (P3): Deep-link back affordances]
US2 --> P6[Phase 6: Polish]
US3 --> P6
```
### User Story Dependencies
- US1 is the MVP.
- US2 and US3 depend on the shared foundational semantics (scope labels + entitled active tenant resolution).
---
## Parallel Execution Examples
### US1
```text
In parallel:
- T011 (tests) in tests/Feature/Spec085/OperationsIndexHeaderTest.php
- T017 (tenant nav shortcuts) in app/Providers/Filament/TenantPanelProvider.php
Then:
- T018T020 in app/Filament/Pages/Monitoring/Operations.php
```
### US2
```text
In parallel:
- T021T022 (non-mutation + scope label tests)
- T023 (default tenant filter test) in tests/Feature/Monitoring/OperationsTenantScopeTest.php
Then:
- T024T025 in app/Filament/Pages/Monitoring/Operations.php
```
### US3
```text
In parallel:
- T026T027 (run detail tests) in tests/Feature/Spec085/RunDetailBackAffordanceTest.php
Then:
- T028T029 in app/Filament/Pages/Operations/TenantlessOperationRunViewer.php
```
---
## Implementation Strategy
### MVP Scope
- Implement US1 only (T011T020), run T033, then manually validate via specs/085-tenant-operate-hub/quickstart.md.
### Incremental Delivery
- US1 → US2 → US3, keeping each story independently testable.

View File

@ -118,12 +118,21 @@
$component = Livewire::actingAs($user)
->test(Operations::class)
->assertCanSeeTableRecords([$runA])
->assertCanNotSeeTableRecords([$runB]);
->assertSee('TenantA')
->assertDontSee('TenantB')
->assertSet('tableFilters.tenant_id.value', (string) $tenantA->getKey());
$component
->filterTable('tenant_id', null)
->assertCanSeeTableRecords([$runA, $runB]);
->callAction('operate_hub_show_all_tenants')
->assertSet('tableFilters.tenant_id.value', null)
->assertRedirect('/admin/operations');
Filament::setTenant(null, true);
Livewire::actingAs($user)
->test(Operations::class)
->assertSee('TenantA')
->assertSee('TenantB');
});
it('does not register legacy operation resource routes', function (): void {

View File

@ -5,8 +5,13 @@
use App\Models\Tenant;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
beforeEach(function (): void {
Http::preventStrayRequests();
});
it('defaults Monitoring → Operations list to the active tenant when tenant context is set', function () {
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();
@ -47,6 +52,49 @@
->assertDontSee('TenantB');
});
it('defaults Monitoring → Operations list to the remembered tenant when Filament tenant is not available', function () {
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();
[$user] = createUserWithTenant($tenantA, role: 'owner');
$tenantB->forceFill(['workspace_id' => (int) $tenantA->workspace_id])->save();
$user->tenants()->syncWithoutDetaching([
$tenantB->getKey() => ['role' => 'owner'],
]);
OperationRun::factory()->create([
'tenant_id' => $tenantA->getKey(),
'type' => 'policy.sync',
'status' => 'queued',
'outcome' => 'pending',
'initiator_name' => 'TenantA',
]);
OperationRun::factory()->create([
'tenant_id' => $tenantB->getKey(),
'type' => 'inventory.sync',
'status' => 'queued',
'outcome' => 'pending',
'initiator_name' => 'TenantB',
]);
Filament::setTenant(null, true);
$workspaceId = (int) $tenantA->workspace_id;
app(WorkspaceContext::class)->rememberLastTenantId($workspaceId, (int) $tenantA->getKey());
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => $workspaceId])
->get('/admin/operations')
->assertOk()
->assertSee('Scope: Tenant — '.$tenantA->name)
->assertSee('Policy sync')
->assertSee('TenantA')
->assertDontSee('Inventory sync');
});
it('scopes Monitoring → Operations tabs to the active tenant', function () {
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();

View File

@ -9,6 +9,11 @@
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Support\Facades\Http;
beforeEach(function (): void {
Http::preventStrayRequests();
});
it('allows viewing an operation run without a selected workspace when the user is a member of the run workspace', function (): void {
$workspace = Workspace::factory()->create();

View File

@ -2,6 +2,8 @@
declare(strict_types=1);
use App\Models\OperationRun;
use App\Support\OperationRunLinks;
use Illuminate\Support\Facades\File;
it('routes all OperationRun view links through OperationRunLinks', function (): void {
@ -30,3 +32,12 @@
expect($violations)->toBeEmpty();
})->group('ops-ux');
it('resolves tenantless operation run links to the canonical admin.operations.view route', function (): void {
$run = OperationRun::factory()->create();
$expectedUrl = route('admin.operations.view', ['run' => (int) $run->getKey()]);
expect(OperationRunLinks::tenantlessView($run))->toBe($expectedUrl);
expect(OperationRunLinks::tenantlessView((int) $run->getKey()))->toBe($expectedUrl);
})->group('ops-ux');

View File

@ -0,0 +1,264 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\TenantDashboard;
use App\Filament\Resources\RestoreRunResource;
use App\Models\AuditLog;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Bus;
it('renders operate hub pages DB-only with no outbound HTTP and no queued jobs', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'provider.connection.check',
'status' => 'completed',
'outcome' => 'blocked',
'initiator_name' => 'System',
]);
$this->actingAs($user);
Bus::fake();
Filament::setTenant(null, true);
$session = [
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
];
assertNoOutboundHttp(function () use ($run, $session): void {
$this->withSession($session)
->get(route('admin.operations.index'))
->assertOk()
->assertSee('Scope: Workspace — all tenants');
$this->withSession($session)
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertOk()
->assertSee('Scope: Workspace — all tenants');
$this->withSession($session)
->get(route('admin.monitoring.alerts'))
->assertOk()
->assertSee('Scope: Workspace — all tenants');
$this->withSession($session)
->get(route('admin.monitoring.audit-log'))
->assertOk()
->assertSee('Scope: Workspace — all tenants');
});
Bus::assertNothingDispatched();
})->group('ops-ux');
it('shows back to tenant on run detail when tenant context is active and entitled', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'policy.sync',
'status' => 'queued',
'outcome' => 'pending',
]);
$this->actingAs($user);
Filament::setTenant($tenant, true);
$response = $this->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
])->get(route('admin.operations.view', ['run' => (int) $run->getKey()]));
$response
->assertOk()
->assertSee('← Back to '.$tenant->name)
->assertSee(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant), false)
->assertSee('Show all operations')
->assertDontSee('Back to Operations');
expect(substr_count((string) $response->getContent(), '← Back to '.$tenant->name))->toBe(1);
})->group('ops-ux');
it('shows back to tenant when filament tenant is absent but last tenant memory exists', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'policy.sync',
'status' => 'queued',
'outcome' => 'pending',
]);
$this->actingAs($user);
Filament::setTenant(null, true);
$workspaceId = (int) $tenant->workspace_id;
$lastTenantMap = [(string) $workspaceId => (int) $tenant->getKey()];
$response = $this->withSession([
WorkspaceContext::SESSION_KEY => $workspaceId,
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => $lastTenantMap,
])->get(route('admin.operations.view', ['run' => (int) $run->getKey()]));
$response
->assertOk()
->assertSee('← Back to '.$tenant->name)
->assertSee(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant), false)
->assertSee('Show all operations')
->assertDontSee('Back to Operations');
})->group('ops-ux');
it('shows no tenant return affordance when active and last tenant contexts are not entitled', function (): void {
[$user, $entitledTenant] = createUserWithTenant(role: 'owner');
$nonEntitledTenant = Tenant::factory()->create([
'workspace_id' => (int) $entitledTenant->workspace_id,
]);
$run = OperationRun::factory()->create([
'tenant_id' => (int) $entitledTenant->getKey(),
'workspace_id' => (int) $entitledTenant->workspace_id,
'type' => 'policy.sync',
'status' => 'queued',
'outcome' => 'pending',
]);
$this->actingAs($user);
Filament::setTenant($nonEntitledTenant, true);
$workspaceId = (int) $entitledTenant->workspace_id;
$response = $this->withSession([
WorkspaceContext::SESSION_KEY => $workspaceId,
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [(string) $workspaceId => (int) $nonEntitledTenant->getKey()],
])->get(route('admin.operations.view', ['run' => (int) $run->getKey()]));
$response
->assertOk()
->assertSee('Back to Operations')
->assertDontSee('← Back to '.$nonEntitledTenant->name)
->assertDontSee('Show all operations');
})->group('ops-ux');
it('returns 404 for non-member workspace access to /admin/operations', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get(route('admin.operations.index'))
->assertNotFound();
})->group('ops-ux');
it('returns 404 for non-entitled tenant dashboard direct access', function (): void {
$tenant = Tenant::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant))
->assertNotFound();
})->group('ops-ux');
it('keeps member-without-capability workflow start denial as 403 with no run side effects', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'operator');
$this->actingAs($user);
Filament::setTenant($tenant, true);
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(RestoreRunResource::getUrl('create', tenant: $tenant))
->assertForbidden();
expect(OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->exists())->toBeFalse();
})->group('ops-ux');
it('does not mutate workspace or last-tenant session memory on /admin/operations', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
Filament::setTenant($tenant, true);
$workspaceId = (int) $tenant->workspace_id;
$lastTenantMap = [(string) $workspaceId => (int) $tenant->getKey()];
$response = $this->withSession([
WorkspaceContext::SESSION_KEY => $workspaceId,
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => $lastTenantMap,
])->get(route('admin.operations.index'));
$response->assertOk();
$response->assertSessionHas(WorkspaceContext::SESSION_KEY, $workspaceId);
$response->assertSessionHas(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, $lastTenantMap);
})->group('ops-ux');
it('shows tenant scope label when tenant context is active', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
Filament::setTenant($tenant, true);
$this->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
])->get(route('admin.operations.index'))
->assertOk()
->assertSee('Scope: Tenant — '.$tenant->name)
->assertDontSee('Scope: Workspace — all tenants');
})->group('ops-ux');
it('does not create audit entries when viewing operate hub pages', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'policy.sync',
'status' => 'queued',
'outcome' => 'pending',
]);
$this->actingAs($user);
Filament::setTenant(null, true);
$before = (int) AuditLog::query()->count();
$session = [
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
];
$this->withSession($session)
->get(route('admin.operations.index'))
->assertOk();
$this->withSession($session)
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertOk();
$this->withSession($session)
->get(route('admin.monitoring.alerts'))
->assertOk();
$this->withSession($session)
->get(route('admin.monitoring.audit-log'))
->assertOk();
expect((int) AuditLog::query()->count())->toBe($before);
})->group('ops-ux');

View File

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
uses(RefreshDatabase::class);
beforeEach(function (): void {
Http::preventStrayRequests();
});
it('does not set or clear tenant context on GET /admin/operations', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
Filament::setTenant($tenant, true);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get('/admin/operations')
->assertOk();
expect(Filament::getTenant())->toBe($tenant);
});
it('renders workspace scope label when no tenant context is active', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
Filament::setTenant(null, true);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get('/admin/operations')
->assertOk()
->assertSee('Scope: Workspace — all tenants');
expect(Filament::getTenant())->toBeNull();
});

View File

@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\TenantDashboard;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
uses(RefreshDatabase::class);
beforeEach(function (): void {
Http::preventStrayRequests();
});
it('returns 404 for non-workspace-members on central operations index', function (): void {
$user = User::factory()->create();
session()->forget(WorkspaceContext::SESSION_KEY);
$this->actingAs($user)
->get('/admin/operations')
->assertNotFound();
});
it('returns 404 for non-workspace-members on central operation run detail', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
session()->forget(WorkspaceContext::SESSION_KEY);
$run = OperationRun::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => null,
'type' => 'provider.connection.check',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
]);
$this->actingAs($user)
->get("/admin/operations/{$run->getKey()}")
->assertNotFound();
});
it('returns 404 for non-entitled users on tenant dashboard direct access', function (): void {
$tenantA = Tenant::factory()->create();
[$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner');
$tenantB = Tenant::factory()->create([
'workspace_id' => (int) $tenantA->workspace_id,
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id])
->get(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenantB))
->assertNotFound();
});

View File

@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
uses(RefreshDatabase::class);
beforeEach(function (): void {
Http::preventStrayRequests();
});
it('renders tenant scope label and CTAs when tenant context is active and entitled', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
Filament::setTenant($tenant, true);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get('/admin/operations')
->assertOk()
->assertSee('Scope: Tenant — '.$tenant->name)
->assertSee('Back to '.$tenant->name)
->assertSee('Show all tenants');
});
it('treats stale tenant context as workspace-wide without tenant identity hints', function (): void {
$entitledTenant = Tenant::factory()->create();
[$user, $entitledTenant] = createUserWithTenant($entitledTenant, role: 'owner', workspaceRole: 'readonly');
$staleTenant = Tenant::factory()->create([
'workspace_id' => (int) $entitledTenant->workspace_id,
]);
Filament::setTenant($staleTenant, true);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $entitledTenant->workspace_id])
->get('/admin/operations')
->assertOk()
->assertSee('Scope: Workspace — all tenants')
->assertDontSee('Back to '.$staleTenant->name)
->assertDontSee($staleTenant->name)
->assertDontSee('Show all tenants');
});
it('clears filament tenant context and last-tenant session state via clear-tenant-context endpoint', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
$workspaceId = (int) $tenant->workspace_id;
$lastTenantIds = [
(string) $workspaceId => (int) $tenant->getKey(),
];
Filament::setTenant($tenant, true);
$this->actingAs($user)
->withSession([
WorkspaceContext::SESSION_KEY => $workspaceId,
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => $lastTenantIds,
])
->from('/admin/alerts')
->post('/admin/clear-tenant-context')
->assertRedirect('/admin/alerts');
expect(Filament::getTenant())->toBeNull();
expect(session()->get(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, []))
->not->toHaveKey((string) $workspaceId);
$this->withSession([
WorkspaceContext::SESSION_KEY => $workspaceId,
])
->get('/admin/operations')
->assertOk()
->assertSee('Scope: Workspace — all tenants')
->assertDontSee('Scope: Tenant — '.$tenant->name);
});

View File

@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
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 Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
uses(RefreshDatabase::class);
beforeEach(function (): void {
Http::preventStrayRequests();
});
it('shows back-to-tenant and show-all-operations when tenant context is active and entitled', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
$run = OperationRun::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'type' => 'provider.connection.check',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
]);
Filament::setTenant($tenant, true);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get("/admin/operations/{$run->getKey()}")
->assertOk()
->assertSee('← Back to '.$tenant->name)
->assertSee('Show all operations')
->assertDontSee('Back to Operations');
});
it('shows only back-to-operations when no tenant context is active', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
$run = OperationRun::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'type' => 'provider.connection.check',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
]);
Filament::setTenant(null, true);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get("/admin/operations/{$run->getKey()}")
->assertOk()
->assertSee('Back to Operations')
->assertDontSee('← Back to ')
->assertDontSee('Show all operations');
});
it('treats stale tenant context as workspace-wide on run detail', function (): void {
$entitledTenant = Tenant::factory()->create();
[$user, $entitledTenant] = createUserWithTenant($entitledTenant, role: 'owner', workspaceRole: 'readonly');
$staleTenant = Tenant::factory()->create([
'workspace_id' => (int) $entitledTenant->workspace_id,
]);
$run = OperationRun::factory()->create([
'workspace_id' => (int) $entitledTenant->workspace_id,
'tenant_id' => (int) $entitledTenant->getKey(),
'type' => 'provider.connection.check',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
]);
Filament::setTenant($staleTenant, true);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $entitledTenant->workspace_id])
->get("/admin/operations/{$run->getKey()}")
->assertOk()
->assertSee('Scope: Workspace — all tenants')
->assertSee('Back to Operations')
->assertDontSee('← Back to '.$staleTenant->name)
->assertDontSee($staleTenant->name)
->assertDontSee('Show all operations');
});

View File

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\TenantDashboard;
use App\Models\Tenant;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
uses(RefreshDatabase::class);
beforeEach(function (): void {
Http::preventStrayRequests();
});
it('shows a Monitoring group with central shortcuts in the tenant sidebar', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant))
->assertOk();
$panel = Filament::getCurrentOrDefaultPanel();
$monitoringLabels = collect($panel->getNavigationItems())
->filter(static fn ($item): bool => $item->getGroup() === 'Monitoring')
->map(static fn ($item): string => $item->getLabel())
->values()
->all();
expect($monitoringLabels)->toContain('Runs');
expect($monitoringLabels)->toContain('Alerts');
expect($monitoringLabels)->toContain('Audit Log');
expect($monitoringLabels)->not->toContain('Operations');
});