merge: spec 077 session work

This commit is contained in:
Ahmed Darrazi 2026-02-06 20:33:41 +01:00
commit 572201457d
48 changed files with 2330 additions and 276 deletions

View File

@ -7,6 +7,7 @@
use App\Models\Tenant;
use App\Models\User;
use App\Models\UserTenantPreference;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Filament\Pages\Page;
use Illuminate\Database\Eloquent\Collection;
@ -69,6 +70,8 @@ public function selectTenant(int $tenantId): void
$this->persistLastTenant($user, $tenant);
app(WorkspaceContext::class)->rememberLastTenantId((int) $tenant->workspace_id, (int) $tenant->getKey(), request());
$this->redirect(TenantDashboard::getUrl(tenant: $tenant));
}

View File

@ -8,6 +8,7 @@
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use App\Support\Workspaces\WorkspaceIntendedUrl;
use Filament\Actions\Action;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
@ -100,7 +101,9 @@ public function selectWorkspace(int $workspaceId): void
$context->setCurrentWorkspace($workspace, $user, request());
$this->redirect($this->redirectAfterWorkspaceSelected($user));
$intendedUrl = WorkspaceIntendedUrl::consume(request());
$this->redirect($intendedUrl ?: $this->redirectAfterWorkspaceSelected($user));
}
/**
@ -132,7 +135,9 @@ public function createWorkspace(array $data): void
->success()
->send();
$this->redirect($this->redirectAfterWorkspaceSelected($user));
$intendedUrl = WorkspaceIntendedUrl::consume(request());
$this->redirect($intendedUrl ?: $this->redirectAfterWorkspaceSelected($user));
}
private function redirectAfterWorkspaceSelected(User $user): string

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages\Monitoring;
use BackedEnum;
use Filament\Pages\Page;
use UnitEnum;
class Alerts extends Page
{
protected static bool $shouldRegisterNavigation = false;
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
protected static ?string $navigationLabel = 'Alerts';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-bell-alert';
protected static ?string $slug = 'alerts';
protected static ?string $title = 'Alerts';
protected string $view = 'filament.pages.monitoring.alerts';
}

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages\Monitoring;
use BackedEnum;
use Filament\Pages\Page;
use UnitEnum;
class AuditLog extends Page
{
protected static bool $shouldRegisterNavigation = false;
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
protected static ?string $navigationLabel = 'Audit Log';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clipboard-document-list';
protected static ?string $slug = 'audit-log';
protected static ?string $title = 'Audit Log';
protected string $view = 'filament.pages.monitoring.audit-log';
}

View File

@ -1,22 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages\Monitoring;
use App\Filament\Resources\OperationRunResource;
use App\Filament\Widgets\Operations\OperationsKpiHeader;
use App\Models\OperationRun;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\OperationCatalog;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Facades\Filament;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Pages\Page;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use UnitEnum;
@ -26,6 +25,8 @@ class Operations extends Page implements HasForms, HasTable
use InteractsWithForms;
use InteractsWithTable;
public string $activeTab = 'all';
protected static bool $isDiscovered = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-queue-list';
@ -37,89 +38,62 @@ class Operations extends Page implements HasForms, HasTable
// Must be non-static
protected string $view = 'filament.pages.monitoring.operations';
public function mount(): void
{
$this->mountInteractsWithTable();
}
protected function getHeaderWidgets(): array
{
return [
OperationsKpiHeader::class,
];
}
public function updatedActiveTab(): void
{
$this->resetPage();
}
public function table(Table $table): Table
{
return $table
->query(
OperationRun::query()
->where('tenant_id', Filament::getTenant()->id)
->latest('created_at')
)
->columns([
TextColumn::make('type')
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
->searchable()
->sortable(),
return OperationRunResource::table($table)
->query(function (): Builder {
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
TextColumn::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
$query = OperationRun::query()
->with('user')
->latest('id')
->when(
$workspaceId,
fn (Builder $query): Builder => $query->where('workspace_id', (int) $workspaceId),
)
->when(
! $workspaceId,
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
);
TextColumn::make('outcome')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
return $this->applyActiveTab($query);
});
}
TextColumn::make('initiator_name')
->label('Initiator')
->searchable(),
TextColumn::make('created_at')
->dateTime()
->sortable()
->label('Started'),
TextColumn::make('duration')
->getStateUsing(function (OperationRun $record) {
if ($record->started_at && $record->completed_at) {
return $record->completed_at->diffForHumans($record->started_at, true);
}
return '-';
}),
])
->filters([
SelectFilter::make('outcome')
->options([
'succeeded' => 'Succeeded',
'partially_succeeded' => 'Partially Succeeded',
'failed' => 'Failed',
'cancelled' => 'Cancelled',
'pending' => 'Pending',
]),
SelectFilter::make('type')
->options(
fn () => OperationRun::where('tenant_id', Filament::getTenant()->id)
->distinct()
->pluck('type', 'type')
->toArray()
),
Filter::make('created_at')
->form([
DatePicker::make('created_from'),
DatePicker::make('created_until'),
])
->query(function (Builder $query, array $data): Builder {
return $query
->when(
$data['created_from'],
fn (Builder $query, $date) => $query->whereDate('created_at', '>=', $date),
)
->when(
$data['created_until'],
fn (Builder $query, $date) => $query->whereDate('created_at', '<=', $date),
);
}),
])
->actions([
// View action handled by opening a modal or side-peek
]);
private function applyActiveTab(Builder $query): Builder
{
return match ($this->activeTab) {
'active' => $query->whereIn('status', [
OperationRunStatus::Queued->value,
OperationRunStatus::Running->value,
]),
'succeeded' => $query
->where('status', OperationRunStatus::Completed->value)
->where('outcome', OperationRunOutcome::Succeeded->value),
'partial' => $query
->where('status', OperationRunStatus::Completed->value)
->where('outcome', OperationRunOutcome::PartiallySucceeded->value),
'failed' => $query
->where('status', OperationRunStatus::Completed->value)
->where('outcome', OperationRunOutcome::Failed->value),
default => $query,
};
}
}

View File

@ -17,8 +17,10 @@
use App\Support\OperationRunStatus;
use App\Support\OpsUx\RunDetailPolling;
use App\Support\OpsUx\RunDurationInsights;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions;
use Filament\Facades\Filament;
use Filament\Forms\Components\DatePicker;
use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry;
@ -38,6 +40,8 @@ class OperationRunResource extends Resource
protected static ?string $slug = 'operations';
protected static bool $shouldRegisterNavigation = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-queue-list';
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
@ -46,12 +50,13 @@ class OperationRunResource extends Resource
public static function getEloquentQuery(): Builder
{
$tenantId = Tenant::current()?->getKey();
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
return parent::getEloquentQuery()
->with('user')
->latest('id')
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId));
->when($workspaceId, fn (Builder $query) => $query->where('workspace_id', (int) $workspaceId))
->when(! $workspaceId, fn (Builder $query) => $query->whereRaw('1 = 0'));
}
public static function form(Schema $schema): Schema
@ -156,7 +161,7 @@ public static function infolist(Schema $schema): Schema
$previousRunUrl = null;
if ($changeIndicator !== null) {
$tenant = Tenant::current();
$tenant = Filament::getTenant();
$previousRunUrl = $tenant instanceof Tenant
? OperationRunLinks::view($changeIndicator['previous_report_id'], $tenant)
@ -272,16 +277,47 @@ public static function table(Table $table): Table
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
])
->filters([
Tables\Filters\SelectFilter::make('tenant_id')
->label('Tenant')
->options(function (): array {
$user = auth()->user();
if (! $user instanceof User) {
return [];
}
return collect($user->getTenants(Filament::getCurrentOrDefaultPanel()))
->mapWithKeys(static fn (Tenant $tenant): array => [
(string) $tenant->getKey() => $tenant->getFilamentName(),
])
->all();
})
->default(function (): ?string {
$tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) {
return null;
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
if ($workspaceId === null || (int) $tenant->workspace_id !== (int) $workspaceId) {
return null;
}
return (string) $tenant->getKey();
})
->searchable(),
Tables\Filters\SelectFilter::make('type')
->options(function (): array {
$tenantId = Tenant::current()?->getKey();
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
if (! $tenantId) {
if ($workspaceId === null) {
return [];
}
return OperationRun::query()
->where('tenant_id', $tenantId)
->where('workspace_id', (int) $workspaceId)
->select('type')
->distinct()
->orderBy('type')
@ -299,14 +335,20 @@ public static function table(Table $table): Table
Tables\Filters\SelectFilter::make('initiator_name')
->label('Initiator')
->options(function (): array {
$tenantId = Tenant::current()?->getKey();
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
if (! $tenantId) {
if ($workspaceId === null) {
return [];
}
$tenant = Filament::getTenant();
$tenantId = $tenant instanceof Tenant && (int) $tenant->workspace_id === (int) $workspaceId
? (int) $tenant->getKey()
: null;
return OperationRun::query()
->where('tenant_id', $tenantId)
->where('workspace_id', (int) $workspaceId)
->when($tenantId, fn (Builder $query): Builder => $query->where('tenant_id', $tenantId))
->whereNotNull('initiator_name')
->select('initiator_name')
->distinct()
@ -342,7 +384,8 @@ public static function table(Table $table): Table
}),
])
->actions([
Actions\ViewAction::make(),
Actions\ViewAction::make()
->url(fn (OperationRun $record): string => route('admin.operations.view', ['run' => (int) $record->getKey()])),
])
->bulkActions([]);
}
@ -351,7 +394,7 @@ public static function getPages(): array
{
return [
'index' => Pages\ListOperationRuns::route('/'),
'view' => Pages\ViewOperationRun::route('/{record}'),
'view' => Pages\ViewOperationRun::route('/r/{record}'),
];
}

View File

@ -3,6 +3,7 @@
namespace App\Filament\Resources\TenantResource\Pages;
use App\Filament\Resources\TenantResource;
use App\Filament\Widgets\Tenant\RecentOperationsSummary;
use App\Filament\Widgets\Tenant\TenantArchivedBanner;
use App\Models\Tenant;
use App\Services\Intune\AuditLogger;
@ -23,6 +24,7 @@ protected function getHeaderWidgets(): array
{
return [
TenantArchivedBanner::class,
RecentOperationsSummary::class,
];
}

View File

@ -3,6 +3,7 @@
namespace App\Filament\Resources\Workspaces;
use App\Filament\Resources\Workspaces\RelationManagers\WorkspaceMembershipsRelationManager;
use App\Models\User;
use App\Models\Workspace;
use BackedEnum;
use Filament\Actions;
@ -11,6 +12,7 @@
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use UnitEnum;
class WorkspaceResource extends Resource
@ -25,10 +27,31 @@ class WorkspaceResource extends Resource
protected static bool $shouldRegisterNavigation = false;
protected static ?string $breadcrumb = 'Manage workspaces';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-squares-2x2';
protected static string|UnitEnum|null $navigationGroup = 'Settings';
public static function getEloquentQuery(): Builder
{
$query = parent::getEloquentQuery();
$user = auth()->user();
if (! $user instanceof User) {
return $query->whereRaw('1 = 0');
}
return $query
->whereNull('archived_at')
->whereIn('id', function ($subQuery) use ($user): void {
$subQuery->from('workspace_memberships')
->select('workspace_id')
->where('user_id', $user->getKey());
});
}
public static function form(Schema $schema): Schema
{
return $schema

View File

@ -5,7 +5,6 @@
namespace App\Filament\Widgets\Dashboard;
use App\Filament\Resources\FindingResource;
use App\Filament\Resources\OperationRunResource;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\Tenant;
@ -81,10 +80,10 @@ protected function getStats(): array
->url(FindingResource::getUrl('index', tenant: $tenant)),
Stat::make('Active operations', $activeRuns)
->color($activeRuns > 0 ? 'warning' : 'gray')
->url(OperationRunResource::getUrl('index', tenant: $tenant)),
->url(route('admin.operations.index')),
Stat::make('Inventory active', $inventoryActiveRuns)
->color($inventoryActiveRuns > 0 ? 'warning' : 'gray')
->url(OperationRunResource::getUrl('index', tenant: $tenant)),
->url(route('admin.operations.index')),
];
}
}

View File

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Filament\Widgets\Tenant;
use App\Models\OperationRun;
use App\Models\Tenant;
use Filament\Facades\Filament;
use Filament\Widgets\Widget;
use Illuminate\Database\Eloquent\Collection;
class RecentOperationsSummary extends Widget
{
protected static bool $isLazy = false;
protected string $view = 'filament.widgets.tenant.recent-operations-summary';
/**
* @return array<string, mixed>
*/
protected function getViewData(): array
{
$tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) {
return [
'tenant' => null,
'runs' => collect(),
'operationsIndexUrl' => route('admin.operations.index'),
];
}
/** @var Collection<int, OperationRun> $runs */
$runs = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->orderByDesc('created_at')
->orderByDesc('id')
->limit(5)
->get([
'id',
'type',
'status',
'outcome',
'created_at',
'started_at',
'completed_at',
]);
return [
'tenant' => $tenant,
'runs' => $runs,
'operationsIndexUrl' => route('admin.operations.index'),
];
}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
final class ClearTenantContextController
{
public function __invoke(Request $request): RedirectResponse
{
Filament::setTenant(null, true);
app(WorkspaceContext::class)->clearLastTenantId($request);
return redirect()->to('/admin/operations');
}
}

View File

@ -49,6 +49,8 @@ public function __invoke(Request $request): RedirectResponse
$this->persistLastTenant($user, $tenant);
app(WorkspaceContext::class)->rememberLastTenantId((int) $workspaceId, (int) $tenant->getKey(), $request);
return redirect()->to(TenantDashboard::getUrl(tenant: $tenant));
}

View File

@ -9,6 +9,7 @@
use App\Models\User;
use App\Models\Workspace;
use App\Support\Workspaces\WorkspaceContext;
use App\Support\Workspaces\WorkspaceIntendedUrl;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@ -44,6 +45,12 @@ public function __invoke(Request $request): RedirectResponse
$context->setCurrentWorkspace($workspace, $user, $request);
$intendedUrl = WorkspaceIntendedUrl::consume($request);
if ($intendedUrl !== null) {
return redirect()->to($intendedUrl);
}
$tenantsQuery = $user->tenants()
->where('workspace_id', $workspace->getKey())
->where('status', 'active');

View File

@ -5,6 +5,7 @@
use App\Models\User;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use App\Support\Workspaces\WorkspaceIntendedUrl;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Http\Response as HttpResponse;
@ -75,6 +76,10 @@ public function handle(Request $request, Closure $next): Response
$target = $hasAnyActiveMembership ? '/admin/choose-workspace' : '/admin/no-access';
if ($target === '/admin/choose-workspace') {
WorkspaceIntendedUrl::storeFromRequest($request);
}
return new HttpResponse('', 302, ['Location' => $target]);
}
}

View File

@ -6,46 +6,77 @@
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Auth\WorkspaceRoleCapabilityMap;
use App\Support\Auth\Capabilities;
use Illuminate\Auth\Access\Response;
class WorkspacePolicy
{
/**
* Determine whether the user can view any models.
*/
public function viewAny(User $user): bool
public function viewAny(User $user): bool|Response
{
return true;
$isMember = WorkspaceMembership::query()
->where('user_id', $user->getKey())
->exists();
return $isMember ? Response::allow() : Response::denyAsNotFound();
}
/**
* Determine whether the user can view the model.
*/
public function view(User $user, Workspace $workspace): bool
public function view(User $user, Workspace $workspace): bool|Response
{
return WorkspaceMembership::query()
$isMember = WorkspaceMembership::query()
->where('user_id', $user->getKey())
->where('workspace_id', $workspace->getKey())
->exists();
return $isMember ? Response::allow() : Response::denyAsNotFound();
}
/**
* Determine whether the user can create models.
*/
public function create(User $user): bool
public function create(User $user): bool|Response
{
return true;
$hasAnyMembership = WorkspaceMembership::query()
->where('user_id', $user->getKey())
->exists();
if (! $hasAnyMembership) {
return Response::denyAsNotFound();
}
$rolesWithManageCapability = WorkspaceRoleCapabilityMap::rolesWithCapability(Capabilities::WORKSPACE_MANAGE);
$canManageAnyWorkspace = WorkspaceMembership::query()
->where('user_id', $user->getKey())
->whereIn('role', $rolesWithManageCapability)
->exists();
return $canManageAnyWorkspace
? Response::allow()
: Response::deny();
}
/**
* Determine whether the user can update the model.
*/
public function update(User $user, Workspace $workspace): bool
public function update(User $user, Workspace $workspace): bool|Response
{
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
return $resolver->can($user, $workspace, Capabilities::WORKSPACE_MANAGE);
if (! $resolver->isMember($user, $workspace)) {
return Response::denyAsNotFound();
}
return $resolver->can($user, $workspace, Capabilities::WORKSPACE_MANAGE)
? Response::allow()
: Response::deny();
}
/**

View File

@ -9,6 +9,10 @@
use App\Filament\Pages\TenantDashboard;
use App\Filament\Resources\Workspaces\WorkspaceResource;
use App\Models\Tenant;
use App\Models\User;
use App\Models\WorkspaceMembership;
use App\Services\Auth\WorkspaceRoleCapabilityMap;
use App\Support\Auth\Capabilities;
use App\Support\Middleware\DenyNonMemberTenantAccess;
use Filament\Facades\Filament;
use Filament\Http\Middleware\Authenticate;
@ -53,18 +57,56 @@ public function panel(Panel $panel): Panel
'primary' => Color::Amber,
])
->navigationItems([
NavigationItem::make('Workspaces')
NavigationItem::make('Switch workspace')
->url(fn (): string => ChooseWorkspace::getUrl())
->icon('heroicon-o-squares-2x2')
->group('Settings')
->sort(10),
NavigationItem::make('Manage workspaces')
->url(function (): string {
return route('filament.admin.resources.workspaces.index');
})
->icon('heroicon-o-squares-2x2')
->group('Settings')
->sort(20)
->visible(function (): bool {
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
$roles = WorkspaceRoleCapabilityMap::rolesWithCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE);
return WorkspaceMembership::query()
->where('user_id', (int) $user->getKey())
->whereIn('role', $roles)
->exists();
}),
NavigationItem::make('Operations')
->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()
)
->renderHook(
PanelsRenderHook::TOPBAR_START,
fn () => view('filament.partials.context-bar')->render()
)
->renderHook(
PanelsRenderHook::USER_MENU_PROFILE_AFTER,
fn () => view('filament.partials.workspace-switcher')->render()

View File

@ -135,9 +135,28 @@ public function compare(
$canPersist = $persist;
if ($liveCheckMeta['attempted'] === true && $liveCheckMeta['succeeded'] === false) {
if ($canPersist && $liveCheckMeta['attempted'] === true && $liveCheckMeta['succeeded'] === false) {
// Enterprise-safe: never overwrite stored inventory when we could not refresh it.
$canPersist = false;
// When the failure is a deterministic misconfiguration (e.g. permission denied), persist an "error" snapshot
// only if we have no stored inventory yet, so the UI can explain the failure.
$reasonCode = is_string($liveCheckMeta['reason_code'] ?? null)
? (string) $liveCheckMeta['reason_code']
: null;
$shouldPersistErrorSnapshot = in_array($reasonCode, [
'authentication_failed',
'permission_denied',
], true);
if (! $shouldPersistErrorSnapshot) {
$canPersist = false;
} else {
$hasStoredStatuses = TenantPermission::query()
->where('tenant_id', $tenant->id)
->exists();
$canPersist = ! $hasStoredStatuses;
}
}
foreach ($required as $permission) {

View File

@ -6,7 +6,9 @@
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\CapabilityResolver;
use App\Models\WorkspaceMembership;
use App\Services\Auth\WorkspaceRoleCapabilityMap;
use App\Support\Auth\Capabilities;
use App\Support\Workspaces\WorkspaceContext;
use Closure;
use Filament\Facades\Filament;
@ -27,6 +29,14 @@ public function handle(Request $request, Closure $next): Response
$path = '/'.ltrim($request->path(), '/');
$workspaceContext = app(WorkspaceContext::class);
$workspaceId = $workspaceContext->currentWorkspaceId($request);
$existingTenant = Filament::getTenant();
if ($existingTenant instanceof Tenant && $workspaceId !== null && (int) $existingTenant->workspace_id !== (int) $workspaceId) {
Filament::setTenant(null, true);
}
if ($path === '/livewire/update') {
$refererPath = parse_url((string) $request->headers->get('referer', ''), PHP_URL_PATH) ?? '';
$refererPath = '/'.ltrim((string) $refererPath, '/');
@ -44,6 +54,12 @@ public function handle(Request $request, Closure $next): Response
return $next($request);
}
if ($path === '/admin/operations') {
$this->configureNavigationForRequest($panel);
return $next($request);
}
if ($request->route()?->hasParameter('tenant')) {
$user = $request->user();
@ -66,9 +82,6 @@ public function handle(Request $request, Closure $next): Response
abort(404);
}
$workspaceContext = app(WorkspaceContext::class);
$workspaceId = $workspaceContext->currentWorkspaceId($request);
if ($workspaceId === null) {
abort(404);
}
@ -92,6 +105,9 @@ public function handle(Request $request, Closure $next): Response
}
Filament::setTenant($tenant, true);
app(WorkspaceContext::class)->rememberLastTenantId((int) $workspaceId, (int) $tenant->getKey(), $request);
$this->configureNavigationForRequest($panel);
return $next($request);
@ -100,7 +116,8 @@ public function handle(Request $request, Closure $next): Response
if (
str_starts_with($path, '/admin/w/')
|| str_starts_with($path, '/admin/workspaces')
|| in_array($path, ['/admin/choose-workspace', '/admin/choose-tenant', '/admin/no-access'], true)
|| str_starts_with($path, '/admin/operations')
|| in_array($path, ['/admin/choose-workspace', '/admin/choose-tenant', '/admin/no-access', '/admin/alerts', '/admin/audit-log', '/admin/onboarding'], true)
) {
$this->configureNavigationForRequest($panel);
@ -121,60 +138,6 @@ public function handle(Request $request, Closure $next): Response
return $next($request);
}
$tenant = null;
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId($request);
if ($workspaceId !== null) {
$tenant = $user->tenants()
->where('workspace_id', $workspaceId)
->where('status', 'active')
->first();
if (! $tenant) {
$tenant = $user->tenants()
->where('workspace_id', $workspaceId)
->first();
}
if (! $tenant) {
$tenant = $user->tenants()
->withTrashed()
->where('workspace_id', $workspaceId)
->first();
}
}
if (! $tenant) {
try {
$tenant = Tenant::current();
} catch (\RuntimeException) {
$tenant = null;
}
if ($tenant instanceof Tenant && ! app(CapabilityResolver::class)->isMember($user, $tenant)) {
$tenant = null;
}
}
if (! $tenant) {
$tenant = $user->tenants()
->where('status', 'active')
->first();
}
if (! $tenant) {
$tenant = $user->tenants()->first();
}
if (! $tenant) {
$tenant = $user->tenants()->withTrashed()->first();
}
if ($tenant) {
Filament::setTenant($tenant, true);
}
$this->configureNavigationForRequest($panel);
return $next($request);
@ -195,11 +158,53 @@ private function configureNavigationForRequest(\Filament\Panel $panel): void
$panel->navigation(function (): NavigationBuilder {
return app(NavigationBuilder::class)
->item(
NavigationItem::make('Workspaces')
NavigationItem::make('Switch workspace')
->url(fn (): string => ChooseWorkspace::getUrl())
->icon('heroicon-o-squares-2x2')
->group('Settings')
->sort(10),
)
->item(
NavigationItem::make('Manage workspaces')
->url(fn (): string => route('filament.admin.resources.workspaces.index'))
->icon('heroicon-o-squares-2x2')
->group('Settings')
->sort(20)
->visible(function (): bool {
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
$roles = WorkspaceRoleCapabilityMap::rolesWithCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE);
return WorkspaceMembership::query()
->where('user_id', (int) $user->getKey())
->whereIn('role', $roles)
->exists();
}),
)
->item(
NavigationItem::make('Operations')
->url(fn (): string => route('admin.operations.index'))
->icon('heroicon-o-queue-list')
->group('Monitoring')
->sort(10),
)
->item(
NavigationItem::make('Alerts')
->url(fn (): string => '/admin/alerts')
->icon('heroicon-o-bell-alert')
->group('Monitoring')
->sort(20),
)
->item(
NavigationItem::make('Audit Log')
->url(fn (): string => '/admin/audit-log')
->icon('heroicon-o-clipboard-document-list')
->group('Monitoring')
->sort(30),
);
});
}

View File

@ -7,7 +7,6 @@
use App\Filament\Resources\BackupScheduleResource;
use App\Filament\Resources\BackupSetResource;
use App\Filament\Resources\EntraGroupResource;
use App\Filament\Resources\OperationRunResource;
use App\Filament\Resources\PolicyResource;
use App\Filament\Resources\ProviderConnectionResource;
use App\Filament\Resources\RestoreRunResource;
@ -18,7 +17,7 @@ final class OperationRunLinks
{
public static function index(Tenant $tenant): string
{
return OperationRunResource::getUrl('index', tenant: $tenant);
return route('admin.operations.index');
}
public static function tenantlessView(OperationRun|int $run): string
@ -30,7 +29,7 @@ public static function tenantlessView(OperationRun|int $run): string
public static function view(OperationRun|int $run, Tenant $tenant): string
{
return OperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant);
return self::tenantlessView($run);
}
/**

View File

@ -11,6 +11,10 @@ final class WorkspaceContext
{
public const SESSION_KEY = 'current_workspace_id';
public const INTENDED_URL_SESSION_KEY = 'workspace_intended_url';
public const LAST_TENANT_IDS_SESSION_KEY = 'workspace_last_tenant_ids';
public function __construct(private WorkspaceResolver $resolver) {}
public function currentWorkspaceId(?Request $request = null): ?int
@ -53,6 +57,54 @@ public function setCurrentWorkspace(Workspace $workspace, ?User $user = null, ?R
}
}
public function rememberLastTenantId(int $workspaceId, int $tenantId, ?Request $request = null): void
{
$session = ($request && $request->hasSession()) ? $request->session() : session();
$map = $session->get(self::LAST_TENANT_IDS_SESSION_KEY, []);
$map = is_array($map) ? $map : [];
$map[(string) $workspaceId] = $tenantId;
$session->put(self::LAST_TENANT_IDS_SESSION_KEY, $map);
}
public function lastTenantId(?Request $request = null): ?int
{
$workspaceId = $this->currentWorkspaceId($request);
if ($workspaceId === null) {
return null;
}
$session = ($request && $request->hasSession()) ? $request->session() : session();
$map = $session->get(self::LAST_TENANT_IDS_SESSION_KEY, []);
$map = is_array($map) ? $map : [];
$id = $map[(string) $workspaceId] ?? null;
return is_int($id) ? $id : (is_numeric($id) ? (int) $id : null);
}
public function clearLastTenantId(?Request $request = null): void
{
$workspaceId = $this->currentWorkspaceId($request);
if ($workspaceId === null) {
return;
}
$session = ($request && $request->hasSession()) ? $request->session() : session();
$map = $session->get(self::LAST_TENANT_IDS_SESSION_KEY, []);
$map = is_array($map) ? $map : [];
unset($map[(string) $workspaceId]);
$session->put(self::LAST_TENANT_IDS_SESSION_KEY, $map);
}
public function clearCurrentWorkspace(?User $user = null, ?Request $request = null): void
{
$session = ($request && $request->hasSession()) ? $request->session() : session();

View File

@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace App\Support\Workspaces;
use Illuminate\Http\Request;
use Illuminate\Session\Store;
final class WorkspaceIntendedUrl
{
/**
* Store a safe intended URL (path + query) for returning after workspace selection.
*/
public static function store(string $pathWithQuery, ?Request $request = null): void
{
$pathWithQuery = trim($pathWithQuery);
if ($pathWithQuery === '') {
return;
}
if (! self::isAllowed($pathWithQuery)) {
return;
}
$session = self::session($request);
if (! $session instanceof Store) {
return;
}
$session->put(WorkspaceContext::INTENDED_URL_SESSION_KEY, $pathWithQuery);
}
/**
* Store the intended URL derived from the current request.
*/
public static function storeFromRequest(Request $request): void
{
if (! $request->isMethod('GET')) {
return;
}
$path = '/'.ltrim($request->path(), '/');
$queryString = $request->getQueryString();
$pathWithQuery = $queryString ? "{$path}?{$queryString}" : $path;
self::store($pathWithQuery, $request);
}
/**
* Consume (read + forget) the intended URL. Returns null if missing or unsafe.
*/
public static function consume(?Request $request = null): ?string
{
$session = self::session($request);
if (! $session instanceof Store) {
return null;
}
$value = $session->pull(WorkspaceContext::INTENDED_URL_SESSION_KEY);
if (! is_string($value)) {
return null;
}
$value = trim($value);
if ($value === '' || ! self::isAllowed($value)) {
return null;
}
return $value;
}
public static function clear(?Request $request = null): void
{
$session = self::session($request);
if (! $session instanceof Store) {
return;
}
$session->forget(WorkspaceContext::INTENDED_URL_SESSION_KEY);
}
private static function session(?Request $request = null): ?Store
{
$session = ($request && $request->hasSession())
? $request->session()
: session()->driver();
return $session instanceof Store ? $session : null;
}
private static function isAllowed(string $pathWithQuery): bool
{
if (str_contains($pathWithQuery, "\n") || str_contains($pathWithQuery, "\r")) {
return false;
}
if (preg_match('#^https?://#i', $pathWithQuery) === 1) {
return false;
}
if (str_starts_with($pathWithQuery, '//')) {
return false;
}
if (! str_starts_with($pathWithQuery, '/admin')) {
return false;
}
$path = parse_url($pathWithQuery, PHP_URL_PATH);
$path = '/'.ltrim((string) ($path ?? ''), '/');
if (in_array($path, ['/admin/choose-workspace', '/admin/no-access'], true)) {
return false;
}
return true;
}
}

View File

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

View File

@ -0,0 +1,6 @@
<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>

View File

@ -1,3 +1,36 @@
<x-filament-panels::page>
<x-filament::tabs label="Operations tabs">
<x-filament::tabs.item
:active="$this->activeTab === 'all'"
wire:click="$set('activeTab', 'all')"
>
All
</x-filament::tabs.item>
<x-filament::tabs.item
:active="$this->activeTab === 'active'"
wire:click="$set('activeTab', 'active')"
>
Active
</x-filament::tabs.item>
<x-filament::tabs.item
:active="$this->activeTab === 'succeeded'"
wire:click="$set('activeTab', 'succeeded')"
>
Succeeded
</x-filament::tabs.item>
<x-filament::tabs.item
:active="$this->activeTab === 'partial'"
wire:click="$set('activeTab', 'partial')"
>
Partial
</x-filament::tabs.item>
<x-filament::tabs.item
:active="$this->activeTab === 'failed'"
wire:click="$set('activeTab', 'failed')"
>
Failed
</x-filament::tabs.item>
</x-filament::tabs>
{{ $this->table }}
</x-filament-panels::page>

View File

@ -0,0 +1,102 @@
@php
use App\Filament\Pages\ChooseWorkspace;
use App\Models\Tenant;
use App\Models\User;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
/** @var WorkspaceContext $workspaceContext */
$workspaceContext = app(WorkspaceContext::class);
$workspace = $workspaceContext->currentWorkspace(request());
$user = auth()->user();
$tenants = collect();
if ($user instanceof User) {
$tenants = collect($user->getTenants(Filament::getCurrentOrDefaultPanel()));
}
$currentTenant = Filament::getTenant();
$currentTenantName = $currentTenant instanceof Tenant ? $currentTenant->getFilamentName() : null;
$lastTenantId = $workspaceContext->lastTenantId(request());
$canClearTenantContext = $currentTenantName !== null || $lastTenantId !== null;
@endphp
<div class="flex items-center gap-3">
<a
href="{{ ChooseWorkspace::getUrl() }}"
class="text-sm text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-gray-100"
>
<span class="font-medium">Workspace:</span>
<span>{{ $workspace?->name ?? '—' }}</span>
</a>
<div class="h-4 w-px bg-gray-200 dark:bg-gray-700"></div>
<x-filament::dropdown placement="bottom-start" teleport>
<x-slot name="trigger">
<button
type="button"
class="text-sm text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-gray-100"
>
<span class="font-medium">Tenant:</span>
<span>{{ $currentTenantName ?? '—' }}</span>
</button>
</x-slot>
<x-filament::dropdown.list>
<div class="px-3 py-2 space-y-2" x-data="{ query: '' }">
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">Tenant context</div>
@if (! $workspace)
<div class="text-xs text-gray-500 dark:text-gray-400">Choose a workspace first.</div>
@elseif ($tenants->isEmpty())
<div class="text-xs text-gray-500 dark:text-gray-400">No tenants you can access in this workspace.</div>
@else
<div class="space-y-2">
<input
type="text"
class="fi-input fi-text-input w-full"
placeholder="Select tenant…"
x-model="query"
/>
<div class="max-h-64 overflow-auto rounded-lg border border-gray-200 dark:border-gray-700">
@foreach ($tenants as $tenant)
<form method="POST" action="{{ route('admin.select-tenant') }}">
@csrf
<input type="hidden" name="tenant_id" value="{{ (int) $tenant->getKey() }}" />
<button
type="submit"
class="w-full px-3 py-2 text-left text-sm hover:bg-gray-50 dark:hover:bg-gray-800"
data-search="{{ (string) str($tenant->getFilamentName())->lower() }}"
x-show="query === '' || ($el.dataset.search ?? '').includes(query.toLowerCase())"
>
{{ $tenant->getFilamentName() }}
</button>
</form>
@endforeach
</div>
@if ($canClearTenantContext)
<form method="POST" action="{{ route('admin.clear-tenant-context') }}">
@csrf
<x-filament::button color="gray" size="sm" outlined>
Clear tenant context
</x-filament::button>
</form>
@endif
<div class="text-xs text-gray-500 dark:text-gray-400">
Switching tenants is explicit. Canonical monitoring URLs do not change tenant context.
</div>
</div>
@endif
</div>
</x-filament::dropdown.list>
</x-filament::dropdown>
</div>

View File

@ -25,7 +25,7 @@
<form method="POST" action="{{ route('admin.switch-workspace') }}" class="space-y-2">
@csrf
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">Workspace</div>
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">Switch workspace</div>
<select
name="workspace_id"
@ -40,7 +40,7 @@ class="fi-input fi-select w-full"
@endforeach
</select>
<div class="text-xs text-gray-500 dark:text-gray-400">Switch workspace</div>
<div class="text-xs text-gray-500 dark:text-gray-400">Select a workspace to switch context.</div>
</form>
</div>
</x-filament::dropdown.list>

View File

@ -0,0 +1,55 @@
@php
/** @var ?\App\Models\Tenant $tenant */
/** @var \Illuminate\Support\Collection<int, \App\Models\OperationRun> $runs */
/** @var string $operationsIndexUrl */
@endphp
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
<div class="flex items-center justify-between gap-3">
<div class="text-sm font-semibold">Recent operations</div>
<a
href="{{ $operationsIndexUrl }}"
class="text-sm font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
>
View all operations
</a>
</div>
@if ($runs->isEmpty())
<div class="mt-3 text-sm text-gray-500 dark:text-gray-400">
No operations yet.
</div>
@else
<ul class="mt-3 divide-y divide-gray-100 dark:divide-gray-800">
@foreach ($runs as $run)
<li class="flex items-center justify-between gap-3 py-2">
<div class="min-w-0">
<div class="truncate text-sm font-medium">
{{ \App\Support\OperationCatalog::label((string) $run->type) }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ $run->created_at?->diffForHumans() ?? '—' }}
</div>
</div>
<div class="flex shrink-0 items-center gap-3">
<div class="text-right text-xs text-gray-600 dark:text-gray-300">
<div>{{ (string) $run->status }}</div>
<div>{{ (string) $run->outcome }}</div>
</div>
<a
href="{{ \App\Support\OperationRunLinks::tenantlessView($run) }}"
class="text-sm font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
>
View
</a>
</div>
</li>
@endforeach
</ul>
@endif
</div>

View File

@ -3,6 +3,7 @@
use App\Filament\Pages\TenantDashboard;
use App\Http\Controllers\AdminConsentCallbackController;
use App\Http\Controllers\Auth\EntraController;
use App\Http\Controllers\ClearTenantContextController;
use App\Http\Controllers\RbacDelegatedAuthController;
use App\Http\Controllers\SelectTenantController;
use App\Http\Controllers\SwitchWorkspaceController;
@ -101,6 +102,10 @@
Route::middleware(['web', 'auth', 'ensure-correct-guard:web', 'ensure-workspace-selected'])
->post('/admin/select-tenant', SelectTenantController::class)
->name('admin.select-tenant');
Route::middleware(['web', 'auth', 'ensure-correct-guard:web', 'ensure-workspace-selected'])
->post('/admin/clear-tenant-context', ClearTenantContextController::class)
->name('admin.clear-tenant-context');
Route::bind('workspace', function (string $value): Workspace {
/** @var WorkspaceResolver $resolver */
$resolver = app(WorkspaceResolver::class);
@ -132,6 +137,48 @@
->get('/admin/onboarding', \App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class)
->name('admin.onboarding');
Route::middleware([
'web',
'panel:admin',
'ensure-correct-guard:web',
DenyNonMemberTenantAccess::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
FilamentAuthenticate::class,
'ensure-workspace-selected',
'ensure-filament-tenant-selected',
])
->get('/admin/operations', \App\Filament\Pages\Monitoring\Operations::class)
->name('admin.operations.index');
Route::middleware([
'web',
'panel:admin',
'ensure-correct-guard:web',
DenyNonMemberTenantAccess::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
FilamentAuthenticate::class,
'ensure-workspace-selected',
'ensure-filament-tenant-selected',
])
->get('/admin/alerts', \App\Filament\Pages\Monitoring\Alerts::class)
->name('admin.monitoring.alerts');
Route::middleware([
'web',
'panel:admin',
'ensure-correct-guard:web',
DenyNonMemberTenantAccess::class,
DisableBladeIconComponents::class,
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');
Route::middleware([
'web',
'panel:admin',

View File

@ -0,0 +1,35 @@
# Specification Quality Checklist: Workspace-first Navigation & Monitoring Hub
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-02-06
**Feature**: [specs/077-workspace-nav-monitoring-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
- Validation pass on first iteration.
- URLs are treated as product behavior (not implementation details).

View File

@ -0,0 +1,67 @@
# Contracts — Routes & Semantics (077)
**Spec**: [specs/077-workspace-nav-monitoring-hub/spec.md](../spec.md)
This feature is an admin UI/navigation refactor. Contracts are expressed as web route semantics + access rules.
## Canonical routes
### Workspace context
- `GET /admin/choose-workspace`
- Purpose: select active workspace context
- Access: authenticated user
- Visibility: shows only workspaces where the user is a member
- `POST /admin/switch-workspace`
- Purpose: update workspace context
- Access: authenticated user
- Security:
- If user is not a member of the selected workspace → 404 (deny-as-not-found)
### Workspace management (CRUD)
- `GET /admin/workspaces`
- `GET /admin/workspaces/{workspace}`
- `GET /admin/workspaces/{workspace}/edit`
- `GET /admin/workspaces/create`
Contract semantics:
- Index lists only workspaces the user is a member of.
- If user attempts to access a workspace record they are not a member of → 404 (deny-as-not-found)
- If user is a member but lacks the required capability for a protected action/screen (create/edit/membership management) → 403
- If user is authorized → normal Filament behavior
### Monitoring hub — Operations
- `GET /admin/operations`
- Canonical operations index (tenantless URL)
- Behavior:
- If tenant context is active: default filter state = current tenant (removable)
- If tenant context is not active: workspace-wide list
- `GET /admin/operations/{run}`
- Canonical run deep link
- Security:
- If run belongs to a workspace the user is not a member of → 404
### Monitoring hub — Reserved surfaces (placeholders)
- `GET /admin/alerts`
- Reserved placeholder page
- Access: workspace members (workspace context required)
- `GET /admin/audit-log`
- Reserved placeholder page
- Access: workspace members (workspace context required)
## Status code rules (summary)
- Non-member / not entitled to the workspace scope → 404
- Member but missing capability (workspace-scoped protected actions) → 403
## Non-leakage requirements
- Global search must not list inaccessible workspaces/tenants/runs.
- Navigation labels and groups must not imply the existence of admin-only surfaces.

View File

@ -0,0 +1,66 @@
# Data Model — Workspace-first Navigation & Monitoring Hub (077)
**Date**: 2026-02-06
**Spec**: [specs/077-workspace-nav-monitoring-hub/spec.md](spec.md)
This feature is primarily information architecture + context enforcement. No new tables are required; the design depends on existing entities and their relationships.
## Entities
### Workspace
Represents a portfolio / customer container (primary context).
- Key fields (existing, relevant):
- `id`
- `name`
- `slug` (optional)
- `archived_at` (nullable)
### WorkspaceMembership
Entitlement relationship between a user and a workspace.
- Key fields (existing, relevant):
- `workspace_id`
- `user_id`
- `role` (e.g. owner/operator/etc; actual role semantics are managed by the capability resolver)
### Tenant (Managed Tenant)
Represents a Microsoft/Intune tenant belonging to a workspace (secondary context via Filament tenancy).
- Key fields (existing, relevant):
- `id`
- `workspace_id` (foreign key to Workspace)
- `external_id` (used in Filament tenancy route `/admin/t/{tenant}`)
- `status` (e.g., active)
### OperationRun
Canonical monitoring record (workspace-level entity; may optionally be linked to a tenant).
- Key fields (existing, relevant):
- `id`
- `workspace_id` (required for access control)
- `tenant_id` (nullable; used for default filtering and “recent operations”)
- `type`, `status`, `outcome`
- timestamps (created/started/completed)
- `context` (JSON)
## Relationships
- Workspace has many WorkspaceMemberships.
- Workspace has many Tenants.
- Workspace has many OperationRuns.
- Tenant belongs to Workspace.
- OperationRun belongs to Workspace.
- OperationRun optionally belongs to Tenant.
## Invariants / Rules enforced by this feature
- Workspace context (`current_workspace_id`) is required for workspace-scoped navigation and access control.
- Tenant context must be consistent with workspace context:
- If tenant is not in current workspace, tenant context is cleared (continue tenantless).
- OperationRun access is controlled by membership in the runs `workspace_id`.

View File

@ -0,0 +1,219 @@
# Implementation Plan: Workspace-first Navigation & Monitoring Hub
**Branch**: `077-workspace-nav-monitoring-hub` | **Date**: 2026-02-06 | **Spec**: [specs/077-workspace-nav-monitoring-hub/spec.md](spec.md)
**Input**: Feature specification from [specs/077-workspace-nav-monitoring-hub/spec.md](spec.md)
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
## Summary
Resolve workspace navigation ambiguity and formalize a workspace-first context model:
- Unambiguous labels: **Switch workspace** (`/admin/choose-workspace`) vs **Manage workspaces** (`/admin/workspaces`).
- Monitoring → **Operations** remains canonical and tenantless (`/admin/operations`, `/admin/operations/{run}`).
- Tenant context influences Operations only via **server-side default filter state** (removable), never via routing.
- Strict non-leaking security semantics:
- Non-member workspace scope → 404 (deny-as-not-found)
- Workspace member missing capability (protected actions/screens) → 403
- Accessing a workspace record outside membership → 404 (deny-as-not-found)
Supporting artifacts:
- [research.md](research.md)
- [data-model.md](data-model.md)
- [contracts/routes.md](contracts/routes.md)
- [quickstart.md](quickstart.md)
## Technical Context
**Language/Version**: PHP 8.4.x
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4
**Storage**: PostgreSQL (Sail)
**Testing**: Pest v4
**Target Platform**: Web (Filament admin panels)
**Project Type**: Laravel monolith
**Performance Goals**: Operations pages remain DB-only at render; list/detail stay fast on large run tables (pagination + indexed filters)
**Constraints**: Filament-native patterns only; canonical URLs must not depend on tenant context; strict 404/403 non-leakage semantics
**Scale/Scope**: Multi-workspace MSP use; many tenants and many operation runs
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Inventory-first: N/A (no inventory semantics changes)
- Read/write separation: PASS (no write operations introduced)
- Graph contract path: N/A (no Graph calls)
- Deterministic capabilities: PASS (capability gating uses existing resolver/registry patterns)
- RBAC-UX: PASS (explicit 404 vs 403 rules)
- RBAC-UX destructive confirmation: N/A (no destructive actions introduced)
- RBAC-UX global search: N/A (no new searchable resources; no changes to global search)
- Tenant isolation: PASS (workspace membership is isolation boundary; tenant context auto-cleared when invalid)
- Run observability: N/A (no new operations/jobs)
- Automation: N/A
- Data minimization: N/A
- Badge semantics (BADGE-001): N/A
## Project Structure
### Documentation (this feature)
```text
specs/077-workspace-nav-monitoring-hub/
├── spec.md
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── routes.md
└── checklists/
└── requirements.md
```
### Source Code (repository root)
```text
app/
├── Filament/
│ ├── Pages/
│ │ └── ChooseWorkspace.php
│ └── Resources/
│ ├── OperationRunResource.php
│ └── OperationRunResource/
│ └── Pages/
│ └── ListOperationRuns.php
├── Http/
│ └── Middleware/
│ └── EnsureWorkspaceSelected.php
├── Providers/
│ └── Filament/
│ └── AdminPanelProvider.php
└── Support/
└── Middleware/
└── EnsureFilamentTenantSelected.php
resources/
└── views/
└── filament/
└── partials/
└── workspace-switcher.blade.php
routes/
└── web.php
tests/
└── Feature/
└── (new tests for navigation labels + 404/403 + operations default filter)
```
**Structure Decision**: Laravel monolith using Filament resources/pages and Laravel middleware.
## Complexity Tracking
No constitution violations.
## Phase 0 — Outline & Research (complete)
All unknowns/decisions have been resolved and recorded:
- Repo reality + ambiguity sources + decisions D1D4: [research.md](research.md)
- No remaining NEEDS CLARIFICATION items in the spec.
## Phase 1 — Design & Contracts (complete)
- Data model: no new tables/columns required; behavior is implemented via middleware + Filament config: [data-model.md](data-model.md)
- Route/security contracts: [contracts/routes.md](contracts/routes.md)
- Manual validation steps + suggested test filters: [quickstart.md](quickstart.md)
Agent context update:
- Re-run `.specify/scripts/bash/update-agent-context.sh copilot` after finalizing this plan file (the earlier run happened while this file contained placeholders).
## Phase 2 — Implementation Plan (ready for tasks)
### Step 1 — Navigation labels: “one label, one meaning”
- Update admin navigation to include:
- **Switch workspace**`/admin/choose-workspace`
- **Manage workspaces**`/admin/workspaces`
- Remove/replace any navigation items labeled only “Workspaces”.
Implementation targets:
- Update [app/Support/Middleware/EnsureFilamentTenantSelected.php](../../app/Support/Middleware/EnsureFilamentTenantSelected.php) navigation builder:
- Change the label from `Workspaces` to `Switch workspace` for the choose-workspace link.
- Ensure this fallback navigation does not accidentally imply CRUD management.
- Update [app/Providers/Filament/AdminPanelProvider.php](../../app/Providers/Filament/AdminPanelProvider.php) nav item label for workspace CRUD to `Manage workspaces`.
- Update [resources/views/filament/partials/workspace-switcher.blade.php](../../resources/views/filament/partials/workspace-switcher.blade.php) text/links to consistently say “Switch workspace”.
- Add reserved Monitoring navigation surfaces for **Alerts** and **Audit Log** as placeholder pages (non-functional “coming soon”) to satisfy FR-011.
### Step 2 — Enforce workspace-scoped RBAC semantics for `/admin/workspaces`
- `/admin/workspaces` stays tenantless and workspace-scoped.
- Enforce strict non-leakage semantics:
- Non-member attempting to access a workspace record → **404** (deny-as-not-found)
- Member missing required capability for protected actions/screens → **403**
Implementation targets:
- Scope the Workspaces query (index) to only workspaces the user is a member of.
- Ensure `WorkspacePolicy` returns 404 semantics for non-members (record access).
- Gate create/edit/membership-management behind canonical workspace capabilities (no raw strings).
- Hide “Manage workspaces” navigation unless the user can manage something workspace-admin related (capability-based).
### Step 3 — Workspace selection redirect + return-to-intended
Requirement: visiting any workspace-scoped page without a selected workspace MUST redirect to `/admin/choose-workspace` and then return to the originally requested URL.
Implementation targets:
- Update [app/Http/Middleware/EnsureWorkspaceSelected.php](../../app/Http/Middleware/EnsureWorkspaceSelected.php):
- When redirecting to `/admin/choose-workspace`, store the intended URL (path + query) in session.
- Preserve the existing exemptions for auth routes and for `/admin/operations/{run}` and Livewire update referers.
- Update both workspace-selection entrypoints to honor intended URLs:
- [app/Filament/Pages/ChooseWorkspace.php](../../app/Filament/Pages/ChooseWorkspace.php)
- [app/Http/Controllers/SwitchWorkspaceController.php](../../app/Http/Controllers/SwitchWorkspaceController.php)
- After setting the workspace, redirect to the stored intended URL (if present and safe), otherwise keep the existing behavior (onboarding / choose-tenant / tenant dashboard).
### Step 4 — Auto-clear invalid tenant context on workspace change
Requirement: if tenant context is active but does not belong to the current workspace, auto-clear tenant context and continue on tenantless workspace pages.
Implementation targets:
- In [app/Support/Middleware/EnsureFilamentTenantSelected.php](../../app/Support/Middleware/EnsureFilamentTenantSelected.php) (or a dedicated middleware used for tenantless pages):
- Detect a persisted Filament tenant that does not match `WorkspaceContext::currentWorkspaceId()`.
- Clear the persisted Filament tenant context (confirm the correct Filament v5 mechanism during implementation).
### Step 5 — Operations: move tenant scoping from query to removable default filter
Requirement: `/admin/operations` stays canonical; if tenant context is active, default to that tenant using server-side default filter state with a visible removable chip.
Implementation targets:
- Update [app/Filament/Resources/OperationRunResource.php](../../app/Filament/Resources/OperationRunResource.php):
- Remove tenant-context filtering from `getEloquentQuery()`.
- Update [app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php](../../app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php):
- Add a tenant filter (select) over available tenants in the current workspace.
- Default the filter state from the current tenant context when valid.
- Ensure the filter chip is visible and can be cleared to view workspace-wide operations.
### Step 6 — Tests (Pest) + formatting
Add/adjust tests to cover the strict semantics:
- Navigation labels: “Switch workspace” vs “Manage workspaces” (no ambiguous “Workspaces”).
- `/admin/workspaces`:
- non-member record access → 404
- member missing capability for a protected action/screen → 403
- EnsureWorkspaceSelected:
- visiting `/admin/operations` without workspace → redirects to choose-workspace
- after selecting workspace → returns to intended URL
- Operations default filter:
- with tenant context active → tenant filter default set
- clearing filter → shows workspace-wide results
Tooling:
- Run `./vendor/bin/sail bin pint --dirty`.
- Run focused tests via `./vendor/bin/sail artisan test --compact --filter=...`.

View File

@ -0,0 +1,50 @@
# Quickstart — Workspace-first Navigation & Monitoring Hub (077)
**Audience**: Devs and reviewers validating the feature on staging/local
**Spec**: [specs/077-workspace-nav-monitoring-hub/spec.md](spec.md)
## Local setup
- Start containers: `./vendor/bin/sail up -d`
- Install dependencies if needed: `./vendor/bin/sail composer install` and `./vendor/bin/sail npm install`
- Run migrations: `./vendor/bin/sail artisan migrate`
## Manual validation checklist
### Navigation separation
1. Open `/admin` and sign in.
2. In the user menu, confirm there is an explicit entry labeled **"Switch workspace"** that navigates to `/admin/choose-workspace`.
3. In the sidebar, confirm **"Manage workspaces"** exists only when authorized.
4. Confirm there is no navigation item labeled simply **"Workspaces"** that ambiguously points to both concepts.
### Operations canonical + default tenant filter
1. Visit `/admin/operations` with no tenant context selected.
- Expect: page loads and shows workspace-wide runs.
2. Activate tenant context (`/admin/t/{tenant}`), then navigate to `/admin/operations`.
- Expect: default tenant filter applied, visible filter chip, chip can be cleared.
3. Visit a run deep link `/admin/operations/{run}` from both tenantless and tenant context.
- Expect: same canonical page, no tenant-route dependency.
### Security semantics
- Non-member accessing operations for another workspace: expect **404**.
- Workspace member but missing capability for a protected action/screen: expect **403**.
- Accessing `/admin/workspaces` for a workspace you are not a member of: expect **404**.
## Test execution
Run focused tests:
- US1 (nav separation): `./vendor/bin/sail artisan test --compact --filter=WorkspaceNavigationHub`
- US2 (canonical ops URLs): `./vendor/bin/sail artisan test --compact --filter=OperationsCanonicalUrls`
- US3 (non-leakage): `./vendor/bin/sail artisan test --compact --filter=NonLeakageWorkspaceOperations`
Run a targeted suite for the feature area:
- `./vendor/bin/sail artisan test --compact tests/Feature/Workspaces tests/Feature/Monitoring tests/Feature/OpsUx`
Run formatting before finalizing:
- `./vendor/bin/sail pint --dirty`

View File

@ -0,0 +1,58 @@
# Research — Workspace-first Navigation & Monitoring Hub (077)
**Date**: 2026-02-06
**Branch**: 077-workspace-nav-monitoring-hub
**Spec**: [specs/077-workspace-nav-monitoring-hub/spec.md](spec.md)
## Repo Reality Check (what exists today)
- Admin panel exists at `/admin` via [app/Providers/Filament/AdminPanelProvider.php](../../app/Providers/Filament/AdminPanelProvider.php).
- System panel exists at `/system` with a separate auth guard (`platform`) via [app/Providers/Filament/SystemPanelProvider.php](../../app/Providers/Filament/SystemPanelProvider.php).
- Workspace context selection exists:
- Page `/admin/choose-workspace` via [app/Filament/Pages/ChooseWorkspace.php](../../app/Filament/Pages/ChooseWorkspace.php)
- POST switch endpoint `/admin/switch-workspace` via [routes/web.php](../../routes/web.php)
- Workspace switcher UI in the user menu via [resources/views/filament/partials/workspace-switcher.blade.php](../../resources/views/filament/partials/workspace-switcher.blade.php)
- Navigation ambiguity is currently real:
- When no tenant is selected, navigation is replaced with a single item labeled **"Workspaces"** linking to the choose-workspace page via [app/Support/Middleware/EnsureFilamentTenantSelected.php](../../app/Support/Middleware/EnsureFilamentTenantSelected.php).
- Separately, the sidebar includes another **"Workspaces"** item linking to `/admin/workspaces` (workspace CRUD) via [app/Providers/Filament/AdminPanelProvider.php](../../app/Providers/Filament/AdminPanelProvider.php).
- Operations is already canonical and tenantless:
- Resource slug is `/admin/operations` via [app/Filament/Resources/OperationRunResource.php](../../app/Filament/Resources/OperationRunResource.php).
- Detail page is `/admin/operations/{record}`.
## Decisions (resolved)
### D1 — Manage workspaces stays on `/admin/workspaces` and follows workspace RBAC semantics (404 for non-members, 403 for missing capability)
- Decision: Treat `/admin/workspaces` as a **workspace-scoped** management surface in the tenant plane (`/admin`, Entra users):
- Non-members (or out-of-scope workspace records) → **404** (deny-as-not-found)
- Members missing required capabilities for protected actions/screens → **403**
- Rationale: Aligns with the constitution RBAC-UX model (membership is the isolation boundary; capability denial is 403 after membership is established) while still preventing cross-workspace leakage.
- Alternatives considered:
- Move management into `/system` panel: rejected because this feature targets the tenant plane IA. (If workspace CRUD becomes platform-admin only later, that should be handled as a separate migration spec.)
### D2 — Tenant context influences Operations via server-side default filter state, not querystring
- Decision: Apply the tenant default filter server-side while keeping the canonical URL `/admin/operations` unchanged.
- Rationale: Matches Spec 077 clarification (Q2=A). Prevents link-sharing surprises and keeps canonical monitoring routes stable.
- Alternatives considered:
- Querystring-based default filtering (e.g. `?tenant_id=`): rejected as it makes filtered URLs the de-facto navigation target.
### D3 — Missing workspace context redirects to `/admin/choose-workspace` and returns to the requested URL
- Decision: When a workspace-scoped page is visited without an active workspace selection, redirect to `/admin/choose-workspace` and then return.
- Rationale: Matches Spec 077 clarification (Q3=A) and aligns with existing `/admin` root override behavior in [routes/web.php](../../routes/web.php).
### D4 — Invalid tenant context (tenant not in current workspace) is auto-cleared
- Decision: If tenant context is active but does not belong to the current workspace, clear tenant context and continue on workspace-level pages.
- Rationale: Matches Spec 077 clarification (Q4=A). Reduces “ghost tenant” behavior after a workspace switch.
- Alternatives considered:
- Hard 404: rejected as too confusing during normal context switching.
## Key Implementation Implications (for planning)
- **Rename navigation labels** to satisfy “one label, one meaning”:
- The “Workspaces” navigation item that points to the choose-workspace page must become **"Switch workspace"**.
- The “Workspaces” navigation item that points to CRUD must become **"Manage workspaces"** and be capability-gated with workspace RBAC semantics (404 for non-members; 403 for missing capability).
- **Operations filter chip/removal**: current behavior filters by `Tenant::current()` inside `OperationRunResource::getEloquentQuery()`, which is not user-removable. The plan should move this behavior into a table filter with default state.
- **No render-time external calls**: monitoring pages must remain DB-only at render (already consistent with constitution).

View File

@ -0,0 +1,162 @@
# Feature Specification: Workspace-first Navigation & Monitoring Hub
**Feature Branch**: `077-workspace-nav-monitoring-hub`
**Created**: 2026-02-06
**Status**: Draft
**Input**: User description: "Workspace-first navigation and monitoring hub for an enterprise admin suite: remove workspace navigation ambiguity, lock canonical operations deep links, apply tenant context only as default filters, and enforce strict 404/403 access semantics without information leakage."
## Clarifications
### Session 2026-02-06
- Q: What is the authorization plane + status-code rule for `/admin/workspaces` ("Manage workspaces")? → A: Tenant plane (`/admin`, Entra users). Workspace management is workspace-scoped: non-members receive 404 (deny-as-not-found); members missing required capabilities receive 403.
- Q: How should the tenant-context default filter on `/admin/operations` be implemented? → A: Server-side default state with a removable filter chip; URL remains `/admin/operations`.
- Q: What happens when a user visits a workspace-scoped page (e.g. `/admin/operations`) with no `current_workspace_id` selected? → A: Redirect to `/admin/choose-workspace` and return to the originally requested URL after selection.
- Q: If tenant context is active but the tenant is not in the current workspace (e.g., user switches workspaces), what should happen? → A: Auto-clear tenant context and continue on tenantless workspace pages.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Switch workspace without ambiguity (Priority: P1)
As an operator/admin, I need to switch my active workspace (portfolio) using a clear, single-purpose entry point, so that I never confuse "switch workspace" with "manage workspaces".
**Why this priority**: Workspace context is foundational. If its confusing, every other module becomes harder to use and support.
**Independent Test**: A user can find "Switch workspace", select a workspace they are a member of, and the application context updates while workspace management remains separate.
**Acceptance Scenarios**:
1. **Given** I am signed in and belong to multiple workspaces, **When** I choose "Switch workspace", **Then** I see only workspaces I am a member of and can select one.
2. **Given** I can manage workspaces, **When** I open "Manage workspaces", **Then** I can access workspace CRUD screens and breadcrumbs stay within the management area.
3. **Given** I cannot manage workspaces, **When** I look at navigation, **Then** I do not see "Manage workspaces".
---
### User Story 2 - Use Monitoring hub from canonical links (Priority: P2)
As an operator, I need monitoring pages (starting with Operations) to be reachable via stable, shareable links that never depend on tenant context, so that support, alerts, and notifications can deep-link reliably.
**Why this priority**: Monitoring must be dependable across contexts; deep links are critical for incident response and support.
**Independent Test**: Visiting the canonical operations URLs works with and without tenant context, and the system enforces membership checks.
**Acceptance Scenarios**:
1. **Given** I am a member of a workspace, **When** I visit `/admin/operations`, **Then** I can view a workspace-wide list of operations.
2. **Given** I have an active tenant context, **When** I visit `/admin/operations`, **Then** operations are pre-filtered to that tenant but the URL remains `/admin/operations`.
3. **Given** I have a run link `/admin/operations/{run}`, **When** I open it, **Then** I see the run detail regardless of tenant context.
---
### User Story 3 - Navigate and search without leaking inaccessible data (Priority: P3)
As a user, I should never learn about workspaces/tenants/runs I cannot access through navigation labels, breadcrumbs, counts, or global search results.
**Why this priority**: Preventing information leakage is a core enterprise requirement and reduces risk in multi-tenant MSP environments.
**Independent Test**: An unauthorized user receives not-found responses for out-of-scope resources and does not see them in search.
**Acceptance Scenarios**:
1. **Given** I am not a member of a workspace, **When** I attempt to access that workspaces monitoring data or runs, **Then** I receive a not-found response.
2. **Given** I am a workspace member but lack a capability for a protected workspace-scoped screen or action, **When** I attempt to access it directly, **Then** I receive a forbidden response.
3. **Given** I use global search, **When** I search for entities outside my scope, **Then** they do not appear in results (no partial hints).
### Edge Cases
- User is a member of zero workspaces.
- User loses workspace membership while having an active session.
- Tenant context is active but the tenant does not belong to the current workspace.
- A run is referenced by an external deep link after it was deleted or moved.
- User can view monitoring but cannot perform mutations (e.g., cancel/retry) if those actions exist.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature changes navigation and authorization behavior but does not introduce new external API calls or background jobs. Any mutation actions added later (e.g., cancel/retry) must follow the platforms safety gates (confirmation/audit) and be covered by authorization tests.
**Constitution alignment (RBAC-UX):**
- **Authorization plane(s) involved**:
- **Tenant plane (Entra users)** only.
- **Platform plane (`/system`) is out of scope** for this feature.
- **Authorization planes**:
- Workspace-level pages (e.g., monitoring hub, workspace management) are governed by workspace membership and workspace capabilities.
- Tenant context is secondary and must not change canonical routing for monitoring pages.
- **Isolation model note (workspace scope)**:
- “Workspace-scoped” monitoring is an explicit, access-checked aggregation scope over the managed tenants that belong to the selected workspace.
- All reads remain bounded to the current workspace; there is no cross-workspace monitoring view in this feature.
- **404 vs 403 semantics (strict)**:
- Non-member / not entitled to the workspace scope → **404** (deny-as-not-found)
- Workspace member but missing the required capability for a protected screen/action → **403**
- **Server-side enforcement**: Navigation visibility must not be treated as authorization; all access control is enforced on the server for every protected page and every mutation.
- **Global search non-leakage**: Global search must not show titles, counts, or partial matches for inaccessible workspaces/tenants/runs. Inaccessible entities behave as not-found.
### Functional Requirements
- **FR-001 (One label, one meaning)**: The application MUST provide two distinct, clearly-labeled entry points:
- "Switch workspace" for selecting the active workspace context.
- "Manage workspaces" for workspace CRUD/administration.
- **FR-002 (Canonical workspace switch route)**: "Switch workspace" MUST navigate to `/admin/choose-workspace`.
- **FR-003 (Canonical workspace management route)**: "Manage workspaces" MUST navigate to `/admin/workspaces` and MUST NOT be labeled simply "Workspaces".
- **FR-004 (Breadcrumb correctness)**: Breadcrumbs in workspace management MUST point back to `/admin/workspaces` and must not send users to the workspace switcher.
- **FR-005 (Monitoring is workspace-level)**: Monitoring pages MUST be workspace-scoped and reachable without tenant context.
- **FR-006 (Canonical Operations URLs)**: Operations MUST remain canonical and tenantless:
- index: `/admin/operations`
- detail: `/admin/operations/{run}`
- **FR-007 (Tenant context affects defaults, not routing)**: If tenant context is active, the operations index MUST default to showing runs for that tenant using **server-side default filter state**, and users MUST be able to clear that default to view workspace-wide operations. The canonical URL MUST remain `/admin/operations` and the default MUST present a visible, removable filter chip (no required querystring parameters).
- **FR-008 (Tenant shortcut to operations)**: Tenant detail screens MUST offer a "Recent operations" summary and a "View all operations" call-to-action that leads to the canonical operations index.
- **FR-009 (Membership gating)**: Users MUST be a member of a workspace to access workspace-scoped pages. Non-members MUST receive a not-found response.
- **FR-010 (Capability gating for management)**: Workspace-scoped management/mutations MUST be restricted to users with the appropriate capability/capabilities (from the canonical registry). Unauthorized workspace members MUST receive a forbidden response.
- Canonical capabilities used by this feature:
- `workspace.manage` (Capabilities::WORKSPACE_MANAGE): create/edit workspace fields.
- `workspace_membership.manage` (Capabilities::WORKSPACE_MEMBERSHIP_MANAGE): add/remove members and change roles.
- **FR-011 (Monitoring hub IA)**: The sidebar MUST provide a "Monitoring" area that is the canonical home for Operations now, with reserved surfaces for future Alerts and Audit Log.
- **FR-012 (Deep-link stability)**: Any monitoring entity intended for support workflows MUST have a stable deep link that does not depend on tenant context.
- **FR-013 (No workspace selected)**: If a user visits a workspace-scoped page without a selected workspace context, the system MUST redirect to `/admin/choose-workspace` and then return the user to their originally requested URL after a successful selection.
- **FR-014 (Invalid tenant context)**: If tenant context is active but the tenant does not belong to the current workspace, the system MUST auto-clear tenant context and continue on workspace-level pages without tenant scoping.
- **FR-077-016 (Header context bar)**: The header MUST provide an always-available context bar for Suite navigation:
- **FR-077-016-A (Workspace visible)**: If a workspace is selected, show `Workspace: <name>` and allow the user to open the existing workspace switcher (`/admin/choose-workspace`).
- **FR-077-016-B (Tenant accessible on tenantless pages)**: The header MUST surface tenant context even on tenantless pages (e.g., `/admin/operations`). If there is an active tenant context, show `Tenant: <tenant name>` (fallback to a safe identifier). If there is no active tenant but there is a last-selected tenant in the current workspace session, show it.
- **FR-077-016-C (No implicit switching)**: Canonical pages MUST NOT silently switch tenant or workspace. The context bar is an explicit control only.
- **FR-077-016-D (No leakage)**: Tenant picker contents MUST include only tenants the user is entitled to view within the current workspace. Unauthorized tenant selection via direct URL MUST remain deny-as-not-found (404).
- **FR-077-016-E (Filament-native)**: Implementation MUST use Filament v5 mechanisms (topbar/user-menu render hooks + Filament tenancy) and Livewire v4 where needed.
### Key Entities *(include if feature involves data)*
- **Workspace**: Primary context container for a customer/portfolio.
- **Workspace Membership**: The relationship that entitles a user to a workspace.
- **Managed Tenant**: Secondary context within a workspace; used for scoping defaults and tenant workflows.
- **Operation Run**: A record representing an operational execution that belongs to a workspace and may be associated with a tenant.
- **Capability**: A named permission that gates management/mutation behavior.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001 (Reduced confusion)**: In a moderated test with new users, at least 90% correctly choose the right destination (switch vs manage) on first attempt.
- **SC-002 (Faster workspace switching)**: Users can switch to a known workspace in under 15 seconds without using search.
- **SC-003 (Reliable deep links)**: Support can open `/admin/operations/{run}` successfully regardless of tenant context in 100% of tested cases.
- **SC-004 (No leakage regressions)**: Security regression tests confirm 0 instances of inaccessible workspaces/tenants/runs appearing in navigation or global search.
## Acceptance details (pinned)
### Recent operations summary (FR-008)
- Show the most recent **5** operation runs for the current tenant, ordered by `created_at` descending (fallback: `id` descending).
- Display, at minimum: `type` (label), `status`, `outcome`, `created_at` (or since), and a link to the run detail.
- Provide a "View all operations" CTA that navigates to canonical `/admin/operations` (no tenant prefix / no required query params).
### Header context bar (FR-077-016)
- The header shows a stable, compact context bar:
- `Workspace: <name>` (clickable)
- `Tenant: <name>` (picker)
- Tenant picker is available on tenantless pages.
- No automatic tenant selection occurs when opening canonical URLs.
## Mandatory Tests (pinned)
- **T-077-016-1 (Tenant dropdown on tenantless pages)**: With a selected workspace and an active tenant context, visiting `/admin/operations` shows the tenant picker and selecting a tenant navigates to tenant home.
- **T-077-016-2 (Security filtering)**: Only entitled tenants within the current workspace appear in the picker; posting /navigating to an unauthorized tenant results in 404.
- **T-077-016-3 (No implicit switching)**: Visiting `/admin/operations/{run}` from a deep link MUST NOT auto-switch tenant.

View File

@ -0,0 +1,215 @@
---
description: "Task list for Spec 077 implementation"
---
# Tasks: Workspace-first Navigation & Monitoring Hub (077)
**Input**: Design documents from `/specs/077-workspace-nav-monitoring-hub/`
**Prerequisites**:
- Required: [spec.md](spec.md), [plan.md](plan.md)
- Optional (used): [research.md](research.md), [data-model.md](data-model.md), [contracts/routes.md](contracts/routes.md), [quickstart.md](quickstart.md)
**Tests**: REQUIRED (Pest) — this feature changes runtime behavior (navigation + authorization + filtering).
**Livewire/Filament compatibility**: Filament v5 + Livewire v4 only.
---
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Prepare the minimal scaffolding for safe, test-first delivery.
- [X] T001 Create new Pest test file for workspace navigation in tests/Feature/Workspaces/WorkspaceNavigationHubTest.php
- [X] T002 Create new Pest test file for operations canonical routing in tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php
- [X] T003 [P] Create new Pest test file for non-leakage semantics in tests/Feature/OpsUx/NonLeakageWorkspaceOperationsTest.php
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Shared plumbing needed by multiple stories.
- [X] T004 Add intended-URL session key constant in app/Support/Workspaces/WorkspaceContext.php
- [X] T005 Implement “store intended URL” helper in app/Support/Workspaces/WorkspaceIntendedUrl.php
- [X] T006 [P] Add tests for intended-URL helper in tests/Feature/Workspaces/WorkspaceIntendedUrlTest.php
- [X] T007 Update middleware to use intended-URL helper in app/Http/Middleware/EnsureWorkspaceSelected.php
- [X] T008 [P] Add safe-redirect allowlist for intended URLs in app/Support/Workspaces/WorkspaceIntendedUrl.php
**Checkpoint**: Intended redirect plumbing exists and is covered by tests.
---
## Phase 3: User Story 1 — Switch workspace without ambiguity (Priority: P1) 🎯 MVP
**Goal**: Clear separation between “Switch workspace” and “Manage workspaces”, with correct 404/403 behavior.
**Independent Test**: A signed-in user can switch workspaces via “Switch workspace”, and “Manage workspaces” is only visible/accessible when authorized.
### Tests for User Story 1 (write first)
- [X] T009 [P] [US1] Assert nav label “Switch workspace” appears when tenant is not selected in tests/Feature/Workspaces/WorkspaceNavigationHubTest.php
- [X] T010 [P] [US1] Assert no ambiguous “Workspaces” nav item exists in tests/Feature/Workspaces/WorkspaceNavigationHubTest.php
- [X] T011 [P] [US1] Assert `/admin/workspaces` is tenantless and reachable for a workspace owner in tests/Feature/Workspaces/WorkspacesResourceIsTenantlessTest.php
- [X] T012 [P] [US1] Assert `/admin/workspaces/{record}` is deny-as-not-found for non-members (404) in tests/Feature/Workspaces/WorkspacesResourceIsTenantlessTest.php
### Implementation for User Story 1
- [X] T013 [US1] Rename fallback nav item to “Switch workspace” in app/Support/Middleware/EnsureFilamentTenantSelected.php
- [X] T014 [US1] Update user-menu copy/CTA to “Switch workspace” in resources/views/filament/partials/workspace-switcher.blade.php
- [X] T015 [US1] Rename admin sidebar item to “Manage workspaces” in app/Providers/Filament/AdminPanelProvider.php
- [X] T016 [US1] Gate “Manage workspaces” navigation visibility via capability in app/Providers/Filament/AdminPanelProvider.php
- [X] T017 [US1] Enforce workspace-scoped RBAC semantics for workspace management (404 non-member, 403 missing capability) in app/Policies/WorkspacePolicy.php
- [X] T018 [US1] Ensure workspace management breadcrumbs point to `/admin/workspaces` in app/Filament/Resources/Workspaces/WorkspaceResource.php
- [X] T019 [US1] Ensure `/admin/workspaces` routes do not require tenant context in app/Support/Middleware/EnsureFilamentTenantSelected.php
- [X] T020 [US1] Run focused tests for US1 via `./vendor/bin/sail artisan test --compact --filter=WorkspaceNavigationHub` (document in specs/077-workspace-nav-monitoring-hub/quickstart.md)
**Checkpoint**: UI uses unambiguous labels; `/admin/workspaces` follows workspace RBAC semantics (no leakage).
---
## Phase 4: User Story 2 — Use Monitoring hub from canonical links (Priority: P2)
**Goal**: `/admin/operations` and `/admin/operations/{run}` work regardless of tenant context; tenant context only sets removable default filters.
**Independent Test**: Visiting `/admin/operations` works tenantless (workspace-selected), and in tenant context it defaults to that tenant via a removable filter chip.
### Tests for User Story 2 (write first)
- [X] T021 [P] [US2] Assert `/admin/operations` is reachable without tenant context in tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php
- [X] T022 [P] [US2] Assert `/admin/operations/{run}` works with and without tenant context in tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php
- [X] T023 [P] [US2] Assert operations list defaults to current tenant (filter state) when tenant context active in tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php
- [X] T024 [P] [US2] Assert clearing tenant filter shows workspace-wide runs in tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php
### Implementation for User Story 2
- [X] T025 [US2] Allow `/admin/operations` (index) through tenancy-enforcing middleware without auto-setting tenant in app/Support/Middleware/EnsureFilamentTenantSelected.php
- [X] T026 [US2] Ensure workspace selection is required for `/admin/operations` and stores intended URL for return flow in app/Http/Middleware/EnsureWorkspaceSelected.php
- [X] T027 [US2] Redirect back to intended URL after workspace selection in both app/Filament/Pages/ChooseWorkspace.php and app/Http/Controllers/SwitchWorkspaceController.php
- [X] T028 [US2] Remove hard tenant scoping from query in app/Filament/Resources/OperationRunResource.php
- [X] T029 [US2] Add tenant SelectFilter with removable chip and server-side default state in app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php
- [X] T030 [US2] Scope selectable tenants in the filter to current workspace in app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php
- [X] T031 [US2] Add “Recent operations” summary (last 5 by created_at) + “View all operations” CTA on tenant view page in app/Filament/Resources/TenantResource/Pages/ViewTenant.php
- [X] T032 [US2] Ensure “View all operations” CTA routes to canonical `/admin/operations` in app/Filament/Resources/TenantResource/Pages/ViewTenant.php
- [X] T033 [US2] Ensure operations pages remain DB-only (no Graph calls) by extending existing checks in tests/Feature/MonitoringOperationsTest.php
- [X] T034 [US2] Run focused tests for US2 via `./vendor/bin/sail artisan test --compact --filter=OperationsCanonicalUrls` and update specs/077-workspace-nav-monitoring-hub/quickstart.md
**Checkpoint**: Canonical operations URLs work; tenant context only affects default filter state.
---
## Phase 5: User Story 3 — Navigate and search without leaking inaccessible data (Priority: P3)
**Goal**: Enforce strict 404 vs 403 semantics without leaking admin surfaces or cross-workspace/tenant data.
**Independent Test**: Non-members get 404; members missing capability get 403, and no navigation labels hint at inaccessible features.
### Tests for User Story 3 (write first)
- [X] T035 [P] [US3] Assert non-member access to another workspaces operations is 404 in tests/Feature/OpsUx/NonLeakageWorkspaceOperationsTest.php
- [X] T036 [P] [US3] Assert member missing `workspace.manage` gets 403 on `/admin/workspaces/{record}/edit` in tests/Feature/OpsUx/NonLeakageWorkspaceOperationsTest.php
- [X] T037 [P] [US3] Assert invalid tenant context is auto-cleared when switching workspace in tests/Feature/Workspaces/ManagedTenantsWorkspaceRoutingTest.php
- [X] T038 [P] [US3] Assert reserved Monitoring placeholder pages exist (`/admin/alerts`, `/admin/audit-log`) in tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php
### Implementation for User Story 3
- [X] T039 [US3] Implement “auto-clear invalid tenant context” check in app/Support/Middleware/EnsureFilamentTenantSelected.php
- [X] T040 [US3] Confirm and implement correct Filament v5 mechanism for clearing persisted tenant state in app/Support/Middleware/EnsureFilamentTenantSelected.php
- [X] T041 [US3] Implement reserved Monitoring placeholder pages (Alerts, Audit Log) as Filament pages under app/Filament/Pages/Monitoring/**
- [X] T042 [US3] Ensure navigation does not expose admin-only surfaces to unauthorized users in app/Providers/Filament/AdminPanelProvider.php
- [X] T043 [US3] Verify global search does not introduce new leakage for operations/workspaces and, if needed, disable global search for resources without view/edit pages in app/Filament/**
- [X] T044 [US3] Run focused tests for US3 via `./vendor/bin/sail artisan test --compact --filter=NonLeakageWorkspaceOperations`
**Checkpoint**: 404/403 behavior matches spec; no cross-scope leaks.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Stabilize, format, and validate end-to-end.
- [X] T045 Run formatter on touched files via `./vendor/bin/sail bin pint --dirty`
- [X] T046 Run targeted full suite for touched areas via `./vendor/bin/sail artisan test --compact tests/Feature/Workspaces tests/Feature/Monitoring tests/Feature/OpsUx`
- [X] T047 [P] Confirm manual quickstart steps still match UI labels and routes in specs/077-workspace-nav-monitoring-hub/quickstart.md
- [X] T048 [P] Confirm route semantics still match contracts in specs/077-workspace-nav-monitoring-hub/contracts/routes.md
- [X] T049 Ensure Filament v5 + Livewire v4 APIs are used (no v3/v4 Filament APIs) in app/Filament/**
- [X] T050 Run full suite (optional) via `./vendor/bin/sail artisan test --compact`
### Post-implementation bugfixes
- [X] T058 Fix route conflict so Operations “View” consistently hits canonical `/admin/operations/{run}` by moving Filament resource view route to `/admin/operations/r/{record}` in app/Filament/Resources/OperationRunResource.php
---
## Phase 7: Addendum — Header Context Bar (FR-077-016)
**Goal**: Always-visible context bar for Workspace + Tenant, usable on tenantless pages without implicit switching.
### Tests (write first)
- [X] T051 [P] [FR-077-016] Assert tenant picker renders on `/admin/operations` in tests/Feature/Monitoring/HeaderContextBarTest.php
- [X] T052 [P] [FR-077-016] Assert tenant picker lists only entitled tenants in tests/Feature/Monitoring/HeaderContextBarTest.php
- [X] T053 [P] [FR-077-016] Assert deep link `/admin/operations/{run}` does not auto-switch tenant in tests/Feature/Monitoring/HeaderContextBarTest.php
### Implementation
- [X] T054 [FR-077-016] Render context bar in topbar via render hook in app/Providers/Filament/AdminPanelProvider.php
- [X] T055 [FR-077-016] Add context bar partial view in resources/views/filament/partials/context-bar.blade.php
- [X] T056 [FR-077-016] Remove implicit tenant auto-selection behavior while preserving deny-as-not-found semantics in app/Support/Middleware/EnsureFilamentTenantSelected.php
- [X] T057 [FR-077-016] Persist last-selected tenant per workspace session in app/Support/Workspaces/WorkspaceContext.php and controllers/pages that select tenants
**Checkpoint**: Tenant picker usable on tenantless pages; no silent tenant switching.
---
## Dependencies & Execution Order
### Phase Dependencies
- Phase 1 (Setup) → Phase 2 (Foundational)
- Phase 2 (Foundational) → Phase 3+ (User stories)
- Phase 3 (US1) is the MVP and should be delivered first.
- Phase 4 (US2) depends on the clarified navigation + intended-URL plumbing (Phases 12).
- Phase 5 (US3) depends on the implemented behavior from US1/US2 so it can assert non-leakage.
### User Story Dependencies
- US1 → US2: soft dependency (naming + intended redirect improves US2 flows)
- US2 → US3: recommended dependency (US3 asserts final 404/403 and filter semantics)
---
## Parallel Execution Examples
### US1 parallelizable work
- T009, T010, T011, T012 can be written in parallel (different assertions/files)
- T013, T014, T015 can be implemented in parallel (different files)
### US2 parallelizable work
- T021T024 can be written in parallel
- T028 (resource query) and T029 (table filters) can be implemented in parallel
- T031T032 can be implemented in parallel with operations filter work (different file)
### US3 parallelizable work
- T035T038 can be written in parallel
- T039T042 can be implemented in parallel (different files)
---
## Implementation Strategy
### MVP scope (recommended)
- Deliver Phase 13 (US1) only.
- Validate with `./vendor/bin/sail artisan test --compact --filter=WorkspaceNavigationHub`.
- Demo “Switch workspace” vs “Manage workspaces” clarity + correct 404/403 behavior.
### Incremental delivery
- Add US2 (canonical operations URLs + removable tenant default filter)
- Add US3 (non-leakage regression guards)
- Finish with Phase 6 polish and a full suite run

View File

@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
it('renders the tenant context picker on tenantless Monitoring → Operations', function (): void {
$tenant = Tenant::factory()->create(['status' => 'active']);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
Filament::setTenant(null, true);
$this->actingAs($user)
->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
(string) $tenant->workspace_id => (int) $tenant->getKey(),
],
])
->get('/admin/operations')
->assertOk()
->assertSee('Workspace:')
->assertSee('Tenant:')
->assertSee('Select tenant…')
->assertSee('admin/select-tenant')
->assertSee('Clear tenant context')
->assertSee($tenant->getFilamentName());
$this->actingAs($user)
->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
])
->post(route('admin.select-tenant'), ['tenant_id' => (int) $tenant->getKey()])
->assertRedirect();
});
it('filters the header tenant picker to tenants the user can access', function (): void {
$tenantA = Tenant::factory()->create(['status' => 'active']);
[$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner');
$tenantB = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $tenantA->workspace_id,
'name' => 'ZZZ-UNAUTHORIZED-TENANT-NAME-12345',
]);
Filament::setTenant(null, true);
$this->actingAs($user)
->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id,
])
->get('/admin/operations')
->assertOk()
->assertSee($tenantA->getFilamentName())
->assertDontSee($tenantB->getFilamentName());
});
it('does not implicitly switch tenant when opening canonical operation deep links', function (): void {
$tenantA = Tenant::factory()->create(['status' => 'active']);
[$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner');
$tenantB = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $tenantA->workspace_id,
]);
$user->tenants()->syncWithoutDetaching([
$tenantB->getKey() => ['role' => 'owner'],
]);
$runA = OperationRun::factory()->create([
'tenant_id' => (int) $tenantA->getKey(),
'workspace_id' => (int) $tenantA->workspace_id,
'type' => 'policy.sync',
'initiator_name' => 'TenantA',
]);
OperationRun::factory()->create([
'tenant_id' => (int) $tenantB->getKey(),
'workspace_id' => (int) $tenantB->workspace_id,
'type' => 'inventory.sync',
'initiator_name' => 'TenantB',
]);
Filament::setTenant(null, true);
$this->actingAs($user)
->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id,
])
->get(route('admin.operations.view', ['run' => (int) $runA->getKey()]))
->assertOk();
expect(Filament::getTenant())->toBeNull();
$this->actingAs($user)
->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id,
])
->get('/admin/operations')
->assertOk()
->assertSee('Policy sync')
->assertSee('Inventory sync')
->assertSee('TenantA')
->assertSee('TenantB');
});

View File

@ -1,7 +1,8 @@
<?php
use App\Filament\Resources\OperationRunResource;
use App\Models\OperationRun;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Queue;
@ -21,8 +22,11 @@
Bus::fake();
Queue::fake();
Filament::setTenant(null, true);
assertNoOutboundHttp(function () use ($tenant) {
$this->get(OperationRunResource::getUrl('index', tenant: $tenant))
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get('/admin/operations')
->assertOk();
});

View File

@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\OperationRunResource\Pages\ListOperationRuns;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('serves /admin/operations without tenant context (workspace-wide)', function (): void {
$tenantA = Tenant::factory()->create();
[$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner');
$tenantB = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $tenantA->workspace_id,
]);
$user->tenants()->syncWithoutDetaching([
$tenantB->getKey() => ['role' => 'owner'],
]);
$runA = OperationRun::factory()->create([
'tenant_id' => (int) $tenantA->getKey(),
'workspace_id' => (int) $tenantA->workspace_id,
'type' => 'policy.sync',
'initiator_name' => 'TenantA',
]);
$runB = OperationRun::factory()->create([
'tenant_id' => (int) $tenantB->getKey(),
'workspace_id' => (int) $tenantB->workspace_id,
'type' => 'inventory.sync',
'initiator_name' => 'TenantB',
]);
Filament::setTenant(null, true);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id])
->get('/admin/operations')
->assertOk()
->assertSee('Policy sync')
->assertSee('Inventory sync')
->assertSee('TenantA')
->assertSee('TenantB');
});
it('serves /admin/operations/{run} with and without tenant context', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'policy.sync',
]);
Filament::setTenant(null, true);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertOk()
->assertSee('Operation run');
Filament::setTenant($tenant, true);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertOk()
->assertSee('Operation run');
});
it('defaults the tenant filter from tenant context and can be cleared', function (): void {
$tenantA = Tenant::factory()->create();
[$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner');
$tenantB = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $tenantA->workspace_id,
]);
$user->tenants()->syncWithoutDetaching([
$tenantB->getKey() => ['role' => 'owner'],
]);
$runA = OperationRun::factory()->create([
'tenant_id' => (int) $tenantA->getKey(),
'workspace_id' => (int) $tenantA->workspace_id,
'type' => 'policy.sync',
'initiator_name' => 'TenantA',
]);
$runB = OperationRun::factory()->create([
'tenant_id' => (int) $tenantB->getKey(),
'workspace_id' => (int) $tenantB->workspace_id,
'type' => 'inventory.sync',
'initiator_name' => 'TenantB',
]);
Filament::setTenant($tenantA, true);
$this->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id,
]);
session([
WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id,
]);
$component = Livewire::actingAs($user)
->test(ListOperationRuns::class)
->assertCanSeeTableRecords([$runA])
->assertCanNotSeeTableRecords([$runB]);
$component
->filterTable('tenant_id', null)
->assertCanSeeTableRecords([$runA, $runB]);
});
it('has reserved Monitoring placeholder pages for Alerts and Audit Log', 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/alerts')
->assertOk();
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get('/admin/audit-log')
->assertOk();
});

View File

@ -1,7 +1,8 @@
<?php
use App\Filament\Resources\OperationRunResource;
use App\Models\OperationRun;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Bus;
it('renders Monitoring → Operations index DB-only (no outbound HTTP, no background work)', function () {
@ -19,8 +20,11 @@
Bus::fake();
Filament::setTenant(null, true);
assertNoOutboundHttp(function () use ($tenant) {
$this->get(OperationRunResource::getUrl('index', tenant: $tenant))
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get('/admin/operations')
->assertOk()
->assertSee('Total Runs (30 days)')
->assertSee('Active Runs')
@ -51,10 +55,13 @@
Bus::fake();
Filament::setTenant(null, true);
assertNoOutboundHttp(function () use ($tenant, $run) {
$this->get(OperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant))
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertOk()
->assertSee('Policy sync');
->assertSee('Operation run');
});
Bus::assertNothingDispatched();

View File

@ -1,18 +1,20 @@
<?php
use App\Filament\Resources\OperationRunResource;
use App\Filament\Resources\OperationRunResource\Pages\ListOperationRuns;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Livewire\Livewire;
it('scopes Monitoring → Operations list to the active tenant', function () {
it('defaults Monitoring → Operations list to the active tenant when tenant context is set', 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'],
]);
@ -33,8 +35,11 @@
'initiator_name' => 'TenantB',
]);
Filament::setTenant($tenantA, true);
$this->actingAs($user)
->get(OperationRunResource::getUrl('index', tenant: $tenantA))
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id])
->get('/admin/operations')
->assertOk()
->assertSee('Policy sync')
->assertSee('TenantA')
@ -48,6 +53,8 @@
[$user] = createUserWithTenant($tenantA, role: 'owner');
$tenantB->forceFill(['workspace_id' => (int) $tenantA->workspace_id])->save();
$user->tenants()->syncWithoutDetaching([
$tenantB->getKey() => ['role' => 'owner'],
]);
@ -103,6 +110,13 @@
$tenantA->makeCurrent();
Filament::setTenant($tenantA, true);
$this->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id,
]);
session([
WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id,
]);
Livewire::actingAs($user)
->test(ListOperationRuns::class)
->assertCanSeeTableRecords([$runActiveA, $runSucceededA, $runPartialA, $runFailedA])
@ -121,16 +135,12 @@
->assertCanNotSeeTableRecords([$runActiveA, $runSucceededA, $runPartialA, $runActiveB, $runFailedB]);
});
it('prevents cross-tenant access to Monitoring → Operations detail', function () {
it('prevents cross-workspace access to Monitoring → Operations detail', function () {
$tenantA = Tenant::factory()->create();
[$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner');
$tenantB = Tenant::factory()->create();
[$user] = createUserWithTenant($tenantA, role: 'owner');
$user->tenants()->syncWithoutDetaching([
$tenantB->getKey() => ['role' => 'owner'],
]);
$runB = OperationRun::factory()->create([
'tenant_id' => $tenantB->getKey(),
'type' => 'inventory.sync',
@ -140,6 +150,7 @@
]);
$this->actingAs($user)
->get(OperationRunResource::getUrl('view', ['record' => $runB], tenant: $tenantA))
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id])
->get(route('admin.operations.view', ['run' => (int) $runB->getKey()]))
->assertNotFound();
});

View File

@ -1,17 +1,21 @@
<?php
use App\Filament\Resources\OperationRunResource;
declare(strict_types=1);
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Graph\GraphClientInterface;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
it('allows access to monitoring page for tenant members', function () {
it('allows access to Monitoring → Operations for workspace members', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
$run = OperationRun::create([
'tenant_id' => $tenant->id,
OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'policy.sync',
'status' => 'queued',
'outcome' => 'pending',
@ -19,18 +23,22 @@
'run_identity_hash' => 'hash123',
]);
Filament::setTenant(null, true);
$this->actingAs($user)
->get(OperationRunResource::getUrl('index', tenant: $tenant))
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get('/admin/operations')
->assertSuccessful()
->assertSee('Policy sync');
});
it('renders monitoring pages DB-only (never calls Graph)', function () {
it('renders Monitoring → Operations pages DB-only (never calls Graph)', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
$run = OperationRun::create([
'tenant_id' => $tenant->id,
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'policy.sync',
'status' => 'queued',
'outcome' => 'pending',
@ -47,59 +55,62 @@
$mock->shouldReceive('request')->never();
});
Filament::setTenant(null, true);
$this->actingAs($user)
->get(OperationRunResource::getUrl('index', tenant: $tenant))
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get('/admin/operations')
->assertSuccessful();
$this->actingAs($user)
->get(OperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant))
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertSuccessful();
});
it('shows runs only for current tenant', function () {
it('defaults the operations list to the active tenant when tenant context is set', function (): void {
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();
[$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner');
// We must simulate being in tenant context
$this->actingAs($user);
// Filament::setTenant($tenantA); // This is usually handled by middleware on routes, but in Livewire test we might need manual set or route visit.
$tenantB = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $tenantA->workspace_id,
]);
// Easier approach: visit the page for tenantA
$user->tenants()->syncWithoutDetaching([
$tenantB->getKey() => ['role' => 'owner'],
]);
OperationRun::create([
'tenant_id' => $tenantA->id,
OperationRun::factory()->create([
'tenant_id' => (int) $tenantA->getKey(),
'workspace_id' => (int) $tenantA->workspace_id,
'type' => 'policy.sync',
'status' => 'queued',
'outcome' => 'pending',
'initiator_name' => 'System',
'run_identity_hash' => 'hashA',
'initiator_name' => 'TenantA',
]);
OperationRun::create([
'tenant_id' => $tenantB->id,
OperationRun::factory()->create([
'tenant_id' => (int) $tenantB->getKey(),
'workspace_id' => (int) $tenantB->workspace_id,
'type' => 'inventory.sync',
'status' => 'queued',
'outcome' => 'pending',
'initiator_name' => 'System',
'run_identity_hash' => 'hashB',
'initiator_name' => 'TenantB',
]);
// Livewire::test needs to know the tenant if the component relies on it.
// However, the component relies on `Filament::getTenant()`.
// The cleanest way is to just GET the page URL, which runs middleware.
Filament::setTenant($tenantA, true);
$this->get(OperationRunResource::getUrl('index', tenant: $tenantA))
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id])
->get('/admin/operations')
->assertSee('Policy sync')
->assertDontSee('Inventory sync');
});
it('allows readonly users to view operations list and detail', function () {
it('allows readonly users to view operations list and detail', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant($tenant, role: 'readonly');
$run = OperationRun::create([
'tenant_id' => $tenant->id,
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'policy.sync',
'status' => 'queued',
'outcome' => 'pending',
@ -107,30 +118,27 @@
'run_identity_hash' => 'hash123',
]);
Filament::setTenant(null, true);
$this->actingAs($user)
->get(OperationRunResource::getUrl('index', tenant: $tenant))
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get('/admin/operations')
->assertSuccessful()
->assertSee('Policy sync');
$this->actingAs($user)
->get(OperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant))
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertSuccessful()
->assertSee('Policy sync');
->assertSee('Operation run');
});
it('denies access to unauthorized users', function () {
$tenant = Tenant::factory()->create();
it('returns 404 when viewing an operation run outside workspace membership', function (): void {
$run = OperationRun::factory()->create();
$user = User::factory()->create();
// Not attached to tenant
// In a multitenant app, if you try to access a tenant route you are not part of,
// Filament typically returns 404 (Not Found) if it can't find the tenant-user relationship, or 403.
// The previous fail said "Received 404". This confirms Filament couldn't find the tenant for this user scope or just hides it.
// We should accept 404 or 403.
$response = $this->actingAs($user)
->get(OperationRunResource::getUrl('index', tenant: $tenant));
// Allow either 403 or 404 as "Denied"
$this->assertTrue(in_array($response->status(), [403, 404]));
$this->actingAs($user)
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertNotFound();
});

View File

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
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 Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('returns 404 when a non-member tries to view another workspace operation run', function (): void {
$workspaceA = Workspace::factory()->create();
$workspaceB = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspaceA->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$tenantB = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $workspaceB->getKey(),
]);
$runB = OperationRun::factory()->create([
'tenant_id' => (int) $tenantB->getKey(),
'workspace_id' => (int) $workspaceB->getKey(),
'type' => 'policy.sync',
'initiator_name' => 'WorkspaceB',
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspaceA->getKey()])
->get(route('admin.operations.view', ['run' => (int) $runB->getKey()]))
->assertNotFound();
});
it('returns 403 when a workspace member without workspace.manage tries to edit a workspace', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'manager',
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get('/admin/workspaces/'.(int) $workspace->getKey().'/edit')
->assertForbidden();
});

View File

@ -1,71 +1,93 @@
<?php
use App\Filament\Resources\OperationRunResource;
declare(strict_types=1);
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;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
test('operation runs are listed for the active tenant', function () {
test('operation runs default to the active tenant when tenant context is set', function (): void {
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();
[$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner');
$tenantB = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $tenantA->workspace_id,
]);
$user->tenants()->syncWithoutDetaching([
$tenantB->getKey() => ['role' => 'owner'],
]);
OperationRun::factory()->create([
'tenant_id' => $tenantA->getKey(),
'tenant_id' => (int) $tenantA->getKey(),
'workspace_id' => (int) $tenantA->workspace_id,
'type' => 'policy.sync',
'status' => 'queued',
'outcome' => 'pending',
]);
OperationRun::factory()->create([
'tenant_id' => $tenantB->getKey(),
'tenant_id' => (int) $tenantB->getKey(),
'workspace_id' => (int) $tenantB->workspace_id,
'type' => 'inventory.sync',
'status' => 'queued',
'outcome' => 'pending',
]);
[$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner');
$tenantB->forceFill(['workspace_id' => (int) $tenantA->workspace_id])->save();
$user->tenants()->syncWithoutDetaching([
$tenantB->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenantA, true);
$this->actingAs($user)
->get(OperationRunResource::getUrl('index', tenant: $tenantA))
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id])
->get('/admin/operations')
->assertOk()
->assertSee('Policy sync')
->assertDontSee('Inventory sync');
});
test('operation run view is not accessible cross-tenant', function () {
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();
test('operation run view is not accessible cross-workspace', function (): void {
$workspaceA = Workspace::factory()->create();
$workspaceB = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspaceA->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$tenantB = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $workspaceB->getKey(),
]);
$runB = OperationRun::factory()->create([
'tenant_id' => $tenantB->getKey(),
'tenant_id' => (int) $tenantB->getKey(),
'workspace_id' => (int) $workspaceB->getKey(),
'type' => 'inventory.sync',
'status' => 'queued',
'outcome' => 'pending',
]);
[$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner');
$tenantB->forceFill(['workspace_id' => (int) $tenantA->workspace_id])->save();
$user->tenants()->syncWithoutDetaching([
$tenantB->getKey() => ['role' => 'owner'],
]);
$this->actingAs($user)
->get(OperationRunResource::getUrl('view', ['record' => $runB], tenant: $tenantA))
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspaceA->getKey()])
->get(route('admin.operations.view', ['run' => (int) $runB->getKey()]))
->assertNotFound();
});
test('readonly users can view operation runs for their tenant', function () {
test('readonly users can view operation runs in their workspace', function (): void {
$tenant = Tenant::factory()->create();
$run = OperationRun::factory()->create([
'tenant_id' => $tenant->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'drift.generate',
'status' => 'queued',
'outcome' => 'pending',
@ -73,13 +95,17 @@
[$user, $tenant] = createUserWithTenant($tenant, role: 'readonly');
Filament::setTenant(null, true);
$this->actingAs($user)
->get(OperationRunResource::getUrl('index', tenant: $tenant))
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get('/admin/operations')
->assertOk()
->assertSee('Drift generation');
$this->actingAs($user)
->get(OperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant))
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertOk()
->assertSee('Drift generation');
->assertSee('Operation run');
});

View File

@ -58,6 +58,8 @@
'tenant_id' => 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
]);
$tenantInOther->makeCurrent();
$user->tenants()->syncWithoutDetaching([
$tenantInOther->getKey() => ['role' => 'owner'],
]);

View File

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
use App\Support\Workspaces\WorkspaceContext;
use App\Support\Workspaces\WorkspaceIntendedUrl;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('stores and consumes an intended admin URL (path + query)', function (): void {
session()->forget(WorkspaceContext::INTENDED_URL_SESSION_KEY);
WorkspaceIntendedUrl::store('/admin/operations?tab=active');
expect(session(WorkspaceContext::INTENDED_URL_SESSION_KEY))->toBe('/admin/operations?tab=active');
$consumed = WorkspaceIntendedUrl::consume();
expect($consumed)->toBe('/admin/operations?tab=active');
expect(session()->has(WorkspaceContext::INTENDED_URL_SESSION_KEY))->toBeFalse();
});
it('rejects non-admin intended URLs', function (): void {
session()->forget(WorkspaceContext::INTENDED_URL_SESSION_KEY);
WorkspaceIntendedUrl::store('/logout');
expect(session()->has(WorkspaceContext::INTENDED_URL_SESSION_KEY))->toBeFalse();
});
it('rejects absolute URLs and protocol-relative URLs', function (): void {
session()->forget(WorkspaceContext::INTENDED_URL_SESSION_KEY);
WorkspaceIntendedUrl::store('https://example.com/admin/operations');
expect(session()->has(WorkspaceContext::INTENDED_URL_SESSION_KEY))->toBeFalse();
WorkspaceIntendedUrl::store('//example.com/admin/operations');
expect(session()->has(WorkspaceContext::INTENDED_URL_SESSION_KEY))->toBeFalse();
});

View File

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('shows "Switch workspace" navigation when no tenant is selected', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
Filament::setTenant(null, true);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get('/admin/operations')
->assertOk();
$panel = Filament::getCurrentOrDefaultPanel();
$labels = collect($panel->getNavigationItems())
->map(static fn ($item): string => $item->getLabel())
->all();
expect($labels)->toContain('Switch workspace');
expect($labels)->not->toContain('Workspaces');
});

View File

@ -73,3 +73,21 @@
->get('/admin/t/11111111-1111-1111-1111-111111111111/workspaces')
->assertNotFound();
});
it('returns 404 when accessing a workspace record outside membership', function (): void {
$user = User::factory()->create();
$workspaceA = Workspace::factory()->create(['slug' => 'acme-a']);
WorkspaceMembership::factory()->create([
'workspace_id' => $workspaceA->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$workspaceB = Workspace::factory()->create(['slug' => 'acme-b']);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspaceA->getKey()])
->get('/admin/workspaces/'.(int) $workspaceB->getKey())
->assertNotFound();
});