TenantAtlas/app/Support/Workspaces/WorkspaceContext.php
ahmido 5ec62cd117 feat: harden livewire trusted state boundaries (#182)
## Summary
- add the shared trusted-state model and resolver helpers for first-slice Livewire and Filament surfaces
- harden managed tenant onboarding, tenant required permissions, and system runbooks against forged or stale public state
- add focused Pest guard and regression coverage plus the complete spec 152 artifact set

## Validation
- `vendor/bin/sail artisan test --compact`
- manual smoke validated on `/admin/onboarding/{onboardingDraft}`
- manual smoke validated on `/admin/tenants/{tenant}/required-permissions`
- manual smoke validated on `/system/ops/runbooks`

## Notes
- Livewire v4.0+ / Filament v5 stack unchanged
- no new panels, routes, assets, or global-search changes
- provider registration remains in `bootstrap/providers.php`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #182
2026-03-18 23:01:14 +00:00

315 lines
9.7 KiB
PHP

<?php
namespace App\Support\Workspaces;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Tenants\TenantOperabilityService;
use App\Support\Tenants\TenantInteractionLane;
use App\Support\Tenants\TenantOperabilityQuestion;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
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,
private TenantOperabilityService $tenantOperabilityService,
) {}
public function currentWorkspaceId(?Request $request = null): ?int
{
$session = ($request && $request->hasSession()) ? $request->session() : session();
$id = $session->get(self::SESSION_KEY);
return is_int($id) ? $id : (is_numeric($id) ? (int) $id : null);
}
public function currentWorkspace(?Request $request = null): ?Workspace
{
$id = $this->currentWorkspaceId($request);
if (! $id) {
return null;
}
$workspace = Workspace::query()->whereKey($id)->first();
if (! $workspace) {
return null;
}
if (! $this->isWorkspaceSelectable($workspace)) {
return null;
}
return $workspace;
}
public function setCurrentWorkspace(Workspace $workspace, ?User $user = null, ?Request $request = null): void
{
$session = ($request && $request->hasSession()) ? $request->session() : session();
$session->put(self::SESSION_KEY, (int) $workspace->getKey());
if ($user !== null) {
$user->forceFill(['last_workspace_id' => (int) $workspace->getKey()])->save();
}
}
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 rememberTenantContext(Tenant $tenant, ?Request $request = null): bool
{
$workspaceId = $this->currentWorkspaceId($request);
if ($workspaceId === null || (int) $tenant->workspace_id !== $workspaceId) {
return false;
}
$outcome = $this->tenantOperabilityService->outcomeFor(
tenant: $tenant,
actor: $request?->user() instanceof User ? $request->user() : auth()->user(),
workspaceId: $workspaceId,
lane: TenantInteractionLane::StandardActiveOperating,
question: TenantOperabilityQuestion::RememberedContextValidity,
);
if (! $outcome->allowed) {
$this->clearLastTenantId($request);
return false;
}
if (! $this->userCanAccessTenant($tenant, $request)) {
$this->clearRememberedTenantContext($request);
return false;
}
$this->rememberLastTenantId($workspaceId, (int) $tenant->getKey(), $request);
return true;
}
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 clearRememberedTenantContext(?Request $request = null): void
{
$this->clearLastTenantId($request);
}
public function rememberedTenant(?Request $request = null): ?Tenant
{
$workspaceId = $this->currentWorkspaceId($request);
if ($workspaceId === null) {
return null;
}
$rememberedTenantId = $this->lastTenantId($request);
if ($rememberedTenantId === null) {
return null;
}
$tenant = Tenant::query()
->withTrashed()
->whereKey($rememberedTenantId)
->first();
if (! $tenant instanceof Tenant) {
$this->clearRememberedTenantContext($request);
return null;
}
if ((int) $tenant->workspace_id !== $workspaceId) {
$this->clearRememberedTenantContext($request);
return null;
}
if (! $this->userCanAccessTenant($tenant, $request)) {
$this->clearRememberedTenantContext($request);
return null;
}
$outcome = $this->tenantOperabilityService->outcomeFor(
tenant: $tenant,
actor: $request?->user() instanceof User ? $request->user() : auth()->user(),
workspaceId: $workspaceId,
lane: TenantInteractionLane::StandardActiveOperating,
question: TenantOperabilityQuestion::RememberedContextValidity,
);
if (! $outcome->allowed) {
$this->clearRememberedTenantContext($request);
return null;
}
return $tenant;
}
public function clearCurrentWorkspace(?User $user = null, ?Request $request = null): void
{
$session = ($request && $request->hasSession()) ? $request->session() : session();
$session->forget(self::SESSION_KEY);
if ($user !== null && $user->last_workspace_id !== null) {
$user->forceFill(['last_workspace_id' => null])->save();
}
}
public function resolveInitialWorkspaceFor(User $user, ?Request $request = null): ?Workspace
{
$session = ($request && $request->hasSession()) ? $request->session() : session();
$currentId = $this->currentWorkspaceId($request);
if ($currentId) {
$current = Workspace::query()->whereKey($currentId)->first();
if (! $current instanceof Workspace || ! $this->isWorkspaceSelectable($current) || ! $this->isMember($user, $current)) {
$session->forget(self::SESSION_KEY);
if ((int) $user->last_workspace_id === (int) $currentId) {
$user->forceFill(['last_workspace_id' => null])->save();
}
} else {
return $current;
}
}
if ($user->last_workspace_id !== null) {
$workspace = Workspace::query()->whereKey($user->last_workspace_id)->first();
if (! $workspace instanceof Workspace || ! $this->isWorkspaceSelectable($workspace) || ! $this->isMember($user, $workspace)) {
$user->forceFill(['last_workspace_id' => null])->save();
}
}
$memberships = WorkspaceMembership::query()
->where('user_id', $user->getKey())
->with('workspace')
->get();
$selectableWorkspaces = $memberships
->map(fn (WorkspaceMembership $membership) => $membership->workspace)
->filter(fn (?Workspace $workspace) => $workspace instanceof Workspace && $this->isWorkspaceSelectable($workspace))
->values();
if ($selectableWorkspaces->count() === 1) {
/** @var Workspace $workspace */
$workspace = $selectableWorkspaces->first();
$session->put(self::SESSION_KEY, (int) $workspace->getKey());
$user->forceFill(['last_workspace_id' => (int) $workspace->getKey()])->save();
return $workspace;
}
return null;
}
public function isMember(User $user, Workspace $workspace): bool
{
return WorkspaceMembership::query()
->where('user_id', $user->getKey())
->where('workspace_id', $workspace->getKey())
->exists();
}
public function currentWorkspaceForMemberOrFail(User $user, ?Request $request = null): Workspace
{
$workspace = $this->currentWorkspace($request);
if (! $workspace instanceof Workspace || ! $this->isMember($user, $workspace)) {
throw new NotFoundHttpException;
}
return $workspace;
}
public function ensureTenantAccessibleInCurrentWorkspace(Tenant $tenant, User $user, ?Request $request = null): Tenant
{
$workspace = $this->currentWorkspaceForMemberOrFail($user, $request);
if ((int) $tenant->workspace_id !== (int) $workspace->getKey() || ! $user->canAccessTenant($tenant)) {
throw new NotFoundHttpException;
}
return $tenant;
}
private function isWorkspaceSelectable(Workspace $workspace): bool
{
return empty($workspace->archived_at);
}
private function userCanAccessTenant(Tenant $tenant, ?Request $request = null): bool
{
$user = $request?->user();
if (! $user instanceof User) {
$user = auth()->user();
}
return $user instanceof User && $user->canAccessTenant($tenant);
}
}