TenantAtlas/app/Http/Middleware/EnsureWorkspaceSelected.php
Ahmed Darrazi 051db1842d feat(107): implement Workspace Chooser v1 — all 40 tasks complete
Spec 107: Workspace Chooser v1 (Enterprise) + In-App Switch Entry Point

## Core changes
- Refactor EnsureWorkspaceSelected middleware: 7-step algorithm with
  auto-resume (single membership + last_workspace_id), stale session
  detection, ?choose=1 forced chooser, workspace-optional path bypass
- Create WorkspaceRedirectResolver for DRY tenant-count branching
  (0→managed-tenants, 1→tenant-dashboard, >1→choose-tenant)
- Add WorkspaceAutoSelected + WorkspaceSelected audit enum cases
- Rewrite ChooseWorkspace page: role badges, tenant counts,
  wire:click selection, audit logging, WorkspaceRedirectResolver
- Add 'Switch workspace' user menu item in AdminPanelProvider
- Rewrite SwitchWorkspaceController with audit + resolver
- Replace inline tenant branching in routes/web.php with resolver

## New test files (6)
- WorkspaceRedirectResolverTest (5 tests
Spec 107: Workspace Chooser v1 (Enterprise) + In-App Switch Entry Point

## Core changes
- Refactor EnsureWorkspaceSelected middleware: 7-step algorithmst
## Core changes
- Refactor EnsureWorkspaceSelected middleware: 7-stepes - Refactor Ensng  auto-resume (single membership + last_workspace_id), stale sessioid  detection, ?choose=1 forced chooser, w (security invariant preserve- Create WorkspaceRedirectResolver for DRY tenant-count branching

  (0→managed-tenants, 1→tenant-dashboapped (8163 assertions)
2026-02-22 17:19:19 +01:00

250 lines
8.4 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Support\Audit\AuditActionId;
use App\Support\Workspaces\WorkspaceContext;
use App\Support\Workspaces\WorkspaceIntendedUrl;
use App\Support\Workspaces\WorkspaceRedirectResolver;
use Closure;
use Filament\Notifications\Notification;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureWorkspaceSelected
{
/**
* Handle an incoming request.
*
* Spec 107 — 7-step algorithm:
* 1. If workspace-optional path → allow
* 2. If ?choose=1 → redirect to chooser
* 3. If session set → validate membership; stale → clear + warn + chooser
* 4. Load selectable memberships
* 5. If exactly 1 → auto-select + audit + redirect via tenant branching
* 6. If last_workspace_id valid → auto-select + audit + redirect
* 7. Else → redirect to chooser
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
// Auth-related routes are always allowed.
$routeName = $request->route()?->getName();
if (is_string($routeName) && str_contains($routeName, '.auth.')) {
return $next($request);
}
$path = '/'.ltrim($request->path(), '/');
// --- Step 1: workspace-optional bypass ---
if ($this->isWorkspaceOptionalPath($request, $path)) {
return $next($request);
}
// Tenant-scoped routes are handled separately.
if (str_starts_with($path, '/admin/t/')) {
return $next($request);
}
$user = $request->user();
if (! $user instanceof User) {
return $next($request);
}
/** @var WorkspaceContext $context */
$context = app(WorkspaceContext::class);
// --- Step 2: forced chooser via ?choose=1 ---
if ($request->query('choose') === '1') {
return $this->redirectToChooser();
}
// --- Step 3: validate active session ---
$currentId = $context->currentWorkspaceId($request);
if ($currentId !== null) {
$workspace = Workspace::query()->whereKey($currentId)->first();
if (
$workspace instanceof Workspace
&& empty($workspace->archived_at)
&& $context->isMember($user, $workspace)
) {
return $next($request);
}
// Stale session — clear and warn.
$this->clearStaleSession($context, $user, $request, $workspace);
return $this->redirectToChooser();
}
// --- Step 4: load selectable workspace memberships ---
$selectableMemberships = WorkspaceMembership::query()
->where('user_id', $user->getKey())
->join('workspaces', 'workspace_memberships.workspace_id', '=', 'workspaces.id')
->whereNull('workspaces.archived_at')
->select('workspace_memberships.*')
->get();
// --- Step 5: single membership auto-resume ---
if ($selectableMemberships->count() === 1) {
/** @var WorkspaceMembership $membership */
$membership = $selectableMemberships->first();
$workspace = Workspace::query()->whereKey($membership->workspace_id)->first();
if ($workspace instanceof Workspace) {
$context->setCurrentWorkspace($workspace, $user, $request);
$this->emitAuditEvent(
workspace: $workspace,
user: $user,
actionId: AuditActionId::WorkspaceAutoSelected,
method: 'auto',
reason: 'single_membership',
);
return $this->redirectViaTenantBranching($workspace, $user);
}
}
// --- Step 6: last_workspace_id auto-resume ---
if ($user->last_workspace_id !== null) {
$lastWorkspace = Workspace::query()->whereKey($user->last_workspace_id)->first();
if (
$lastWorkspace instanceof Workspace
&& empty($lastWorkspace->archived_at)
&& $context->isMember($user, $lastWorkspace)
) {
$context->setCurrentWorkspace($lastWorkspace, $user, $request);
$this->emitAuditEvent(
workspace: $lastWorkspace,
user: $user,
actionId: AuditActionId::WorkspaceAutoSelected,
method: 'auto',
reason: 'last_used',
);
return $this->redirectViaTenantBranching($lastWorkspace, $user);
}
// Stale last_workspace_id — clear and warn.
$workspaceName = $lastWorkspace?->name;
$user->forceFill(['last_workspace_id' => null])->save();
if ($workspaceName !== null) {
Notification::make()
->title("Your access to {$workspaceName} was removed.")
->danger()
->send();
}
}
// --- Step 7: fallback to chooser ---
if ($selectableMemberships->isNotEmpty()) {
WorkspaceIntendedUrl::storeFromRequest($request);
}
$canCreate = $user->can('create', Workspace::class);
$target = ($selectableMemberships->isNotEmpty() || $canCreate)
? '/admin/choose-workspace'
: '/admin/no-access';
return new \Illuminate\Http\Response('', 302, ['Location' => $target]);
}
private function isWorkspaceOptionalPath(Request $request, string $path): bool
{
if (str_starts_with($path, '/admin/workspaces')) {
return true;
}
if (in_array($path, ['/admin/choose-workspace', '/admin/no-access', '/admin/onboarding', '/admin/settings/workspace'], true)) {
return true;
}
if ($path === '/livewire/update') {
$refererPath = parse_url((string) $request->headers->get('referer', ''), PHP_URL_PATH) ?? '';
$refererPath = '/'.ltrim((string) $refererPath, '/');
if (preg_match('#^/admin/operations/[^/]+$#', $refererPath) === 1) {
return true;
}
}
return preg_match('#^/admin/operations/[^/]+$#', $path) === 1;
}
private function redirectToChooser(): Response
{
return new \Illuminate\Http\Response('', 302, ['Location' => '/admin/choose-workspace']);
}
private function redirectViaTenantBranching(Workspace $workspace, User $user): Response
{
/** @var WorkspaceRedirectResolver $resolver */
$resolver = app(WorkspaceRedirectResolver::class);
$url = $resolver->resolve($workspace, $user);
return new \Illuminate\Http\Response('', 302, ['Location' => $url]);
}
private function clearStaleSession(WorkspaceContext $context, User $user, Request $request, ?Workspace $workspace): void
{
$workspaceName = $workspace?->name;
$session = $request->hasSession() ? $request->session() : session();
$session->forget(WorkspaceContext::SESSION_KEY);
if ($user->last_workspace_id !== null && $context->currentWorkspaceId($request) === null) {
$user->forceFill(['last_workspace_id' => null])->save();
}
if ($workspaceName !== null) {
Notification::make()
->title("Your access to {$workspaceName} was removed.")
->danger()
->send();
}
}
private function emitAuditEvent(
Workspace $workspace,
User $user,
AuditActionId $actionId,
string $method,
string $reason,
?int $prevWorkspaceId = null,
): void {
/** @var WorkspaceAuditLogger $logger */
$logger = app(WorkspaceAuditLogger::class);
$logger->log(
workspace: $workspace,
action: $actionId->value,
context: [
'metadata' => [
'method' => $method,
'reason' => $reason,
'prev_workspace_id' => $prevWorkspaceId,
],
],
actor: $user,
resourceType: 'workspace',
resourceId: (string) $workspace->getKey(),
);
}
}