From 717e2d95a37bfd7f5ca0e8b156f7e23b9d911a01 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Mon, 2 Feb 2026 10:58:01 +0100 Subject: [PATCH 01/10] spec: add workspace/tenant enforcement specs (070-072) --- .../plan.md | 15 ++++++++ .../spec.md | 16 +++++++++ .../tasks.md | 11 ++++++ .../plan.md | 14 ++++++++ .../spec.md | 18 ++++++++++ .../tasks.md | 11 ++++++ .../plan.md | 32 +++++++++++++++++ .../spec.md | 34 +++++++++++++++++++ .../tasks.md | 20 +++++++++++ 9 files changed, 171 insertions(+) create mode 100644 specs/070-workspace-create-membership-fix/plan.md create mode 100644 specs/070-workspace-create-membership-fix/spec.md create mode 100644 specs/070-workspace-create-membership-fix/tasks.md create mode 100644 specs/071-tenant-selection-workspace-scope/plan.md create mode 100644 specs/071-tenant-selection-workspace-scope/spec.md create mode 100644 specs/071-tenant-selection-workspace-scope/tasks.md create mode 100644 specs/072-managed-tenants-workspace-enforcement/plan.md create mode 100644 specs/072-managed-tenants-workspace-enforcement/spec.md create mode 100644 specs/072-managed-tenants-workspace-enforcement/tasks.md diff --git a/specs/070-workspace-create-membership-fix/plan.md b/specs/070-workspace-create-membership-fix/plan.md new file mode 100644 index 0000000..8952d0b --- /dev/null +++ b/specs/070-workspace-create-membership-fix/plan.md @@ -0,0 +1,15 @@ +# Plan — 070 Workspace create: ensure creator membership + +## Tech +- Laravel 12 +- Filament v5 + Livewire v4 + +## Approach +1. Hook into the Workspaces resource create page lifecycle (CreateRecord) to run post-create logic. +2. Create (or ensure) a `workspace_memberships` row for the creator with role `owner`. +3. Set the created workspace as the current workspace in `WorkspaceContext`. +4. Add a Pest regression test that creates a workspace via the Filament create page and asserts membership exists. + +## Files +- `app/Filament/Resources/Workspaces/Pages/CreateWorkspace.php` +- `tests/Feature/Workspaces/CreateWorkspaceCreatesMembershipTest.php` diff --git a/specs/070-workspace-create-membership-fix/spec.md b/specs/070-workspace-create-membership-fix/spec.md new file mode 100644 index 0000000..39440a8 --- /dev/null +++ b/specs/070-workspace-create-membership-fix/spec.md @@ -0,0 +1,16 @@ +# Spec — 070 Workspace create: ensure creator membership + +## Problem +Creating a workspace via the Filament **Workspaces** Resource can create the `workspaces` row but not grant the creating user a `workspace_memberships` record. Since workspace listings are membership-scoped, the newly created workspace may not appear for the creator. + +## Goals +- When a workspace is created via the Workspaces resource, the creating user becomes an `owner` member of that workspace. +- The created workspace becomes the current workspace context for the user. + +## Non-goals +- Changing workspace listing scoping rules. +- Changing roles/permissions beyond assigning `owner` on creation. + +## Acceptance criteria +- Creating a workspace via the Filament Workspaces resource results in a `workspace_memberships` row for the creating user. +- A regression test covers the behavior. diff --git a/specs/070-workspace-create-membership-fix/tasks.md b/specs/070-workspace-create-membership-fix/tasks.md new file mode 100644 index 0000000..12293a1 --- /dev/null +++ b/specs/070-workspace-create-membership-fix/tasks.md @@ -0,0 +1,11 @@ +# Tasks — 070 Workspace create: ensure creator membership + +## Core +- [x] T020 Ensure creating via Workspaces resource grants creator a workspace membership. + +## Tests +- [x] T010 Add regression test for workspace creation creating membership. + +## Validation +- [x] T900 Run Pint on dirty files. +- [x] T910 Run targeted Pest test(s). diff --git a/specs/071-tenant-selection-workspace-scope/plan.md b/specs/071-tenant-selection-workspace-scope/plan.md new file mode 100644 index 0000000..5020d18 --- /dev/null +++ b/specs/071-tenant-selection-workspace-scope/plan.md @@ -0,0 +1,14 @@ +# Plan — 071 Workspace-scoped tenant selection + +## Tech +- Laravel 12 +- Filament v5 + Livewire v4 + +## Approach +1. Update the Filament tenant provider on `User` (`getTenants()` and `getDefaultTenant()`) to optionally filter by the current workspace id from `WorkspaceContext`. +2. Add a feature test asserting `/admin/choose-tenant` only shows tenants from the selected workspace. +3. Run Pint and targeted tests. + +## Files +- `app/Models/User.php` +- `tests/Feature/Filament/ChooseTenantIsWorkspaceScopedTest.php` diff --git a/specs/071-tenant-selection-workspace-scope/spec.md b/specs/071-tenant-selection-workspace-scope/spec.md new file mode 100644 index 0000000..c1d9b5a --- /dev/null +++ b/specs/071-tenant-selection-workspace-scope/spec.md @@ -0,0 +1,18 @@ +# Spec — 071 Workspace-scoped tenant selection + +## Problem +Tenant selection and the Filament tenant menu were not scoped to the currently selected workspace. As a result, selecting a newly created workspace could still show tenants from a different workspace (e.g. “Entra ID (DEV)”), leading to confusing flows where tenant-scoped pages show empty lists. + +## Goals +- Only show tenants belonging to the currently selected workspace in: + - `/admin/choose-tenant` + - Filament tenant menu dropdown +- Keep behavior unchanged when no workspace is selected. + +## Non-goals +- Auto-creating tenants when a workspace is created. +- Changing authorization rules beyond filtering the selectable tenant list. + +## Acceptance criteria +- With workspace A selected, a user who is a member of tenants in A and B only sees A’s tenants on `/admin/choose-tenant`. +- Regression test covers the behavior. diff --git a/specs/071-tenant-selection-workspace-scope/tasks.md b/specs/071-tenant-selection-workspace-scope/tasks.md new file mode 100644 index 0000000..7a359e5 --- /dev/null +++ b/specs/071-tenant-selection-workspace-scope/tasks.md @@ -0,0 +1,11 @@ +# Tasks — 071 Workspace-scoped tenant selection + +## Core +- [x] T020 Scope `User::getTenants()` and `User::getDefaultTenant()` to current workspace when selected. + +## Tests +- [x] T010 Add regression test for workspace-scoped choose-tenant. + +## Validation +- [x] T900 Run Pint on dirty files. +- [x] T910 Run targeted Pest test(s). diff --git a/specs/072-managed-tenants-workspace-enforcement/plan.md b/specs/072-managed-tenants-workspace-enforcement/plan.md new file mode 100644 index 0000000..cb5fb9f --- /dev/null +++ b/specs/072-managed-tenants-workspace-enforcement/plan.md @@ -0,0 +1,32 @@ +# Plan — 072 Managed Tenants workspace context enforcement + +## Tech +- Laravel 12 +- Filament v5 + Livewire v4 +- Pest v4 + +## Approach +1. Treat `/admin/w/{workspace}/...` as the portfolio / workspace entry space. +2. Move Managed Tenants list/onboarding UX to workspace-scoped routes. +3. Make `/admin/managed-tenants/*` legacy-only (redirect to the correct workspace-scoped URL). +4. Enforce workspace/tenant consistency for all `/admin/t/{tenant}` routes (deny-as-not-found on mismatch). + +## Key decisions +- **Workspace is not Filament tenancy**; it remains session + middleware. +- Hard enforcement is implemented in middleware that runs on tenant-scoped routes. +- Prefer redirects over removing routes immediately, to avoid breaking deep links, but ensure they are no longer primary UX. + +## Files (expected) +- `routes/web.php` +- `app/Providers/Filament/AdminPanelProvider.php` +- `app/Http/Middleware/EnsureWorkspaceSelected.php` +- `app/Support/Middleware/DenyNonMemberTenantAccess.php` (or `EnsureFilamentTenantSelected.php`, depending on existing enforcement location) +- `app/Filament/Pages/ManagedTenants/*` (legacy redirects / removal) +- New/updated workspace landing page under `app/Filament/Pages/Workspaces/*` (or equivalent) +- Pest tests in `tests/Feature/Routing/` or `tests/Feature/Filament/` + +## Test plan +- Feature test: `/admin/managed-tenants` redirects to `/admin/w/{workspace}/managed-tenants` when workspace is selected. +- Feature test: `/admin/t/{tenant}` returns 404 when workspace context missing. +- Feature test: `/admin/t/{tenant}` returns 404 when tenant.workspace_id != current workspace. +- Optional: workspace landing lists only workspace tenants. diff --git a/specs/072-managed-tenants-workspace-enforcement/spec.md b/specs/072-managed-tenants-workspace-enforcement/spec.md new file mode 100644 index 0000000..e3f1d68 --- /dev/null +++ b/specs/072-managed-tenants-workspace-enforcement/spec.md @@ -0,0 +1,34 @@ +# Spec — 072 Managed Tenants workspace context enforcement + +## Problem +Managed Tenant pages exist in an unscoped URL space (`/admin/managed-tenants/*`) while Managed Tenants are product-scoped to a Workspace (MSP portfolio). This makes workspace context feel optional and allows confusing / insecure navigation patterns where tenant context and workspace context can drift. + +## Mental model (source of truth) +- **Managed Tenant** = the Entra/Intune tenant. All policy/backup/drift/inventory features are always scoped to a Managed Tenant. + - In code: Filament tenancy (`/admin/t/{tenant_external_id}/...`). +- **Workspace** = portfolio container. Controls which Managed Tenants a user can see + portfolio-level settings. + - In code: session + `last_workspace_id` + middleware (not Filament tenancy). + +## Goals +- Workspace becomes a real, enforced context for all tenant-scoped pages. +- Keep Filament tenancy URL space unchanged: `/admin/t/{tenant_external_id}/...`. +- Introduce / use a workspace-scoped landing space for portfolio UX: `/admin/w/{workspace}/...`. +- Eliminate or redirect legacy unscoped Managed Tenants routes under `/admin/managed-tenants/*`. + +## Non-goals +- Redesigning all navigation IA or introducing a second Filament panel. +- Migrating existing tenant data beyond enforcing `tenants.workspace_id` consistency. + +## Hard rule (security / enterprise) +When accessing `/admin/t/{tenant}` routes: +- `current_workspace_id` must be set, and +- `tenant.workspace_id == current_workspace_id`, and +- user must be a member of the workspace (and/or tenant, per current auth model). +Otherwise: **deny as not found** (404). + +## Acceptance criteria +- `/admin/managed-tenants/*` does not act as a primary UX entry point anymore (redirects to workspace-scoped UX). +- `/admin/w/{workspace}/managed-tenants` exists as the primary portfolio landing for Managed Tenants. +- Tenant switcher only shows tenants from the current workspace. +- Visiting `/admin/t/{tenant}` with missing or mismatched workspace context results in 404. +- Pest tests cover redirects + workspace/tenant mismatch denial. diff --git a/specs/072-managed-tenants-workspace-enforcement/tasks.md b/specs/072-managed-tenants-workspace-enforcement/tasks.md new file mode 100644 index 0000000..f2d5053 --- /dev/null +++ b/specs/072-managed-tenants-workspace-enforcement/tasks.md @@ -0,0 +1,20 @@ +# Tasks — 072 Managed Tenants workspace context enforcement + +## Setup +- [x] T001 Confirm legacy managed-tenants routes and current workspace middleware behavior. + +## Tests (TDD) +- [x] T010 Add regression test: `/admin/managed-tenants` redirects to workspace landing when a workspace is selected. +- [x] T020 Add regression test: `/admin/t/{tenant}` is 404 when workspace context is missing. +- [x] T030 Add regression test: `/admin/t/{tenant}` is 404 when tenant.workspace_id mismatches current workspace. +- [x] T040 Add regression test: `/admin/choose-tenant` redirects to `/admin/choose-workspace` when workspace is not selected. + +## Core +- [x] T100 Create workspace-scoped Managed Tenants landing at `/admin/w/{workspace}/managed-tenants`. +- [x] T110 Make unscoped `/admin/managed-tenants/*` legacy-only (redirect to workspace-scoped URLs). +- [x] T120 Implement hard enforcement: tenant routes require workspace context and tenant.workspace_id match. +- [x] T130 Ensure `/admin/choose-tenant` requires selected workspace. + +## Validation +- [x] T900 Run Pint on dirty files. +- [x] T910 Run targeted Pest tests. -- 2.45.2 From ea526b255ad7084444456260f2e4da01664dd4f7 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Mon, 2 Feb 2026 10:59:10 +0100 Subject: [PATCH 02/10] feat: workspace foundation + workspace-scoped tenant selection --- app/Filament/Pages/ChooseWorkspace.php | 137 +++++++++ app/Filament/Pages/NoAccess.php | 63 ++++ .../Workspaces/Pages/CreateWorkspace.php | 35 +++ .../Workspaces/Pages/EditWorkspace.php | 11 + .../Workspaces/Pages/ListWorkspaces.php | 19 ++ .../Workspaces/WorkspaceResource.php | 62 ++++ app/Http/Middleware/EnsureWorkspaceMember.php | 51 ++++ .../Middleware/EnsureWorkspaceSelected.php | 67 +++++ app/Models/Tenant.php | 6 + app/Models/User.php | 8 + app/Models/Workspace.php | 43 +++ app/Models/WorkspaceMembership.php | 31 ++ app/Policies/WorkspaceMembershipPolicy.php | 108 +++++++ app/Policies/WorkspacePolicy.php | 74 +++++ app/Providers/Filament/AdminPanelProvider.php | 4 + app/Services/Audit/WorkspaceAuditLogger.php | 40 +++ .../Auth/WorkspaceCapabilityResolver.php | 100 +++++++ .../Auth/WorkspaceMembershipManager.php | 272 ++++++++++++++++++ .../Auth/WorkspaceRoleCapabilityMap.php | 74 +++++ app/Support/Auth/Capabilities.php | 12 + app/Support/Auth/WorkspaceRole.php | 11 + app/Support/Rbac/WorkspaceAccessContext.php | 45 +++ app/Support/Workspaces/WorkspaceContext.php | 133 +++++++++ app/Support/Workspaces/WorkspaceResolver.php | 25 ++ bootstrap/app.php | 5 + database/factories/WorkspaceFactory.php | 27 ++ .../factories/WorkspaceMembershipFactory.php | 25 ++ ...6_01_31_230301_create_workspaces_table.php | 31 ++ ...302_create_workspace_memberships_table.php | 33 +++ ...3_add_last_workspace_id_to_users_table.php | 32 +++ ...0304_add_workspace_id_to_tenants_table.php | 47 +++ ...4_add_workspace_id_to_audit_logs_table.php | 164 +++++++++++ ...fill_default_workspace_and_memberships.php | 142 +++++++++ ...49_add_archived_at_to_workspaces_table.php | 30 ++ .../ChooseTenantIsWorkspaceScopedTest.php | 69 +++++ .../ChooseTenantRequiresWorkspaceTest.php | 35 +++ .../TenantSwitcherUrlResolvesTenantTest.php | 37 +++ ...seWorkspaceRedirectsToChooseTenantTest.php | 28 ++ .../CreateWorkspaceCreatesMembershipTest.php | 64 +++++ tests/Pest.php | 27 ++ 40 files changed, 2227 insertions(+) create mode 100644 app/Filament/Pages/ChooseWorkspace.php create mode 100644 app/Filament/Resources/Workspaces/Pages/CreateWorkspace.php create mode 100644 app/Filament/Resources/Workspaces/Pages/EditWorkspace.php create mode 100644 app/Filament/Resources/Workspaces/Pages/ListWorkspaces.php create mode 100644 app/Filament/Resources/Workspaces/WorkspaceResource.php create mode 100644 app/Http/Middleware/EnsureWorkspaceMember.php create mode 100644 app/Http/Middleware/EnsureWorkspaceSelected.php create mode 100644 app/Models/Workspace.php create mode 100644 app/Models/WorkspaceMembership.php create mode 100644 app/Policies/WorkspaceMembershipPolicy.php create mode 100644 app/Policies/WorkspacePolicy.php create mode 100644 app/Services/Audit/WorkspaceAuditLogger.php create mode 100644 app/Services/Auth/WorkspaceCapabilityResolver.php create mode 100644 app/Services/Auth/WorkspaceMembershipManager.php create mode 100644 app/Services/Auth/WorkspaceRoleCapabilityMap.php create mode 100644 app/Support/Auth/WorkspaceRole.php create mode 100644 app/Support/Rbac/WorkspaceAccessContext.php create mode 100644 app/Support/Workspaces/WorkspaceContext.php create mode 100644 app/Support/Workspaces/WorkspaceResolver.php create mode 100644 database/factories/WorkspaceFactory.php create mode 100644 database/factories/WorkspaceMembershipFactory.php create mode 100644 database/migrations/2026_01_31_230301_create_workspaces_table.php create mode 100644 database/migrations/2026_01_31_230302_create_workspace_memberships_table.php create mode 100644 database/migrations/2026_01_31_230303_add_last_workspace_id_to_users_table.php create mode 100644 database/migrations/2026_01_31_230304_add_workspace_id_to_tenants_table.php create mode 100644 database/migrations/2026_02_01_002054_add_workspace_id_to_audit_logs_table.php create mode 100644 database/migrations/2026_02_01_085446_backfill_default_workspace_and_memberships.php create mode 100644 database/migrations/2026_02_01_085849_add_archived_at_to_workspaces_table.php create mode 100644 tests/Feature/Filament/ChooseTenantIsWorkspaceScopedTest.php create mode 100644 tests/Feature/Filament/ChooseTenantRequiresWorkspaceTest.php create mode 100644 tests/Feature/Filament/TenantSwitcherUrlResolvesTenantTest.php create mode 100644 tests/Feature/Workspaces/ChooseWorkspaceRedirectsToChooseTenantTest.php create mode 100644 tests/Feature/Workspaces/CreateWorkspaceCreatesMembershipTest.php diff --git a/app/Filament/Pages/ChooseWorkspace.php b/app/Filament/Pages/ChooseWorkspace.php new file mode 100644 index 0000000..10bd3d0 --- /dev/null +++ b/app/Filament/Pages/ChooseWorkspace.php @@ -0,0 +1,137 @@ + + */ + protected function getHeaderActions(): array + { + return [ + Action::make('createWorkspace') + ->label('Create workspace') + ->modalHeading('Create workspace') + ->form([ + TextInput::make('name') + ->required() + ->maxLength(255), + TextInput::make('slug') + ->helperText('Optional. Used in URLs if set.') + ->maxLength(255) + ->rules(['nullable', 'string', 'max:255', 'alpha_dash', 'unique:workspaces,slug']) + ->dehydrateStateUsing(fn ($state) => filled($state) ? $state : null) + ->dehydrated(fn ($state) => filled($state)), + ]) + ->action(fn (array $data) => $this->createWorkspace($data)), + ]; + } + + /** + * @return Collection + */ + public function getWorkspaces(): Collection + { + $user = auth()->user(); + + if (! $user instanceof User) { + return Workspace::query()->whereRaw('1 = 0')->get(); + } + + return Workspace::query() + ->whereIn('id', function ($query) use ($user): void { + $query->from('workspace_memberships') + ->select('workspace_id') + ->where('user_id', $user->getKey()); + }) + ->whereNull('archived_at') + ->orderBy('name') + ->get(); + } + + public function selectWorkspace(int $workspaceId): void + { + $user = auth()->user(); + + if (! $user instanceof User) { + abort(403); + } + + $workspace = Workspace::query()->whereKey($workspaceId)->first(); + + if (! $workspace instanceof Workspace) { + abort(404); + } + + if (! empty($workspace->archived_at)) { + abort(404); + } + + $context = app(WorkspaceContext::class); + + if (! $context->isMember($user, $workspace)) { + abort(404); + } + + $context->setCurrentWorkspace($workspace, $user, request()); + + $this->redirect(ChooseTenant::getUrl()); + } + + /** + * @param array{name: string, slug?: string|null} $data + */ + public function createWorkspace(array $data): void + { + $user = auth()->user(); + + if (! $user instanceof User) { + abort(403); + } + + $workspace = Workspace::query()->create([ + 'name' => $data['name'], + 'slug' => $data['slug'] ?? null, + ]); + + WorkspaceMembership::query()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + app(WorkspaceContext::class)->setCurrentWorkspace($workspace, $user, request()); + + Notification::make() + ->title('Workspace created') + ->success() + ->send(); + + $this->redirect(ChooseTenant::getUrl()); + } +} diff --git a/app/Filament/Pages/NoAccess.php b/app/Filament/Pages/NoAccess.php index 07574bf..02c2e64 100644 --- a/app/Filament/Pages/NoAccess.php +++ b/app/Filament/Pages/NoAccess.php @@ -4,6 +4,13 @@ namespace App\Filament\Pages; +use App\Models\User; +use App\Models\Workspace; +use App\Models\WorkspaceMembership; +use App\Support\Workspaces\WorkspaceContext; +use Filament\Actions\Action; +use Filament\Forms\Components\TextInput; +use Filament\Notifications\Notification; use Filament\Pages\Page; class NoAccess extends Page @@ -19,4 +26,60 @@ class NoAccess extends Page protected static ?string $title = 'No access'; protected string $view = 'filament.pages.no-access'; + + /** + * @return array + */ + protected function getHeaderActions(): array + { + return [ + Action::make('createWorkspace') + ->label('Create workspace') + ->modalHeading('Create workspace') + ->form([ + TextInput::make('name') + ->required() + ->maxLength(255), + TextInput::make('slug') + ->helperText('Optional. Used in URLs if set.') + ->maxLength(255) + ->rules(['nullable', 'string', 'max:255', 'alpha_dash', 'unique:workspaces,slug']) + ->dehydrateStateUsing(fn ($state) => filled($state) ? $state : null) + ->dehydrated(fn ($state) => filled($state)), + ]) + ->action(fn (array $data) => $this->createWorkspace($data)), + ]; + } + + /** + * @param array{name: string, slug?: string|null} $data + */ + public function createWorkspace(array $data): void + { + $user = auth()->user(); + + if (! $user instanceof User) { + abort(403); + } + + $workspace = Workspace::query()->create([ + 'name' => $data['name'], + 'slug' => $data['slug'] ?? null, + ]); + + WorkspaceMembership::query()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + app(WorkspaceContext::class)->setCurrentWorkspace($workspace, $user, request()); + + Notification::make() + ->title('Workspace created') + ->success() + ->send(); + + $this->redirect(ChooseTenant::getUrl()); + } } diff --git a/app/Filament/Resources/Workspaces/Pages/CreateWorkspace.php b/app/Filament/Resources/Workspaces/Pages/CreateWorkspace.php new file mode 100644 index 0000000..7b1a167 --- /dev/null +++ b/app/Filament/Resources/Workspaces/Pages/CreateWorkspace.php @@ -0,0 +1,35 @@ +user(); + + if (! $user instanceof User) { + return; + } + + WorkspaceMembership::query()->firstOrCreate( + [ + 'workspace_id' => $this->record->getKey(), + 'user_id' => $user->getKey(), + ], + [ + 'role' => 'owner', + ], + ); + + app(WorkspaceContext::class)->setCurrentWorkspace($this->record, $user, request()); + } +} diff --git a/app/Filament/Resources/Workspaces/Pages/EditWorkspace.php b/app/Filament/Resources/Workspaces/Pages/EditWorkspace.php new file mode 100644 index 0000000..3d0763a --- /dev/null +++ b/app/Filament/Resources/Workspaces/Pages/EditWorkspace.php @@ -0,0 +1,11 @@ +schema([ + Forms\Components\TextInput::make('name') + ->required() + ->maxLength(255), + Forms\Components\TextInput::make('slug') + ->required() + ->maxLength(255) + ->unique(ignoreRecord: true), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('name') + ->searchable() + ->sortable(), + Tables\Columns\TextColumn::make('slug') + ->searchable() + ->sortable(), + ]) + ->actions([ + Tables\Actions\EditAction::make(), + ]); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListWorkspaces::route('/'), + 'create' => Pages\CreateWorkspace::route('/create'), + 'edit' => Pages\EditWorkspace::route('/{record}/edit'), + ]; + } +} diff --git a/app/Http/Middleware/EnsureWorkspaceMember.php b/app/Http/Middleware/EnsureWorkspaceMember.php new file mode 100644 index 0000000..e79dd6c --- /dev/null +++ b/app/Http/Middleware/EnsureWorkspaceMember.php @@ -0,0 +1,51 @@ +user(); + + if (! $user instanceof User) { + return $next($request); + } + + $workspaceParam = $request->route()?->parameter('workspace'); + + $workspace = $workspaceParam instanceof Workspace + ? $workspaceParam + : (is_scalar($workspaceParam) + ? app(WorkspaceResolver::class)->resolve((string) $workspaceParam) + : null); + + if (! $workspace instanceof Workspace) { + abort(404); + } + + /** @var WorkspaceContext $context */ + $context = app(WorkspaceContext::class); + + if (! $context->isMember($user, $workspace)) { + abort(404); + } + + $context->setCurrentWorkspace($workspace, $user, $request); + + return $next($request); + } +} diff --git a/app/Http/Middleware/EnsureWorkspaceSelected.php b/app/Http/Middleware/EnsureWorkspaceSelected.php new file mode 100644 index 0000000..b904ff7 --- /dev/null +++ b/app/Http/Middleware/EnsureWorkspaceSelected.php @@ -0,0 +1,67 @@ +route()?->getName(); + + if (is_string($routeName) && str_contains($routeName, '.auth.')) { + return $next($request); + } + + $path = '/'.ltrim($request->path(), '/'); + + if (str_starts_with($path, '/admin/t/')) { + return $next($request); + } + + if (in_array($path, ['/admin/no-access', '/admin/choose-workspace'], true)) { + return $next($request); + } + + $user = $request->user(); + + if (! $user instanceof User) { + return $next($request); + } + + /** @var WorkspaceContext $context */ + $context = app(WorkspaceContext::class); + + $workspace = $context->resolveInitialWorkspaceFor($user, $request); + + if ($workspace !== null) { + return $next($request); + } + + $membershipQuery = WorkspaceMembership::query()->where('user_id', $user->getKey()); + + $hasAnyActiveMembership = Schema::hasColumn('workspaces', 'archived_at') + ? $membershipQuery + ->join('workspaces', 'workspace_memberships.workspace_id', '=', 'workspaces.id') + ->whereNull('workspaces.archived_at') + ->exists() + : $membershipQuery->exists(); + + $target = $hasAnyActiveMembership ? '/admin/choose-workspace' : '/admin/no-access'; + + return new HttpResponse('', 302, ['Location' => $target]); + } +} diff --git a/app/Models/Tenant.php b/app/Models/Tenant.php index c1d98b6..0f6159c 100644 --- a/app/Models/Tenant.php +++ b/app/Models/Tenant.php @@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasManyThrough; @@ -170,6 +171,11 @@ public function memberships(): HasMany return $this->hasMany(TenantMembership::class); } + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + public function roleMappings(): HasMany { return $this->hasMany(TenantRoleMapping::class); diff --git a/app/Models/User.php b/app/Models/User.php index a60c6f3..2a70316 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Support\Auth\Capabilities; +use App\Support\Workspaces\WorkspaceContext; use Filament\Models\Contracts\FilamentUser; use Filament\Models\Contracts\HasDefaultTenant; use Filament\Models\Contracts\HasTenants; @@ -141,7 +142,10 @@ public function getTenants(Panel $panel): array|Collection return collect(); } + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(); + return $this->tenants() + ->when($workspaceId !== null, fn ($query) => $query->where('tenants.workspace_id', $workspaceId)) ->where('status', 'active') ->orderBy('name') ->get(); @@ -153,6 +157,8 @@ public function getDefaultTenant(Panel $panel): ?Model return null; } + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(); + $tenantId = null; if ($this->tenantPreferencesTableExists()) { @@ -164,6 +170,7 @@ public function getDefaultTenant(Panel $panel): ?Model if ($tenantId !== null) { $tenant = $this->tenants() + ->when($workspaceId !== null, fn ($query) => $query->where('tenants.workspace_id', $workspaceId)) ->where('status', 'active') ->whereKey($tenantId) ->first(); @@ -174,6 +181,7 @@ public function getDefaultTenant(Panel $panel): ?Model } return $this->tenants() + ->when($workspaceId !== null, fn ($query) => $query->where('tenants.workspace_id', $workspaceId)) ->where('status', 'active') ->orderBy('name') ->first(); diff --git a/app/Models/Workspace.php b/app/Models/Workspace.php new file mode 100644 index 0000000..d351b3c --- /dev/null +++ b/app/Models/Workspace.php @@ -0,0 +1,43 @@ + */ + use HasFactory; + + protected $guarded = []; + + /** + * @return HasMany + */ + public function memberships(): HasMany + { + return $this->hasMany(WorkspaceMembership::class); + } + + /** + * @return BelongsToMany + */ + public function users(): BelongsToMany + { + return $this->belongsToMany(User::class, 'workspace_memberships') + ->using(WorkspaceMembership::class) + ->withPivot(['id', 'role']) + ->withTimestamps(); + } + + /** + * @return HasMany + */ + public function tenants(): HasMany + { + return $this->hasMany(Tenant::class); + } +} diff --git a/app/Models/WorkspaceMembership.php b/app/Models/WorkspaceMembership.php new file mode 100644 index 0000000..d2f7413 --- /dev/null +++ b/app/Models/WorkspaceMembership.php @@ -0,0 +1,31 @@ + */ + use HasFactory; + + protected $guarded = []; + + /** + * @return BelongsTo + */ + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Policies/WorkspaceMembershipPolicy.php b/app/Policies/WorkspaceMembershipPolicy.php new file mode 100644 index 0000000..df36eac --- /dev/null +++ b/app/Policies/WorkspaceMembershipPolicy.php @@ -0,0 +1,108 @@ +can($user, $workspaceMembership->workspace, Capabilities::WORKSPACE_MEMBERSHIP_VIEW); + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return true; + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, WorkspaceMembership $workspaceMembership): bool + { + if ($this->isLastOwner($workspaceMembership)) { + return false; + } + + /** @var WorkspaceCapabilityResolver $resolver */ + $resolver = app(WorkspaceCapabilityResolver::class); + + return $resolver->can($user, $workspaceMembership->workspace, Capabilities::WORKSPACE_MEMBERSHIP_MANAGE); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, WorkspaceMembership $workspaceMembership): bool + { + if ($this->isLastOwner($workspaceMembership)) { + return false; + } + + /** @var WorkspaceCapabilityResolver $resolver */ + $resolver = app(WorkspaceCapabilityResolver::class); + + return $resolver->can($user, $workspaceMembership->workspace, Capabilities::WORKSPACE_MEMBERSHIP_MANAGE); + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, WorkspaceMembership $workspaceMembership): bool + { + return false; + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, WorkspaceMembership $workspaceMembership): bool + { + return false; + } + + public function manageForWorkspace(User $user, Workspace $workspace): bool + { + /** @var WorkspaceCapabilityResolver $resolver */ + $resolver = app(WorkspaceCapabilityResolver::class); + + return $resolver->can($user, $workspace, Capabilities::WORKSPACE_MEMBERSHIP_MANAGE); + } + + private function isLastOwner(WorkspaceMembership $membership): bool + { + if ($membership->role !== WorkspaceRole::Owner->value) { + return false; + } + + $ownerCount = WorkspaceMembership::query() + ->where('workspace_id', $membership->workspace_id) + ->where('role', WorkspaceRole::Owner->value) + ->count(); + + return $ownerCount <= 1; + } +} diff --git a/app/Policies/WorkspacePolicy.php b/app/Policies/WorkspacePolicy.php new file mode 100644 index 0000000..0a40b4b --- /dev/null +++ b/app/Policies/WorkspacePolicy.php @@ -0,0 +1,74 @@ +where('user_id', $user->getKey()) + ->where('workspace_id', $workspace->getKey()) + ->exists(); + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return true; + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, Workspace $workspace): bool + { + /** @var WorkspaceCapabilityResolver $resolver */ + $resolver = app(WorkspaceCapabilityResolver::class); + + return $resolver->can($user, $workspace, Capabilities::WORKSPACE_MANAGE); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, Workspace $workspace): bool + { + return false; + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, Workspace $workspace): bool + { + return false; + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, Workspace $workspace): bool + { + return false; + } +} diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index f4fae79..5664e14 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -4,6 +4,7 @@ use App\Filament\Pages\Auth\Login; use App\Filament\Pages\ChooseTenant; +use App\Filament\Pages\ChooseWorkspace; use App\Filament\Pages\NoAccess; use App\Filament\Pages\Tenancy\RegisterTenant; use App\Filament\Pages\TenantDashboard; @@ -37,6 +38,7 @@ public function panel(Panel $panel): Panel ->path('admin') ->login(Login::class) ->authenticatedRoutes(function (Panel $panel): void { + ChooseWorkspace::registerRoutes($panel); ChooseTenant::registerRoutes($panel); NoAccess::registerRoutes($panel); }) @@ -79,6 +81,8 @@ public function panel(Panel $panel): Panel VerifyCsrfToken::class, SubstituteBindings::class, 'ensure-correct-guard:web', + 'ensure-workspace-selected', + 'ensure-filament-tenant-selected', DenyNonMemberTenantAccess::class, DisableBladeIconComponents::class, DispatchServingFilamentEvent::class, diff --git a/app/Services/Audit/WorkspaceAuditLogger.php b/app/Services/Audit/WorkspaceAuditLogger.php new file mode 100644 index 0000000..ac0f466 --- /dev/null +++ b/app/Services/Audit/WorkspaceAuditLogger.php @@ -0,0 +1,40 @@ + null, + 'workspace_id' => (int) $workspace->getKey(), + 'actor_id' => $actor?->getKey(), + 'actor_email' => $actor?->email, + 'actor_name' => $actor?->name, + 'action' => $action, + 'resource_type' => $resourceType, + 'resource_id' => $resourceId, + 'status' => $status, + 'metadata' => $metadata + $context, + 'recorded_at' => CarbonImmutable::now(), + ]); + } +} diff --git a/app/Services/Auth/WorkspaceCapabilityResolver.php b/app/Services/Auth/WorkspaceCapabilityResolver.php new file mode 100644 index 0000000..10f3c8c --- /dev/null +++ b/app/Services/Auth/WorkspaceCapabilityResolver.php @@ -0,0 +1,100 @@ +getMembership($user, $workspace); + + if ($membership === null) { + return null; + } + + return WorkspaceRole::tryFrom($membership['role']); + } + + public function can(User $user, Workspace $workspace, string $capability): bool + { + if (! Capabilities::isKnown($capability)) { + throw new \InvalidArgumentException("Unknown capability: {$capability}"); + } + + $role = $this->getRole($user, $workspace); + + if ($role === null) { + $this->logDenial($user, $workspace, $capability); + + return false; + } + + $allowed = WorkspaceRoleCapabilityMap::hasCapability($role, $capability); + + if (! $allowed) { + $this->logDenial($user, $workspace, $capability); + } + + return $allowed; + } + + public function isMember(User $user, Workspace $workspace): bool + { + return $this->getMembership($user, $workspace) !== null; + } + + public function clearCache(): void + { + $this->resolvedMemberships = []; + } + + private function logDenial(User $user, Workspace $workspace, string $capability): void + { + $key = implode(':', [(string) $user->getKey(), (string) $workspace->getKey(), $capability]); + + if (isset($this->loggedDenials[$key])) { + return; + } + + $this->loggedDenials[$key] = true; + + Log::warning('rbac.workspace.denied', [ + 'capability' => $capability, + 'workspace_id' => (int) $workspace->getKey(), + 'actor_user_id' => (int) $user->getKey(), + ]); + } + + private function getMembership(User $user, Workspace $workspace): ?array + { + $cacheKey = "workspace_membership_{$user->id}_{$workspace->id}"; + + if (! isset($this->resolvedMemberships[$cacheKey])) { + $membership = WorkspaceMembership::query() + ->where('user_id', $user->id) + ->where('workspace_id', $workspace->id) + ->first(['role']); + + $this->resolvedMemberships[$cacheKey] = $membership?->toArray(); + } + + return $this->resolvedMemberships[$cacheKey]; + } +} diff --git a/app/Services/Auth/WorkspaceMembershipManager.php b/app/Services/Auth/WorkspaceMembershipManager.php new file mode 100644 index 0000000..6941777 --- /dev/null +++ b/app/Services/Auth/WorkspaceMembershipManager.php @@ -0,0 +1,272 @@ +assertValidRole($role); + $this->assertActorCanManage($actor, $workspace); + + return DB::transaction(function () use ($workspace, $actor, $member, $role, $source): WorkspaceMembership { + $existing = WorkspaceMembership::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('user_id', (int) $member->getKey()) + ->first(); + + if ($existing) { + if ($existing->role !== $role) { + $fromRole = (string) $existing->role; + + $existing->forceFill([ + 'role' => $role, + ])->save(); + + $this->auditLogger->log( + workspace: $workspace, + action: AuditActionId::WorkspaceMembershipRoleChange->value, + context: [ + 'metadata' => [ + 'member_user_id' => (int) $member->getKey(), + 'from_role' => $fromRole, + 'to_role' => $role, + 'source' => $source, + ], + ], + actor: $actor, + status: 'success', + resourceType: 'workspace', + resourceId: (string) $workspace->getKey(), + ); + } + + return $existing->refresh(); + } + + $membership = WorkspaceMembership::query()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $member->getKey(), + 'role' => $role, + ]); + + $this->auditLogger->log( + workspace: $workspace, + action: AuditActionId::WorkspaceMembershipAdd->value, + context: [ + 'metadata' => [ + 'member_user_id' => (int) $member->getKey(), + 'role' => $role, + 'source' => $source, + ], + ], + actor: $actor, + status: 'success', + resourceType: 'workspace', + resourceId: (string) $workspace->getKey(), + ); + + return $membership; + }); + } + + public function changeRole(Workspace $workspace, User $actor, WorkspaceMembership $membership, string $newRole): WorkspaceMembership + { + $this->assertValidRole($newRole); + $this->assertActorCanManage($actor, $workspace); + + try { + return DB::transaction(function () use ($workspace, $actor, $membership, $newRole): WorkspaceMembership { + $membership->refresh(); + + if ($membership->workspace_id !== (int) $workspace->getKey()) { + throw new DomainException('Membership belongs to a different workspace.'); + } + + $oldRole = (string) $membership->role; + + if ($oldRole === $newRole) { + return $membership; + } + + $this->guardLastOwnerDemotion($workspace, $membership, $newRole); + + $membership->forceFill([ + 'role' => $newRole, + ])->save(); + + $this->auditLogger->log( + workspace: $workspace, + action: AuditActionId::WorkspaceMembershipRoleChange->value, + context: [ + 'metadata' => [ + 'member_user_id' => (int) $membership->user_id, + 'from_role' => $oldRole, + 'to_role' => $newRole, + ], + ], + actor: $actor, + status: 'success', + resourceType: 'workspace', + resourceId: (string) $workspace->getKey(), + ); + + return $membership->refresh(); + }); + } catch (DomainException $exception) { + if ($exception->getMessage() === 'You cannot demote the last remaining owner.') { + $this->auditLogger->log( + workspace: $workspace, + action: AuditActionId::WorkspaceMembershipLastOwnerBlocked->value, + context: [ + 'metadata' => [ + 'member_user_id' => (int) $membership->user_id, + 'from_role' => (string) $membership->role, + 'attempted_to_role' => $newRole, + ], + ], + actor: $actor, + status: 'blocked', + resourceType: 'workspace', + resourceId: (string) $workspace->getKey(), + ); + } + + throw $exception; + } + } + + public function removeMember(Workspace $workspace, User $actor, WorkspaceMembership $membership): void + { + $this->assertActorCanManage($actor, $workspace); + + try { + DB::transaction(function () use ($workspace, $actor, $membership): void { + $membership->refresh(); + + if ($membership->workspace_id !== (int) $workspace->getKey()) { + throw new DomainException('Membership belongs to a different workspace.'); + } + + $this->guardLastOwnerRemoval($workspace, $membership); + + $memberUserId = (int) $membership->user_id; + $oldRole = (string) $membership->role; + + $membership->delete(); + + $this->auditLogger->log( + workspace: $workspace, + action: AuditActionId::WorkspaceMembershipRemove->value, + context: [ + 'metadata' => [ + 'member_user_id' => $memberUserId, + 'role' => $oldRole, + ], + ], + actor: $actor, + status: 'success', + resourceType: 'workspace', + resourceId: (string) $workspace->getKey(), + ); + }); + } catch (DomainException $exception) { + if ($exception->getMessage() === 'You cannot remove the last remaining owner.') { + $this->auditLogger->log( + workspace: $workspace, + action: AuditActionId::WorkspaceMembershipLastOwnerBlocked->value, + context: [ + 'metadata' => [ + 'member_user_id' => (int) $membership->user_id, + 'role' => (string) $membership->role, + 'attempted_action' => 'remove', + ], + ], + actor: $actor, + status: 'blocked', + resourceType: 'workspace', + resourceId: (string) $workspace->getKey(), + ); + } + + throw $exception; + } + } + + private function assertActorCanManage(User $actor, Workspace $workspace): void + { + /** @var WorkspaceCapabilityResolver $resolver */ + $resolver = app(WorkspaceCapabilityResolver::class); + + if (! $resolver->can($actor, $workspace, Capabilities::WORKSPACE_MEMBERSHIP_MANAGE)) { + throw new DomainException('Forbidden.'); + } + } + + private function assertValidRole(string $role): void + { + $valid = array_map( + static fn (WorkspaceRole $workspaceRole): string => $workspaceRole->value, + WorkspaceRole::cases(), + ); + + if (! in_array($role, $valid, true)) { + throw new DomainException('Invalid role.'); + } + } + + private function guardLastOwnerDemotion(Workspace $workspace, WorkspaceMembership $membership, string $newRole): void + { + if ($membership->role !== WorkspaceRole::Owner->value) { + return; + } + + if ($newRole === WorkspaceRole::Owner->value) { + return; + } + + $owners = WorkspaceMembership::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('role', WorkspaceRole::Owner->value) + ->count(); + + if ($owners <= 1) { + throw new DomainException('You cannot demote the last remaining owner.'); + } + } + + private function guardLastOwnerRemoval(Workspace $workspace, WorkspaceMembership $membership): void + { + if ($membership->role !== WorkspaceRole::Owner->value) { + return; + } + + $owners = WorkspaceMembership::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('role', WorkspaceRole::Owner->value) + ->count(); + + if ($owners <= 1) { + throw new DomainException('You cannot remove the last remaining owner.'); + } + } +} diff --git a/app/Services/Auth/WorkspaceRoleCapabilityMap.php b/app/Services/Auth/WorkspaceRoleCapabilityMap.php new file mode 100644 index 0000000..9e78923 --- /dev/null +++ b/app/Services/Auth/WorkspaceRoleCapabilityMap.php @@ -0,0 +1,74 @@ +> + */ + private static array $roleCapabilities = [ + WorkspaceRole::Owner->value => [ + Capabilities::WORKSPACE_VIEW, + Capabilities::WORKSPACE_MANAGE, + Capabilities::WORKSPACE_ARCHIVE, + Capabilities::WORKSPACE_MEMBERSHIP_VIEW, + Capabilities::WORKSPACE_MEMBERSHIP_MANAGE, + ], + + WorkspaceRole::Manager->value => [ + Capabilities::WORKSPACE_VIEW, + Capabilities::WORKSPACE_MEMBERSHIP_VIEW, + Capabilities::WORKSPACE_MEMBERSHIP_MANAGE, + ], + + WorkspaceRole::Operator->value => [ + Capabilities::WORKSPACE_VIEW, + Capabilities::WORKSPACE_MEMBERSHIP_VIEW, + ], + + WorkspaceRole::Readonly->value => [ + Capabilities::WORKSPACE_VIEW, + ], + ]; + + /** + * @return array + */ + public static function getCapabilities(WorkspaceRole|string $role): array + { + $roleValue = $role instanceof WorkspaceRole ? $role->value : $role; + + return self::$roleCapabilities[$roleValue] ?? []; + } + + /** + * @return array + */ + public static function rolesWithCapability(string $capability): array + { + $roles = []; + + foreach (self::$roleCapabilities as $role => $capabilities) { + if (in_array($capability, $capabilities, true)) { + $roles[] = $role; + } + } + + return $roles; + } + + public static function hasCapability(WorkspaceRole|string $role, string $capability): bool + { + return in_array($capability, self::getCapabilities($role), true); + } +} diff --git a/app/Support/Auth/Capabilities.php b/app/Support/Auth/Capabilities.php index 47bcc50..a17b94b 100644 --- a/app/Support/Auth/Capabilities.php +++ b/app/Support/Auth/Capabilities.php @@ -15,6 +15,18 @@ class Capabilities */ private static ?array $all = null; + // Workspaces + public const WORKSPACE_VIEW = 'workspace.view'; + + public const WORKSPACE_MANAGE = 'workspace.manage'; + + public const WORKSPACE_ARCHIVE = 'workspace.archive'; + + // Workspace memberships + public const WORKSPACE_MEMBERSHIP_VIEW = 'workspace_membership.view'; + + public const WORKSPACE_MEMBERSHIP_MANAGE = 'workspace_membership.manage'; + // Tenants public const TENANT_VIEW = 'tenant.view'; diff --git a/app/Support/Auth/WorkspaceRole.php b/app/Support/Auth/WorkspaceRole.php new file mode 100644 index 0000000..ef10970 --- /dev/null +++ b/app/Support/Auth/WorkspaceRole.php @@ -0,0 +1,11 @@ +isMember; + } + + /** + * Members without capability should receive 403 (forbidden). + */ + public function shouldDenyAsForbidden(): bool + { + return $this->isMember && ! $this->hasCapability; + } + + /** + * User is authorized to perform the action. + */ + public function isAuthorized(): bool + { + return $this->isMember && $this->hasCapability; + } +} diff --git a/app/Support/Workspaces/WorkspaceContext.php b/app/Support/Workspaces/WorkspaceContext.php new file mode 100644 index 0000000..b927f3c --- /dev/null +++ b/app/Support/Workspaces/WorkspaceContext.php @@ -0,0 +1,133 @@ +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 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(); + } else { + $session->put(self::SESSION_KEY, (int) $workspace->getKey()); + + return $workspace; + } + } + + $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(); + } + + private function isWorkspaceSelectable(Workspace $workspace): bool + { + return empty($workspace->archived_at); + } +} diff --git a/app/Support/Workspaces/WorkspaceResolver.php b/app/Support/Workspaces/WorkspaceResolver.php new file mode 100644 index 0000000..5535db8 --- /dev/null +++ b/app/Support/Workspaces/WorkspaceResolver.php @@ -0,0 +1,25 @@ +where('slug', $value) + ->first(); + + if ($workspace !== null) { + return $workspace; + } + + if (! ctype_digit($value)) { + return null; + } + + return Workspace::query()->whereKey((int) $value)->first(); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 5a58a69..25512e2 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -14,12 +14,17 @@ $middleware->alias([ 'ensure-correct-guard' => \App\Http\Middleware\EnsureCorrectGuard::class, 'ensure-platform-capability' => \App\Http\Middleware\EnsurePlatformCapability::class, + 'ensure-workspace-member' => \App\Http\Middleware\EnsureWorkspaceMember::class, + 'ensure-workspace-selected' => \App\Http\Middleware\EnsureWorkspaceSelected::class, + 'ensure-filament-tenant-selected' => \App\Support\Middleware\EnsureFilamentTenantSelected::class, ]); $middleware->prependToPriorityList( \Illuminate\Contracts\Auth\Middleware\AuthenticatesRequests::class, \App\Http\Middleware\EnsureCorrectGuard::class, ); + + $middleware->redirectGuestsTo('/admin/login'); }) ->withExceptions(function (Exceptions $exceptions): void { // diff --git a/database/factories/WorkspaceFactory.php b/database/factories/WorkspaceFactory.php new file mode 100644 index 0000000..a3d2e94 --- /dev/null +++ b/database/factories/WorkspaceFactory.php @@ -0,0 +1,27 @@ + + */ +class WorkspaceFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $name = $this->faker->company(); + + return [ + 'name' => $name, + 'slug' => Str::slug($name).'-'.$this->faker->unique()->randomNumber(5), + ]; + } +} diff --git a/database/factories/WorkspaceMembershipFactory.php b/database/factories/WorkspaceMembershipFactory.php new file mode 100644 index 0000000..eb48066 --- /dev/null +++ b/database/factories/WorkspaceMembershipFactory.php @@ -0,0 +1,25 @@ + + */ +class WorkspaceMembershipFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'workspace_id' => \App\Models\Workspace::factory(), + 'user_id' => \App\Models\User::factory(), + 'role' => 'operator', + ]; + } +} diff --git a/database/migrations/2026_01_31_230301_create_workspaces_table.php b/database/migrations/2026_01_31_230301_create_workspaces_table.php new file mode 100644 index 0000000..1e87951 --- /dev/null +++ b/database/migrations/2026_01_31_230301_create_workspaces_table.php @@ -0,0 +1,31 @@ +id(); + $table->string('name'); + $table->string('slug')->nullable()->unique(); + $table->timestamps(); + + $table->index('name'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('workspaces'); + } +}; diff --git a/database/migrations/2026_01_31_230302_create_workspace_memberships_table.php b/database/migrations/2026_01_31_230302_create_workspace_memberships_table.php new file mode 100644 index 0000000..0670737 --- /dev/null +++ b/database/migrations/2026_01_31_230302_create_workspace_memberships_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('workspace_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->enum('role', ['owner', 'manager', 'operator', 'readonly']); + $table->timestamps(); + + $table->unique(['workspace_id', 'user_id']); + $table->index(['workspace_id', 'role']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('workspace_memberships'); + } +}; diff --git a/database/migrations/2026_01_31_230303_add_last_workspace_id_to_users_table.php b/database/migrations/2026_01_31_230303_add_last_workspace_id_to_users_table.php new file mode 100644 index 0000000..fca1fbd --- /dev/null +++ b/database/migrations/2026_01_31_230303_add_last_workspace_id_to_users_table.php @@ -0,0 +1,32 @@ +foreignId('last_workspace_id') + ->nullable() + ->after('remember_token') + ->constrained('workspaces') + ->nullOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropConstrainedForeignId('last_workspace_id'); + }); + } +}; diff --git a/database/migrations/2026_01_31_230304_add_workspace_id_to_tenants_table.php b/database/migrations/2026_01_31_230304_add_workspace_id_to_tenants_table.php new file mode 100644 index 0000000..75d448b --- /dev/null +++ b/database/migrations/2026_01_31_230304_add_workspace_id_to_tenants_table.php @@ -0,0 +1,47 @@ +foreignId('workspace_id')->nullable(); + + if ($driver !== 'sqlite') { + $column + ->after('id') + ->constrained('workspaces') + ->nullOnDelete(); + } + + $table->index('workspace_id'); + }); + + if ($driver === 'sqlite') { + // SQLite table rebuilds can drop/flatten the partial index defined in + // 2025_12_11_192942_add_is_current_to_tenants.php. Recreate it here. + DB::statement('DROP INDEX IF EXISTS tenants_current_unique'); + DB::statement('CREATE UNIQUE INDEX tenants_current_unique ON tenants (is_current) WHERE is_current = 1 AND deleted_at IS NULL'); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('tenants', function (Blueprint $table) { + $table->dropConstrainedForeignId('workspace_id'); + }); + } +}; diff --git a/database/migrations/2026_02_01_002054_add_workspace_id_to_audit_logs_table.php b/database/migrations/2026_02_01_002054_add_workspace_id_to_audit_logs_table.php new file mode 100644 index 0000000..530422c --- /dev/null +++ b/database/migrations/2026_02_01_002054_add_workspace_id_to_audit_logs_table.php @@ -0,0 +1,164 @@ +getDriverName(); + + if ($driver === 'sqlite') { + Schema::disableForeignKeyConstraints(); + + Schema::rename('audit_logs', 'audit_logs_old'); + + foreach ([ + 'audit_logs_tenant_id_action_index', + 'audit_logs_tenant_id_resource_type_index', + 'audit_logs_recorded_at_index', + ] as $indexName) { + DB::statement("DROP INDEX IF EXISTS {$indexName}"); + } + + Schema::create('audit_logs', function (Blueprint $table) { + $table->id(); + $table->foreignId('tenant_id')->nullable()->constrained()->cascadeOnDelete(); + $table->foreignId('workspace_id')->nullable()->constrained()->nullOnDelete(); + $table->unsignedBigInteger('actor_id')->nullable(); + $table->string('actor_email')->nullable(); + $table->string('actor_name')->nullable(); + $table->string('action'); + $table->string('resource_type')->nullable(); + $table->string('resource_id')->nullable(); + $table->string('status')->default('success'); + $table->json('metadata')->nullable(); + $table->timestamp('recorded_at')->useCurrent(); + $table->timestamps(); + + $table->index(['tenant_id', 'action']); + $table->index(['tenant_id', 'resource_type']); + $table->index(['workspace_id', 'action']); + $table->index(['workspace_id', 'resource_type']); + $table->index('recorded_at'); + }); + + DB::table('audit_logs_old')->orderBy('id')->chunkById(500, function ($rows): void { + foreach ($rows as $row) { + DB::table('audit_logs')->insert([ + 'id' => $row->id, + 'tenant_id' => $row->tenant_id, + 'workspace_id' => null, + 'actor_id' => $row->actor_id, + 'actor_email' => $row->actor_email, + 'actor_name' => $row->actor_name, + 'action' => $row->action, + 'resource_type' => $row->resource_type, + 'resource_id' => $row->resource_id, + 'status' => $row->status, + 'metadata' => $row->metadata, + 'recorded_at' => $row->recorded_at, + 'created_at' => $row->created_at, + 'updated_at' => $row->updated_at, + ]); + } + }, 'id'); + + Schema::drop('audit_logs_old'); + Schema::enableForeignKeyConstraints(); + + return; + } + + DB::statement('ALTER TABLE audit_logs ALTER COLUMN tenant_id DROP NOT NULL'); + + Schema::table('audit_logs', function (Blueprint $table) { + $table->foreignId('workspace_id')->nullable()->constrained()->nullOnDelete()->after('tenant_id'); + + $table->index(['workspace_id', 'action']); + $table->index(['workspace_id', 'resource_type']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + $driver = Schema::getConnection()->getDriverName(); + + if ($driver === 'sqlite') { + Schema::disableForeignKeyConstraints(); + + Schema::rename('audit_logs', 'audit_logs_new'); + + foreach ([ + 'audit_logs_tenant_id_action_index', + 'audit_logs_tenant_id_resource_type_index', + 'audit_logs_recorded_at_index', + 'audit_logs_workspace_id_action_index', + 'audit_logs_workspace_id_resource_type_index', + ] as $indexName) { + DB::statement("DROP INDEX IF EXISTS {$indexName}"); + } + + Schema::create('audit_logs', function (Blueprint $table) { + $table->id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->unsignedBigInteger('actor_id')->nullable(); + $table->string('actor_email')->nullable(); + $table->string('actor_name')->nullable(); + $table->string('action'); + $table->string('resource_type')->nullable(); + $table->string('resource_id')->nullable(); + $table->string('status')->default('success'); + $table->json('metadata')->nullable(); + $table->timestamp('recorded_at')->useCurrent(); + $table->timestamps(); + + $table->index(['tenant_id', 'action']); + $table->index(['tenant_id', 'resource_type']); + $table->index('recorded_at'); + }); + + DB::table('audit_logs_new')->whereNotNull('tenant_id')->orderBy('id')->chunkById(500, function ($rows): void { + foreach ($rows as $row) { + DB::table('audit_logs')->insert([ + 'id' => $row->id, + 'tenant_id' => $row->tenant_id, + 'actor_id' => $row->actor_id, + 'actor_email' => $row->actor_email, + 'actor_name' => $row->actor_name, + 'action' => $row->action, + 'resource_type' => $row->resource_type, + 'resource_id' => $row->resource_id, + 'status' => $row->status, + 'metadata' => $row->metadata, + 'recorded_at' => $row->recorded_at, + 'created_at' => $row->created_at, + 'updated_at' => $row->updated_at, + ]); + } + }, 'id'); + + Schema::drop('audit_logs_new'); + Schema::enableForeignKeyConstraints(); + + return; + } + + Schema::table('audit_logs', function (Blueprint $table) { + $table->dropConstrainedForeignId('workspace_id'); + $table->dropIndex(['workspace_id', 'action']); + $table->dropIndex(['workspace_id', 'resource_type']); + }); + + DB::statement('ALTER TABLE audit_logs ALTER COLUMN tenant_id SET NOT NULL'); + } +}; diff --git a/database/migrations/2026_02_01_085446_backfill_default_workspace_and_memberships.php b/database/migrations/2026_02_01_085446_backfill_default_workspace_and_memberships.php new file mode 100644 index 0000000..0b7334e --- /dev/null +++ b/database/migrations/2026_02_01_085446_backfill_default_workspace_and_memberships.php @@ -0,0 +1,142 @@ +where('slug', 'default') + ->value('id'); + + if (! $defaultWorkspaceId) { + $defaultWorkspaceId = DB::table('workspaces')->insertGetId([ + 'name' => 'Default Workspace', + 'slug' => 'default', + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + if (Schema::hasTable('tenants') && Schema::hasColumn('tenants', 'workspace_id')) { + DB::table('tenants') + ->whereNull('workspace_id') + ->update([ + 'workspace_id' => $defaultWorkspaceId, + 'updated_at' => $now, + ]); + } + + if (! Schema::hasTable('workspace_memberships')) { + return; + } + + $roleRankToRole = [ + 4 => 'owner', + 3 => 'manager', + 2 => 'operator', + 1 => 'readonly', + ]; + + $userRoleRanks = collect(); + + if (Schema::hasTable('tenant_memberships')) { + $userRoleRanks = DB::table('tenant_memberships') + ->select([ + 'user_id', + DB::raw("MAX(CASE role WHEN 'owner' THEN 4 WHEN 'manager' THEN 3 WHEN 'operator' THEN 2 WHEN 'readonly' THEN 1 ELSE 0 END) AS role_rank"), + ]) + ->groupBy('user_id') + ->get(); + } + + $rows = []; + $userIds = []; + + foreach ($userRoleRanks as $row) { + $role = $roleRankToRole[(int) $row->role_rank] ?? null; + + if (! $role) { + continue; + } + + $rows[] = [ + 'workspace_id' => $defaultWorkspaceId, + 'user_id' => $row->user_id, + 'role' => $role, + 'created_at' => $now, + 'updated_at' => $now, + ]; + $userIds[] = $row->user_id; + } + + if (empty($rows) && Schema::hasTable('users')) { + $firstUserId = DB::table('users')->orderBy('id')->value('id'); + + if ($firstUserId) { + $rows[] = [ + 'workspace_id' => $defaultWorkspaceId, + 'user_id' => $firstUserId, + 'role' => 'owner', + 'created_at' => $now, + 'updated_at' => $now, + ]; + $userIds[] = $firstUserId; + } + } + + if (! empty($rows)) { + foreach (array_chunk($rows, 500) as $chunk) { + DB::table('workspace_memberships')->insertOrIgnore($chunk); + } + } + + $ownerCount = DB::table('workspace_memberships') + ->where('workspace_id', $defaultWorkspaceId) + ->where('role', 'owner') + ->count(); + + if ($ownerCount === 0) { + $firstMembershipId = DB::table('workspace_memberships') + ->where('workspace_id', $defaultWorkspaceId) + ->orderBy('id') + ->value('id'); + + if ($firstMembershipId) { + DB::table('workspace_memberships') + ->where('id', $firstMembershipId) + ->update([ + 'role' => 'owner', + 'updated_at' => $now, + ]); + } + } + + if (Schema::hasTable('users') && ! empty($userIds) && Schema::hasColumn('users', 'last_workspace_id')) { + DB::table('users') + ->whereIn('id', array_unique($userIds)) + ->whereNull('last_workspace_id') + ->update([ + 'last_workspace_id' => $defaultWorkspaceId, + 'updated_at' => $now, + ]); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void {} +}; diff --git a/database/migrations/2026_02_01_085849_add_archived_at_to_workspaces_table.php b/database/migrations/2026_02_01_085849_add_archived_at_to_workspaces_table.php new file mode 100644 index 0000000..2d04576 --- /dev/null +++ b/database/migrations/2026_02_01_085849_add_archived_at_to_workspaces_table.php @@ -0,0 +1,30 @@ +timestamp('archived_at')->nullable()->after('slug'); + $table->index('archived_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('workspaces', function (Blueprint $table) { + $table->dropIndex(['archived_at']); + $table->dropColumn('archived_at'); + }); + } +}; diff --git a/tests/Feature/Filament/ChooseTenantIsWorkspaceScopedTest.php b/tests/Feature/Filament/ChooseTenantIsWorkspaceScopedTest.php new file mode 100644 index 0000000..514f10c --- /dev/null +++ b/tests/Feature/Filament/ChooseTenantIsWorkspaceScopedTest.php @@ -0,0 +1,69 @@ +create(); + + $workspaceA = Workspace::factory()->create(['name' => 'Workspace A']); + $workspaceB = Workspace::factory()->create(['name' => 'Workspace B']); + + $tenantA = Tenant::factory()->create([ + 'workspace_id' => $workspaceA->getKey(), + 'name' => 'Tenant A', + 'status' => 'active', + ]); + + $tenantB = Tenant::factory()->create([ + 'workspace_id' => $workspaceB->getKey(), + 'name' => 'Tenant B', + 'status' => 'active', + ]); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspaceA->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspaceB->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + TenantMembership::query()->create([ + 'tenant_id' => $tenantA->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + 'source' => 'manual', + 'source_ref' => null, + 'created_by_user_id' => null, + ]); + + TenantMembership::query()->create([ + 'tenant_id' => $tenantB->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + 'source' => 'manual', + 'source_ref' => null, + 'created_by_user_id' => null, + ]); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspaceA->getKey()]) + ->get('/admin/choose-tenant') + ->assertSuccessful() + ->assertSee('Tenant A') + ->assertDontSee('Tenant B'); +}); diff --git a/tests/Feature/Filament/ChooseTenantRequiresWorkspaceTest.php b/tests/Feature/Filament/ChooseTenantRequiresWorkspaceTest.php new file mode 100644 index 0000000..5b43f97 --- /dev/null +++ b/tests/Feature/Filament/ChooseTenantRequiresWorkspaceTest.php @@ -0,0 +1,35 @@ +create([ + 'last_workspace_id' => null, + ]); + + $workspaceA = Workspace::factory()->create(); + $workspaceB = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspaceA->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspaceB->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $this->actingAs($user) + ->get('/admin/choose-tenant') + ->assertRedirect('/admin/choose-workspace'); +}); diff --git a/tests/Feature/Filament/TenantSwitcherUrlResolvesTenantTest.php b/tests/Feature/Filament/TenantSwitcherUrlResolvesTenantTest.php new file mode 100644 index 0000000..08ab387 --- /dev/null +++ b/tests/Feature/Filament/TenantSwitcherUrlResolvesTenantTest.php @@ -0,0 +1,37 @@ +create(); + + $tenantA = Tenant::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'name' => 'Tenant A', + 'status' => 'active', + ]); + + $tenantB = Tenant::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'name' => 'Tenant B', + 'status' => 'active', + ]); + + [$user] = createUserWithTenant($tenantA, role: 'owner'); + + createUserWithTenant($tenantB, user: $user, role: 'owner'); + + Filament::setTenant($tenantA, true); + expect(Filament::getTenant()?->is($tenantA))->toBeTrue(); + + $response = $this->actingAs($user) + ->get('/admin/t/'.$tenantB->external_id); + + expect(in_array($response->getStatusCode(), [200, 302], true))->toBeTrue(); + expect(Filament::getTenant())->toBeInstanceOf(Tenant::class); + expect(Filament::getTenant()?->is($tenantB))->toBeTrue(); +}); diff --git a/tests/Feature/Workspaces/ChooseWorkspaceRedirectsToChooseTenantTest.php b/tests/Feature/Workspaces/ChooseWorkspaceRedirectsToChooseTenantTest.php new file mode 100644 index 0000000..fae599b --- /dev/null +++ b/tests/Feature/Workspaces/ChooseWorkspaceRedirectsToChooseTenantTest.php @@ -0,0 +1,28 @@ +create(); + + $workspace = Workspace::factory()->create(); + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + Livewire::actingAs($user) + ->test(ChooseWorkspace::class) + ->call('selectWorkspace', $workspace->getKey()) + ->assertRedirect('/admin/choose-tenant'); +}); diff --git a/tests/Feature/Workspaces/CreateWorkspaceCreatesMembershipTest.php b/tests/Feature/Workspaces/CreateWorkspaceCreatesMembershipTest.php new file mode 100644 index 0000000..b20010a --- /dev/null +++ b/tests/Feature/Workspaces/CreateWorkspaceCreatesMembershipTest.php @@ -0,0 +1,64 @@ +create(); + + $existingWorkspace = Workspace::factory()->create(); + WorkspaceMembership::factory()->create([ + 'workspace_id' => $existingWorkspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + app(WorkspaceContext::class)->setCurrentWorkspace($existingWorkspace, $user); + + $tenant = Tenant::factory()->create([ + 'workspace_id' => $existingWorkspace->getKey(), + 'status' => 'active', + ]); + TenantMembership::query()->create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + 'source' => 'manual', + 'source_ref' => null, + 'created_by_user_id' => null, + ]); + Filament::setTenant($tenant, true); + + Livewire::actingAs($user) + ->test(CreateWorkspace::class) + ->fillForm([ + 'name' => 'Acme Workspace', + 'slug' => 'acme-workspace', + ]) + ->call('create') + ->assertHasNoFormErrors(); + + $createdWorkspace = Workspace::query() + ->where('slug', 'acme-workspace') + ->first(); + + expect($createdWorkspace)->not->toBeNull(); + + $this->assertDatabaseHas('workspace_memberships', [ + 'workspace_id' => $createdWorkspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); +}); diff --git a/tests/Pest.php b/tests/Pest.php index ebe72a2..9209eb8 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -2,7 +2,10 @@ use App\Models\Tenant; use App\Models\User; +use App\Models\Workspace; +use App\Models\WorkspaceMembership; use App\Services\Graph\GraphClientInterface; +use App\Support\Workspaces\WorkspaceContext; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\Support\AssertsNoOutboundHttp; use Tests\Support\FailHardGraphClient; @@ -90,6 +93,30 @@ function createUserWithTenant(?Tenant $tenant = null, ?User $user = null, string $user ??= User::factory()->create(); $tenant ??= Tenant::factory()->create(); + $workspace = null; + + if ($tenant->workspace_id !== null) { + $workspace = Workspace::query()->whereKey($tenant->workspace_id)->first(); + } + + if (! $workspace instanceof Workspace) { + $workspace = Workspace::factory()->create(); + + $tenant->forceFill([ + 'workspace_id' => (int) $workspace->getKey(), + ])->save(); + } + + WorkspaceMembership::query()->firstOrCreate([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + ], [ + 'role' => 'owner', + ]); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); + $user->forceFill(['last_workspace_id' => (int) $workspace->getKey()])->save(); + $user->tenants()->syncWithoutDetaching([ $tenant->getKey() => ['role' => $role], ]); -- 2.45.2 From b2f419bdb2230d6641d2743748347fc311759dcc Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Mon, 2 Feb 2026 10:59:24 +0100 Subject: [PATCH 03/10] feat: enforce workspace context for managed tenants (072) --- .../EnsureFilamentTenantSelected.php | 125 ++++++++++++++++++ routes/web.php | 100 ++++++++++++++ tests/Feature/AdminNewRedirectTest.php | 8 ++ .../AuthorizationSemanticsTest.php | 38 ++++++ .../ManagedTenants/OnboardingRedirectTest.php | 17 +++ .../ManagedTenantsWorkspaceRoutingTest.php | 93 +++++++++++++ 6 files changed, 381 insertions(+) create mode 100644 app/Support/Middleware/EnsureFilamentTenantSelected.php create mode 100644 tests/Feature/AdminNewRedirectTest.php create mode 100644 tests/Feature/ManagedTenants/AuthorizationSemanticsTest.php create mode 100644 tests/Feature/ManagedTenants/OnboardingRedirectTest.php create mode 100644 tests/Feature/Workspaces/ManagedTenantsWorkspaceRoutingTest.php diff --git a/app/Support/Middleware/EnsureFilamentTenantSelected.php b/app/Support/Middleware/EnsureFilamentTenantSelected.php new file mode 100644 index 0000000..04fb305 --- /dev/null +++ b/app/Support/Middleware/EnsureFilamentTenantSelected.php @@ -0,0 +1,125 @@ +route()?->hasParameter('tenant')) { + $user = $request->user(); + + if ($user === null) { + return $next($request); + } + + if (! $user instanceof HasTenants) { + abort(404); + } + + $panel = Filament::getCurrentOrDefaultPanel(); + + if (! $panel->hasTenancy()) { + return $next($request); + } + + $tenantParameter = $request->route()->parameter('tenant'); + + $tenant = $panel->getTenant($tenantParameter); + + if (! $tenant instanceof Tenant) { + abort(404); + } + + $workspaceContext = app(WorkspaceContext::class); + $workspaceId = $workspaceContext->currentWorkspaceId($request); + + if ($workspaceId === null) { + abort(404); + } + + if ((int) $tenant->workspace_id !== (int) $workspaceId) { + abort(404); + } + + $workspace = Workspace::query()->whereKey($workspaceId)->first(); + + if (! $workspace instanceof Workspace) { + abort(404); + } + + if (! $user instanceof User || ! $workspaceContext->isMember($user, $workspace)) { + abort(404); + } + + if (! $user->canAccessTenant($tenant)) { + abort(404); + } + + Filament::setTenant($tenant, true); + + return $next($request); + } + + if (filled(Filament::getTenant())) { + return $next($request); + } + + $user = $request->user(); + + if (! $user instanceof User) { + return $next($request); + } + + $tenant = null; + + 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() + ->whereNull('deleted_at') + ->where('status', 'active') + ->first(); + } + + if (! $tenant) { + $tenant = $user->tenants() + ->whereNull('deleted_at') + ->first(); + } + + if (! $tenant) { + $tenant = $user->tenants() + ->withTrashed() + ->first(); + } + + if ($tenant) { + Filament::setTenant($tenant, true); + } + + return $next($request); + } +} diff --git a/routes/web.php b/routes/web.php index b1ef093..ac4f48c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,9 +1,18 @@ name('admin.consent.start'); +// Fallback route: Filament's layout generates this URL when tenancy registration is enabled. +// In this app, package route registration may not always define it early enough, which breaks +// rendering on tenant-scoped routes. +Route::middleware([ + 'web', + 'panel:admin', + 'ensure-correct-guard:web', + DenyNonMemberTenantAccess::class, + DisableBladeIconComponents::class, + DispatchServingFilamentEvent::class, + FilamentAuthenticate::class, + 'ensure-workspace-selected', +]) + ->prefix('/admin') + ->name('filament.admin.') + ->get('/register-tenant', RegisterTenant::class) + ->name('tenant.registration'); + Route::get('/admin/rbac/start', [RbacDelegatedAuthController::class, 'start']) ->name('admin.rbac.start'); @@ -28,3 +55,76 @@ Route::get('/auth/entra/callback', [EntraController::class, 'callback']) ->middleware('throttle:entra-callback') ->name('auth.entra.callback'); + +Route::middleware(['web', 'auth', 'ensure-workspace-selected']) + ->get('/admin/managed-tenants', function (Request $request) { + $workspace = app(WorkspaceContext::class)->currentWorkspace($request); + + if (! $workspace instanceof Workspace) { + return redirect('/admin/choose-workspace'); + } + + return redirect('/admin/w/'.($workspace->slug ?? $workspace->getKey()).'/managed-tenants'); + }) + ->name('admin.legacy.managed-tenants.index'); + +Route::middleware(['web', 'auth', 'ensure-workspace-selected']) + ->get('/admin/managed-tenants/onboarding', function (Request $request) { + $workspace = app(WorkspaceContext::class)->currentWorkspace($request); + + if (! $workspace instanceof Workspace) { + return redirect('/admin/choose-workspace'); + } + + return redirect('/admin/w/'.($workspace->slug ?? $workspace->getKey()).'/managed-tenants/onboarding'); + }) + ->name('admin.legacy.managed-tenants.onboarding'); + +Route::middleware(['web', 'auth', 'ensure-workspace-selected']) + ->get('/admin/new', function (Request $request) { + $workspace = app(WorkspaceContext::class)->currentWorkspace($request); + + if (! $workspace instanceof Workspace) { + return redirect('/admin/choose-workspace'); + } + + return redirect('/admin/w/'.($workspace->slug ?? $workspace->getKey()).'/managed-tenants/onboarding'); + }) + ->name('admin.legacy.onboarding'); + +Route::bind('workspace', function (string $value): Workspace { + /** @var WorkspaceResolver $resolver */ + $resolver = app(WorkspaceResolver::class); + + $workspace = $resolver->resolve($value); + + abort_unless($workspace instanceof Workspace, 404); + + return $workspace; +}); + +Route::middleware(['web', 'auth', 'ensure-workspace-member']) + ->prefix('/admin/w/{workspace}') + ->group(function (): void { + Route::get('/', fn () => redirect('/admin/tenants')) + ->name('admin.workspace.home'); + + Route::get('/ping', fn () => response()->noContent())->name('admin.workspace.ping'); + + Route::get('/managed-tenants', fn () => redirect('/admin/tenants')) + ->name('admin.workspace.managed-tenants.index'); + + Route::get('/managed-tenants/onboarding', fn () => redirect('/admin/tenants/create')) + ->name('admin.workspace.managed-tenants.onboarding'); + }); + +if (app()->runningUnitTests()) { + Route::middleware(['web', 'auth', 'ensure-workspace-selected']) + ->get('/admin/_test/workspace-context', function (Request $request) { + $workspaceId = app(\App\Support\Workspaces\WorkspaceContext::class)->currentWorkspaceId($request); + + return response()->json([ + 'workspace_id' => $workspaceId, + ]); + }); +} diff --git a/tests/Feature/AdminNewRedirectTest.php b/tests/Feature/AdminNewRedirectTest.php new file mode 100644 index 0000000..e278284 --- /dev/null +++ b/tests/Feature/AdminNewRedirectTest.php @@ -0,0 +1,8 @@ +get('/admin/new') + ->assertRedirect('/admin/login'); +}); diff --git a/tests/Feature/ManagedTenants/AuthorizationSemanticsTest.php b/tests/Feature/ManagedTenants/AuthorizationSemanticsTest.php new file mode 100644 index 0000000..0e4fd5d --- /dev/null +++ b/tests/Feature/ManagedTenants/AuthorizationSemanticsTest.php @@ -0,0 +1,38 @@ +create(); + [$user] = createUserWithTenant($tenant, role: 'readonly'); + + $this->actingAs($user) + ->get("/admin/t/{$tenant->external_id}/tenants/{$tenant->id}/edit") + ->assertForbidden(); +}); + +it('returns 404 for a non-member attempting to access a workspace managed-tenant list', function (): void { + $workspace = Workspace::factory()->create(); + Tenant::factory()->create(['workspace_id' => $workspace->getKey()]); + + $user = User::factory()->create(); + + $otherWorkspace = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $otherWorkspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'readonly', + ]); + + $user->forceFill(['last_workspace_id' => $otherWorkspace->getKey()])->save(); + + $this->actingAs($user) + ->get('/admin/w/'.$workspace->slug.'/managed-tenants') + ->assertNotFound(); +}); diff --git a/tests/Feature/ManagedTenants/OnboardingRedirectTest.php b/tests/Feature/ManagedTenants/OnboardingRedirectTest.php new file mode 100644 index 0000000..863c093 --- /dev/null +++ b/tests/Feature/ManagedTenants/OnboardingRedirectTest.php @@ -0,0 +1,17 @@ +create(); + [$user] = createUserWithTenant($tenant, role: 'owner'); + + $workspace = $tenant->workspace; + expect($workspace)->not->toBeNull(); + + $this->actingAs($user) + ->get('/admin/new') + ->assertRedirect('/admin/w/'.$workspace->slug.'/managed-tenants/onboarding'); +}); diff --git a/tests/Feature/Workspaces/ManagedTenantsWorkspaceRoutingTest.php b/tests/Feature/Workspaces/ManagedTenantsWorkspaceRoutingTest.php new file mode 100644 index 0000000..c93f556 --- /dev/null +++ b/tests/Feature/Workspaces/ManagedTenantsWorkspaceRoutingTest.php @@ -0,0 +1,93 @@ +create(); + + $workspace = Workspace::factory()->create(['slug' => 'acme']); + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get('/admin/managed-tenants') + ->assertRedirect("/admin/w/{$workspace->slug}/managed-tenants"); +}); + +it('returns 404 on tenant routes when workspace context is missing', function (): void { + $user = User::factory()->create(); + + $workspace = Workspace::factory()->create(); + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $tenant = Tenant::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'external_id' => '11111111-1111-1111-1111-111111111111', + 'tenant_id' => '11111111-1111-1111-1111-111111111111', + ]); + + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + $this->actingAs($user) + ->get(TenantDashboard::getUrl(tenant: $tenant)) + ->assertNotFound(); +}); + +it('returns 404 on tenant routes when tenant workspace mismatches current workspace', function (): void { + $user = User::factory()->create(); + + $workspaceA = Workspace::factory()->create(['slug' => 'ws-a']); + $workspaceB = Workspace::factory()->create(['slug' => 'ws-b']); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspaceA->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspaceB->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $tenantInA = Tenant::factory()->create([ + 'workspace_id' => (int) $workspaceA->getKey(), + 'external_id' => 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + 'tenant_id' => 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + ]); + + $user->tenants()->syncWithoutDetaching([ + $tenantInA->getKey() => ['role' => 'owner'], + ]); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspaceB->getKey()]) + ->get(TenantDashboard::getUrl(tenant: $tenantInA)) + ->assertNotFound(); +}); -- 2.45.2 From b60a8cea045f121514158ef908f828639095cfd0 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Mon, 2 Feb 2026 10:59:34 +0100 Subject: [PATCH 04/10] fix: render Filament page header actions on selection pages --- .../filament/pages/choose-tenant.blade.php | 70 ++++++++++--------- .../filament/pages/choose-workspace.blade.php | 40 +++++++++++ .../views/filament/pages/no-access.blade.php | 20 +++--- 3 files changed, 87 insertions(+), 43 deletions(-) create mode 100644 resources/views/filament/pages/choose-workspace.blade.php diff --git a/resources/views/filament/pages/choose-tenant.blade.php b/resources/views/filament/pages/choose-tenant.blade.php index ca5a741..6e16aa7 100644 --- a/resources/views/filament/pages/choose-tenant.blade.php +++ b/resources/views/filament/pages/choose-tenant.blade.php @@ -1,37 +1,39 @@ - -
-
- Select a tenant to continue. -
- - @php - $tenants = $this->getTenants(); - @endphp - - @if ($tenants->isEmpty()) -
- No tenants are available for your account. + + +
+
+ Select a tenant to continue.
- @else -
- @foreach ($tenants as $tenant) -
-
-
- {{ $tenant->name }} + + @php + $tenants = $this->getTenants(); + @endphp + + @if ($tenants->isEmpty()) +
+ No tenants are available for your account. +
+ @else +
+ @foreach ($tenants as $tenant) +
+
+
+ {{ $tenant->name }} +
+ + + Continue +
- - - Continue -
-
- @endforeach -
- @endif -
- + @endforeach +
+ @endif +
+ + diff --git a/resources/views/filament/pages/choose-workspace.blade.php b/resources/views/filament/pages/choose-workspace.blade.php new file mode 100644 index 0000000..77fde00 --- /dev/null +++ b/resources/views/filament/pages/choose-workspace.blade.php @@ -0,0 +1,40 @@ + + +
+
+ Select a workspace to continue. +
+ + @php + $workspaces = $this->getWorkspaces(); + @endphp + + @if ($workspaces->isEmpty()) +
+ No active workspaces are available for your account. + You can create one using the button above. +
+ @else +
+ @foreach ($workspaces as $workspace) +
+
+
+ {{ $workspace->name }} +
+ + + Continue + +
+
+ @endforeach +
+ @endif +
+
+
diff --git a/resources/views/filament/pages/no-access.blade.php b/resources/views/filament/pages/no-access.blade.php index b00fcc7..c42993e 100644 --- a/resources/views/filament/pages/no-access.blade.php +++ b/resources/views/filament/pages/no-access.blade.php @@ -1,11 +1,13 @@ - -
-
- You don’t have access to any tenants yet. -
+ + +
+
+ You don’t have access to any tenants yet. +
-
- Ask an administrator to add you to a tenant, then sign in again. +
+ Ask an administrator to add you to a tenant, then sign in again. +
-
-
+ +
-- 2.45.2 From 35e14c10753b2f64d6d53d01b3930d1b2c88e981 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Mon, 2 Feb 2026 16:52:32 +0100 Subject: [PATCH 05/10] feat: enforce workspace context + last-owner safeguards --- app/Console/Commands/SyncPolicies.php | 2 +- .../TenantpilotPurgeNonPersistentData.php | 2 +- app/Filament/Pages/ChooseTenant.php | 6 + app/Filament/Pages/ChooseWorkspace.php | 31 ++- app/Filament/Pages/Tenancy/RegisterTenant.php | 22 ++ .../Resources/BackupScheduleResource.php | 4 +- .../BackupScheduleRunsRelationManager.php | 2 +- app/Filament/Resources/PolicyResource.php | 2 +- .../Resources/PolicyVersionResource.php | 2 +- app/Filament/Resources/RestoreRunResource.php | 4 +- app/Filament/Resources/TenantResource.php | 25 ++- .../TenantResource/Pages/CreateTenant.php | 16 ++ .../Workspaces/Pages/ViewWorkspace.php | 19 ++ .../WorkspaceMembershipsRelationManager.php | 204 ++++++++++++++++++ .../Workspaces/WorkspaceResource.php | 17 +- .../System/Pages/RepairWorkspaceOwners.php | 169 +++++++++++++++ .../Controllers/SelectTenantController.php | 72 +++++++ .../Controllers/SwitchWorkspaceController.php | 61 ++++++ .../Middleware/EnsureWorkspaceSelected.php | 4 + app/Livewire/BackupSetPolicyPickerTable.php | 2 +- app/Models/Tenant.php | 9 +- app/Providers/AuthServiceProvider.php | 6 +- app/Providers/Filament/AdminPanelProvider.php | 14 ++ app/Services/Audit/WorkspaceAuditLogger.php | 9 +- .../Auth/WorkspaceMembershipManager.php | 181 +++++++++------- app/Support/Audit/AuditActionId.php | 6 + .../EnsureFilamentTenantSelected.php | 83 +++++-- app/Support/Workspaces/WorkspaceContext.php | 4 - .../filament/pages/choose-tenant.blade.php | 49 ++++- .../filament/pages/choose-workspace.blade.php | 44 +++- .../partials/workspace-switcher.blade.php | 47 ++++ .../pages/repair-workspace-owners.blade.php | 13 ++ routes/web.php | 34 +++ .../plan.md | 2 + .../spec.md | 1 + .../tasks.md | 16 ++ .../BreakGlassWorkspaceOwnerRecoveryTest.php | 89 ++++++++ .../Auth/WorkspaceLastOwnerGuardTest.php | 96 +++++++++ ...oChooseTenantWhenWorkspaceSelectedTest.php | 29 +++ ...tyStateRegisterTenantCtaVisibilityTest.php | 48 +++++ ...rkspaceShowsLastUsedRecommendationTest.php | 45 ++++ ...oseWorkspaceWhenMultipleWorkspacesTest.php | 40 ++++ .../SelectTenantPostPersistsLastUsedTest.php | 65 ++++++ ...nantResourceIndexIsWorkspaceScopedTest.php | 73 +++++++ ...aceContextTopbarAndTenantSelectionTest.php | 72 +++++++ ...seWorkspaceRedirectsToChooseTenantTest.php | 34 ++- ...sToTenantRegistrationWhenNoTenantsTest.php | 30 +++ .../WorkspacesResourceIsTenantlessTest.php | 75 +++++++ 48 files changed, 1753 insertions(+), 127 deletions(-) create mode 100644 app/Filament/Resources/Workspaces/Pages/ViewWorkspace.php create mode 100644 app/Filament/Resources/Workspaces/RelationManagers/WorkspaceMembershipsRelationManager.php create mode 100644 app/Filament/System/Pages/RepairWorkspaceOwners.php create mode 100644 app/Http/Controllers/SelectTenantController.php create mode 100644 app/Http/Controllers/SwitchWorkspaceController.php create mode 100644 resources/views/filament/partials/workspace-switcher.blade.php create mode 100644 resources/views/filament/system/pages/repair-workspace-owners.blade.php create mode 100644 tests/Feature/Auth/BreakGlassWorkspaceOwnerRecoveryTest.php create mode 100644 tests/Feature/Auth/WorkspaceLastOwnerGuardTest.php create mode 100644 tests/Feature/Filament/AdminHomeRedirectsToChooseTenantWhenWorkspaceSelectedTest.php create mode 100644 tests/Feature/Filament/ChooseTenantEmptyStateRegisterTenantCtaVisibilityTest.php create mode 100644 tests/Feature/Filament/ChooseWorkspaceShowsLastUsedRecommendationTest.php create mode 100644 tests/Feature/Filament/LoginRedirectsToChooseWorkspaceWhenMultipleWorkspacesTest.php create mode 100644 tests/Feature/Filament/SelectTenantPostPersistsLastUsedTest.php create mode 100644 tests/Feature/Filament/TenantResourceIndexIsWorkspaceScopedTest.php create mode 100644 tests/Feature/Filament/WorkspaceContextTopbarAndTenantSelectionTest.php create mode 100644 tests/Feature/Workspaces/SwitchWorkspaceRedirectsToTenantRegistrationWhenNoTenantsTest.php create mode 100644 tests/Feature/Workspaces/WorkspacesResourceIsTenantlessTest.php diff --git a/app/Console/Commands/SyncPolicies.php b/app/Console/Commands/SyncPolicies.php index 8546b78..9a34d81 100644 --- a/app/Console/Commands/SyncPolicies.php +++ b/app/Console/Commands/SyncPolicies.php @@ -34,6 +34,6 @@ private function resolveTenant(): Tenant ->firstOrFail(); } - return Tenant::current(); + return Tenant::currentOrFail(); } } diff --git a/app/Console/Commands/TenantpilotPurgeNonPersistentData.php b/app/Console/Commands/TenantpilotPurgeNonPersistentData.php index ba153a1..21c9ff2 100644 --- a/app/Console/Commands/TenantpilotPurgeNonPersistentData.php +++ b/app/Console/Commands/TenantpilotPurgeNonPersistentData.php @@ -138,7 +138,7 @@ private function resolveTenants() } try { - return collect([Tenant::current()]); + return collect([Tenant::currentOrFail()]); } catch (RuntimeException) { return collect(); } diff --git a/app/Filament/Pages/ChooseTenant.php b/app/Filament/Pages/ChooseTenant.php index 31e8181..bead16d 100644 --- a/app/Filament/Pages/ChooseTenant.php +++ b/app/Filament/Pages/ChooseTenant.php @@ -4,6 +4,7 @@ namespace App\Filament\Pages; +use App\Filament\Pages\Tenancy\RegisterTenant as RegisterTenantPage; use App\Models\Tenant; use App\Models\User; use App\Models\UserTenantPreference; @@ -72,6 +73,11 @@ public function selectTenant(int $tenantId): void $this->redirect(TenantDashboard::getUrl(tenant: $tenant)); } + public function canRegisterTenant(): bool + { + return RegisterTenantPage::canView(); + } + private function persistLastTenant(User $user, Tenant $tenant): void { if (Schema::hasColumn('users', 'last_tenant_id')) { diff --git a/app/Filament/Pages/ChooseWorkspace.php b/app/Filament/Pages/ChooseWorkspace.php index 10bd3d0..8de5ab3 100644 --- a/app/Filament/Pages/ChooseWorkspace.php +++ b/app/Filament/Pages/ChooseWorkspace.php @@ -9,6 +9,7 @@ use App\Models\WorkspaceMembership; use App\Support\Workspaces\WorkspaceContext; use Filament\Actions\Action; +use Filament\Facades\Filament; use Filament\Forms\Components\TextInput; use Filament\Notifications\Notification; use Filament\Pages\Page; @@ -100,7 +101,7 @@ public function selectWorkspace(int $workspaceId): void $context->setCurrentWorkspace($workspace, $user, request()); - $this->redirect(ChooseTenant::getUrl()); + $this->redirect($this->redirectAfterWorkspaceSelected($user)); } /** @@ -132,6 +133,32 @@ public function createWorkspace(array $data): void ->success() ->send(); - $this->redirect(ChooseTenant::getUrl()); + $this->redirect($this->redirectAfterWorkspaceSelected($user)); + } + + private function redirectAfterWorkspaceSelected(User $user): string + { + $tenants = $user->getTenants(Filament::getCurrentOrDefaultPanel()); + + $tenants = $tenants instanceof Collection ? $tenants : collect($tenants); + + if ($tenants->isEmpty()) { + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(); + + if ($workspaceId !== null) { + $role = WorkspaceMembership::query() + ->where('workspace_id', $workspaceId) + ->where('user_id', $user->getKey()) + ->value('role'); + + if (in_array($role, ['owner', 'manager'], true)) { + return route('filament.admin.tenant.registration'); + } + } + + return ChooseTenant::getUrl(); + } + + return ChooseTenant::getUrl(); } } diff --git a/app/Filament/Pages/Tenancy/RegisterTenant.php b/app/Filament/Pages/Tenancy/RegisterTenant.php index 5940f23..4396496 100644 --- a/app/Filament/Pages/Tenancy/RegisterTenant.php +++ b/app/Filament/Pages/Tenancy/RegisterTenant.php @@ -4,9 +4,11 @@ use App\Models\Tenant; use App\Models\User; +use App\Models\WorkspaceMembership; use App\Services\Auth\CapabilityResolver; use App\Services\Intune\AuditLogger; use App\Support\Auth\Capabilities; +use App\Support\Workspaces\WorkspaceContext; use Filament\Forms; use Filament\Pages\Tenancy\RegisterTenant as BaseRegisterTenant; use Filament\Schemas\Schema; @@ -27,6 +29,20 @@ public static function canView(): bool return false; } + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(); + + if ($workspaceId !== null) { + $canRegisterInWorkspace = WorkspaceMembership::query() + ->where('workspace_id', $workspaceId) + ->where('user_id', $user->getKey()) + ->whereIn('role', ['owner', 'manager']) + ->exists(); + + if ($canRegisterInWorkspace) { + return true; + } + } + $tenantIds = $user->tenants()->withTrashed()->pluck('tenants.id'); if ($tenantIds->isEmpty()) { @@ -95,6 +111,12 @@ protected function handleRegistration(array $data): Model abort(403); } + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(); + + if ($workspaceId !== null) { + $data['workspace_id'] = $workspaceId; + } + $tenant = Tenant::create($data); $user = auth()->user(); diff --git a/app/Filament/Resources/BackupScheduleResource.php b/app/Filament/Resources/BackupScheduleResource.php index 6ce9c03..cd05f7e 100644 --- a/app/Filament/Resources/BackupScheduleResource.php +++ b/app/Filament/Resources/BackupScheduleResource.php @@ -938,7 +938,7 @@ public static function table(Table $table): Table public static function getEloquentQuery(): Builder { - $tenantId = Tenant::current()->getKey(); + $tenantId = Tenant::currentOrFail()->getKey(); return parent::getEloquentQuery() ->where('tenant_id', $tenantId) @@ -1054,7 +1054,7 @@ public static function ensurePolicyTypes(array $data): array public static function assignTenant(array $data): array { - $data['tenant_id'] = Tenant::current()->getKey(); + $data['tenant_id'] = Tenant::currentOrFail()->getKey(); return $data; } diff --git a/app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleRunsRelationManager.php b/app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleRunsRelationManager.php index d0b1e99..c615989 100644 --- a/app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleRunsRelationManager.php +++ b/app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleRunsRelationManager.php @@ -21,7 +21,7 @@ class BackupScheduleRunsRelationManager extends RelationManager public function table(Table $table): Table { return $table - ->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::current()->getKey())->with('backupSet')) + ->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::currentOrFail()->getKey())->with('backupSet')) ->defaultSort('scheduled_for', 'desc') ->columns([ Tables\Columns\TextColumn::make('scheduled_for') diff --git a/app/Filament/Resources/PolicyResource.php b/app/Filament/Resources/PolicyResource.php index f2260eb..24b5480 100644 --- a/app/Filament/Resources/PolicyResource.php +++ b/app/Filament/Resources/PolicyResource.php @@ -894,7 +894,7 @@ public static function table(Table $table): Table public static function getEloquentQuery(): Builder { - $tenantId = Tenant::current()->getKey(); + $tenantId = Tenant::currentOrFail()->getKey(); return parent::getEloquentQuery() ->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId)) diff --git a/app/Filament/Resources/PolicyVersionResource.php b/app/Filament/Resources/PolicyVersionResource.php index bb703c6..399d647 100644 --- a/app/Filament/Resources/PolicyVersionResource.php +++ b/app/Filament/Resources/PolicyVersionResource.php @@ -815,7 +815,7 @@ public static function table(Table $table): Table public static function getEloquentQuery(): Builder { - $tenantId = Tenant::current()->getKey(); + $tenantId = Tenant::currentOrFail()->getKey(); return parent::getEloquentQuery() ->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId)) diff --git a/app/Filament/Resources/RestoreRunResource.php b/app/Filament/Resources/RestoreRunResource.php index 34f6bc7..f0ee904 100644 --- a/app/Filament/Resources/RestoreRunResource.php +++ b/app/Filament/Resources/RestoreRunResource.php @@ -87,7 +87,7 @@ public static function form(Schema $schema): Schema Forms\Components\Select::make('backup_set_id') ->label('Backup set') ->options(function () { - $tenantId = Tenant::current()->getKey(); + $tenantId = Tenant::currentOrFail()->getKey(); return BackupSet::query() ->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId)) @@ -219,7 +219,7 @@ public static function getWizardSteps(): array Forms\Components\Select::make('backup_set_id') ->label('Backup set') ->options(function () { - $tenantId = Tenant::current()->getKey(); + $tenantId = Tenant::currentOrFail()->getKey(); return BackupSet::query() ->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId)) diff --git a/app/Filament/Resources/TenantResource.php b/app/Filament/Resources/TenantResource.php index d52f5a6..386b02a 100644 --- a/app/Filament/Resources/TenantResource.php +++ b/app/Filament/Resources/TenantResource.php @@ -9,6 +9,7 @@ use App\Jobs\SyncPoliciesJob; use App\Models\Tenant; use App\Models\User; +use App\Models\WorkspaceMembership; use App\Services\Auth\CapabilityResolver; use App\Services\Auth\RoleCapabilityMap; use App\Services\Directory\EntraGroupLabelResolver; @@ -30,6 +31,7 @@ use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\Rbac\UiEnforcement; +use App\Support\Workspaces\WorkspaceContext; use BackedEnum; use Filament\Actions; use Filament\Actions\ActionGroup; @@ -70,7 +72,21 @@ public static function canCreate(): bool return false; } - return static::userCanManageAnyTenant($user); + if (static::userCanManageAnyTenant($user)) { + return true; + } + + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(); + + if ($workspaceId === null) { + return false; + } + + return WorkspaceMembership::query() + ->where('workspace_id', $workspaceId) + ->where('user_id', $user->getKey()) + ->whereIn('role', ['owner', 'manager']) + ->exists(); } public static function canEdit(Model $record): bool @@ -179,8 +195,15 @@ public static function getEloquentQuery(): Builder return parent::getEloquentQuery()->whereRaw('1 = 0'); } + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); + + if ($workspaceId === null) { + return parent::getEloquentQuery()->whereRaw('1 = 0'); + } + $tenantIds = $user->tenants() ->withTrashed() + ->where('workspace_id', $workspaceId) ->pluck('tenants.id'); return parent::getEloquentQuery() diff --git a/app/Filament/Resources/TenantResource/Pages/CreateTenant.php b/app/Filament/Resources/TenantResource/Pages/CreateTenant.php index 6d3bd89..c5fec3e 100644 --- a/app/Filament/Resources/TenantResource/Pages/CreateTenant.php +++ b/app/Filament/Resources/TenantResource/Pages/CreateTenant.php @@ -4,12 +4,28 @@ use App\Filament\Resources\TenantResource; use App\Models\User; +use App\Support\Workspaces\WorkspaceContext; use Filament\Resources\Pages\CreateRecord; class CreateTenant extends CreateRecord { protected static string $resource = TenantResource::class; + /** + * @param array $data + * @return array + */ + protected function mutateFormDataBeforeCreate(array $data): array + { + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(); + + if ($workspaceId !== null) { + $data['workspace_id'] = $workspaceId; + } + + return $data; + } + protected function afterCreate(): void { $user = auth()->user(); diff --git a/app/Filament/Resources/Workspaces/Pages/ViewWorkspace.php b/app/Filament/Resources/Workspaces/Pages/ViewWorkspace.php new file mode 100644 index 0000000..aa29f70 --- /dev/null +++ b/app/Filament/Resources/Workspaces/Pages/ViewWorkspace.php @@ -0,0 +1,19 @@ +modifyQueryUsing(fn (Builder $query) => $query->with('user')) + ->columns([ + Tables\Columns\TextColumn::make('user.name') + ->label(__('User')) + ->searchable(), + Tables\Columns\TextColumn::make('user.email') + ->label(__('Email')) + ->toggleable(isToggledHiddenByDefault: true), + Tables\Columns\TextColumn::make('role') + ->badge() + ->sortable(), + Tables\Columns\TextColumn::make('created_at')->since(), + ]) + ->headerActions([ + UiEnforcement::forTableAction( + Action::make('add_member') + ->label(__('Add member')) + ->icon('heroicon-o-plus') + ->form([ + Forms\Components\Select::make('user_id') + ->label(__('User')) + ->required() + ->searchable() + ->options(fn () => User::query()->orderBy('name')->pluck('name', 'id')->all()), + Forms\Components\Select::make('role') + ->label(__('Role')) + ->required() + ->options([ + WorkspaceRole::Owner->value => __('Owner'), + WorkspaceRole::Manager->value => __('Manager'), + WorkspaceRole::Operator->value => __('Operator'), + WorkspaceRole::Readonly->value => __('Readonly'), + ]), + ]) + ->action(function (array $data, WorkspaceMembershipManager $manager): void { + $workspace = $this->getOwnerRecord(); + + if (! $workspace instanceof Workspace) { + abort(404); + } + + $actor = auth()->user(); + if (! $actor instanceof User) { + abort(403); + } + + $member = User::query()->find((int) $data['user_id']); + if (! $member) { + Notification::make()->title(__('User not found'))->danger()->send(); + + return; + } + + try { + $manager->addMember( + workspace: $workspace, + actor: $actor, + member: $member, + role: (string) $data['role'], + source: 'manual', + ); + } catch (\Throwable $throwable) { + Notification::make() + ->title(__('Failed to add member')) + ->body($throwable->getMessage()) + ->danger() + ->send(); + + return; + } + + Notification::make()->title(__('Member added'))->success()->send(); + $this->resetTable(); + }), + fn () => $this->getOwnerRecord(), + ) + ->requireCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE) + ->tooltip('You do not have permission to manage workspace memberships.') + ->apply(), + ]) + ->actions([ + UiEnforcement::forTableAction( + Action::make('change_role') + ->label(__('Change role')) + ->icon('heroicon-o-pencil') + ->requiresConfirmation() + ->form([ + Forms\Components\Select::make('role') + ->label(__('Role')) + ->required() + ->options([ + WorkspaceRole::Owner->value => __('Owner'), + WorkspaceRole::Manager->value => __('Manager'), + WorkspaceRole::Operator->value => __('Operator'), + WorkspaceRole::Readonly->value => __('Readonly'), + ]), + ]) + ->action(function (WorkspaceMembership $record, array $data, WorkspaceMembershipManager $manager): void { + $workspace = $this->getOwnerRecord(); + + if (! $workspace instanceof Workspace) { + abort(404); + } + + $actor = auth()->user(); + if (! $actor instanceof User) { + abort(403); + } + + try { + $manager->changeRole( + workspace: $workspace, + actor: $actor, + membership: $record, + newRole: (string) $data['role'], + ); + } catch (\Throwable $throwable) { + Notification::make() + ->title(__('Failed to change role')) + ->body($throwable->getMessage()) + ->danger() + ->send(); + + return; + } + + Notification::make()->title(__('Role updated'))->success()->send(); + $this->resetTable(); + }), + fn () => $this->getOwnerRecord(), + ) + ->requireCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE) + ->tooltip('You do not have permission to manage workspace memberships.') + ->apply(), + + UiEnforcement::forTableAction( + Action::make('remove') + ->label(__('Remove')) + ->color('danger') + ->icon('heroicon-o-x-mark') + ->requiresConfirmation() + ->action(function (WorkspaceMembership $record, WorkspaceMembershipManager $manager): void { + $workspace = $this->getOwnerRecord(); + + if (! $workspace instanceof Workspace) { + abort(404); + } + + $actor = auth()->user(); + if (! $actor instanceof User) { + abort(403); + } + + try { + $manager->removeMember(workspace: $workspace, actor: $actor, membership: $record); + } catch (\Throwable $throwable) { + Notification::make() + ->title(__('Failed to remove member')) + ->body($throwable->getMessage()) + ->danger() + ->send(); + + return; + } + + Notification::make()->title(__('Member removed'))->success()->send(); + $this->resetTable(); + }), + fn () => $this->getOwnerRecord(), + ) + ->requireCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE) + ->tooltip('You do not have permission to manage workspace memberships.') + ->destructive() + ->apply(), + ]) + ->bulkActions([]); + } +} diff --git a/app/Filament/Resources/Workspaces/WorkspaceResource.php b/app/Filament/Resources/Workspaces/WorkspaceResource.php index c58ddde..7bd5576 100644 --- a/app/Filament/Resources/Workspaces/WorkspaceResource.php +++ b/app/Filament/Resources/Workspaces/WorkspaceResource.php @@ -2,8 +2,10 @@ namespace App\Filament\Resources\Workspaces; +use App\Filament\Resources\Workspaces\RelationManagers\WorkspaceMembershipsRelationManager; use App\Models\Workspace; use BackedEnum; +use Filament\Actions; use Filament\Forms; use Filament\Resources\Resource; use Filament\Schemas\Schema; @@ -13,10 +15,14 @@ class WorkspaceResource extends Resource { + protected static bool $isDiscovered = false; + protected static ?string $model = Workspace::class; protected static bool $isScopedToTenant = false; + protected static ?string $recordTitleAttribute = 'name'; + protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-squares-2x2'; protected static string|UnitEnum|null $navigationGroup = 'Settings'; @@ -47,7 +53,8 @@ public static function table(Table $table): Table ->sortable(), ]) ->actions([ - Tables\Actions\EditAction::make(), + Actions\ViewAction::make(), + Actions\EditAction::make(), ]); } @@ -56,7 +63,15 @@ public static function getPages(): array return [ 'index' => Pages\ListWorkspaces::route('/'), 'create' => Pages\CreateWorkspace::route('/create'), + 'view' => Pages\ViewWorkspace::route('/{record}'), 'edit' => Pages\EditWorkspace::route('/{record}/edit'), ]; } + + public static function getRelations(): array + { + return [ + WorkspaceMembershipsRelationManager::class, + ]; + } } diff --git a/app/Filament/System/Pages/RepairWorkspaceOwners.php b/app/Filament/System/Pages/RepairWorkspaceOwners.php new file mode 100644 index 0000000..72d41ae --- /dev/null +++ b/app/Filament/System/Pages/RepairWorkspaceOwners.php @@ -0,0 +1,169 @@ +user(); + + if (! $user instanceof PlatformUser) { + return false; + } + + return $user->hasCapability(PlatformCapabilities::USE_BREAK_GLASS); + } + + /** + * @return array + */ + protected function getHeaderActions(): array + { + $breakGlass = app(BreakGlassSession::class); + + return [ + Action::make('assign_owner') + ->label('Assign owner (break-glass)') + ->color('danger') + ->requiresConfirmation() + ->modalHeading('Assign workspace owner') + ->modalDescription('This is a recovery action. It is audited and should only be used when the workspace owner set is broken.') + ->form([ + Select::make('workspace_id') + ->label('Workspace') + ->required() + ->searchable() + ->getSearchResultsUsing(function (string $search): array { + return Workspace::query() + ->where('name', 'like', "%{$search}%") + ->orderBy('name') + ->limit(25) + ->pluck('name', 'id') + ->all(); + }) + ->getOptionLabelUsing(function ($value): ?string { + if (! is_numeric($value)) { + return null; + } + + return Workspace::query()->whereKey((int) $value)->value('name'); + }), + + Select::make('target_user_id') + ->label('User') + ->required() + ->searchable() + ->getSearchResultsUsing(function (string $search): array { + return User::query() + ->where('email', 'like', "%{$search}%") + ->orderBy('email') + ->limit(25) + ->pluck('email', 'id') + ->all(); + }) + ->getOptionLabelUsing(function ($value): ?string { + if (! is_numeric($value)) { + return null; + } + + return User::query()->whereKey((int) $value)->value('email'); + }), + + Textarea::make('reason') + ->label('Reason') + ->required() + ->minLength(5) + ->maxLength(500) + ->rows(4), + ]) + ->action(function (array $data, BreakGlassSession $breakGlass, WorkspaceAuditLogger $auditLogger): void { + $platformUser = auth('platform')->user(); + + if (! $platformUser instanceof PlatformUser) { + abort(403); + } + + if (! $platformUser->hasCapability(PlatformCapabilities::USE_BREAK_GLASS)) { + abort(403); + } + + if (! $breakGlass->isActive()) { + abort(403); + } + + $workspaceId = (int) ($data['workspace_id'] ?? 0); + $targetUserId = (int) ($data['target_user_id'] ?? 0); + $reason = (string) ($data['reason'] ?? ''); + + $workspace = Workspace::query()->whereKey($workspaceId)->firstOrFail(); + $targetUser = User::query()->whereKey($targetUserId)->firstOrFail(); + + $membership = WorkspaceMembership::query()->firstOrNew([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $targetUser->getKey(), + ]); + + $fromRole = $membership->exists ? (string) $membership->role : null; + + $membership->forceFill([ + 'role' => WorkspaceRole::Owner->value, + ])->save(); + + $auditLogger->log( + workspace: $workspace, + action: AuditActionId::WorkspaceMembershipBreakGlassAssignOwner->value, + context: [ + 'metadata' => [ + 'workspace_id' => (int) $workspace->getKey(), + 'actor_user_id' => (int) $platformUser->getKey(), + 'target_user_id' => (int) $targetUser->getKey(), + 'attempted_role' => WorkspaceRole::Owner->value, + 'from_role' => $fromRole, + 'reason' => trim($reason), + 'source' => 'break_glass', + ], + ], + actor: null, + status: 'success', + resourceType: 'workspace', + resourceId: (string) $workspace->getKey(), + actorId: (int) $platformUser->getKey(), + actorEmail: $platformUser->email, + actorName: $platformUser->name, + ); + + Notification::make() + ->title('Owner assigned') + ->success() + ->send(); + }) + ->disabled(fn (): bool => ! $breakGlass->isActive()), + ]; + } +} diff --git a/app/Http/Controllers/SelectTenantController.php b/app/Http/Controllers/SelectTenantController.php new file mode 100644 index 0000000..e95bb6f --- /dev/null +++ b/app/Http/Controllers/SelectTenantController.php @@ -0,0 +1,72 @@ +user(); + + if (! $user instanceof User) { + abort(403); + } + + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId($request); + + if ($workspaceId === null) { + return redirect()->to('/admin/choose-workspace'); + } + + $validated = $request->validate([ + 'tenant_id' => ['required', 'integer'], + ]); + + $tenant = Tenant::query() + ->where('status', 'active') + ->where('workspace_id', $workspaceId) + ->whereKey($validated['tenant_id']) + ->first(); + + if (! $tenant instanceof Tenant) { + abort(404); + } + + if (! $user->canAccessTenant($tenant)) { + abort(404); + } + + $this->persistLastTenant($user, $tenant); + + return redirect()->to(TenantDashboard::getUrl(tenant: $tenant)); + } + + private function persistLastTenant(User $user, Tenant $tenant): void + { + if (Schema::hasColumn('users', 'last_tenant_id')) { + $user->forceFill(['last_tenant_id' => $tenant->getKey()])->save(); + + return; + } + + if (! Schema::hasTable('user_tenant_preferences')) { + return; + } + + UserTenantPreference::query()->updateOrCreate( + ['user_id' => $user->getKey(), 'tenant_id' => $tenant->getKey()], + ['last_used_at' => now()] + ); + } +} diff --git a/app/Http/Controllers/SwitchWorkspaceController.php b/app/Http/Controllers/SwitchWorkspaceController.php new file mode 100644 index 0000000..79a1a0f --- /dev/null +++ b/app/Http/Controllers/SwitchWorkspaceController.php @@ -0,0 +1,61 @@ +user(); + + if (! $user instanceof User) { + abort(403); + } + + $validated = $request->validate([ + 'workspace_id' => ['required', 'integer'], + ]); + + $workspace = Workspace::query()->whereKey($validated['workspace_id'])->first(); + + if (! $workspace instanceof Workspace) { + abort(404); + } + + if (! empty($workspace->archived_at)) { + abort(404); + } + + $context = app(WorkspaceContext::class); + + if (! $context->isMember($user, $workspace)) { + abort(404); + } + + $context->setCurrentWorkspace($workspace, $user, $request); + + $tenants = $user->getTenants(Filament::getCurrentOrDefaultPanel()); + $tenants = $tenants instanceof \Illuminate\Database\Eloquent\Collection ? $tenants : collect($tenants); + + if ($tenants->isEmpty()) { + if (RegisterTenantPage::canView()) { + return redirect()->route('filament.admin.tenant.registration'); + } + + return redirect()->to(ChooseTenant::getUrl()); + } + + return redirect()->to(ChooseTenant::getUrl()); + } +} diff --git a/app/Http/Middleware/EnsureWorkspaceSelected.php b/app/Http/Middleware/EnsureWorkspaceSelected.php index b904ff7..8b1e618 100644 --- a/app/Http/Middleware/EnsureWorkspaceSelected.php +++ b/app/Http/Middleware/EnsureWorkspaceSelected.php @@ -32,6 +32,10 @@ public function handle(Request $request, Closure $next): Response return $next($request); } + if (str_starts_with($path, '/admin/workspaces')) { + return $next($request); + } + if (in_array($path, ['/admin/no-access', '/admin/choose-workspace'], true)) { return $next($request); } diff --git a/app/Livewire/BackupSetPolicyPickerTable.php b/app/Livewire/BackupSetPolicyPickerTable.php index 3882dfa..8ba8d6f 100644 --- a/app/Livewire/BackupSetPolicyPickerTable.php +++ b/app/Livewire/BackupSetPolicyPickerTable.php @@ -61,7 +61,7 @@ public static function externalIdShort(?string $externalId): string public function table(Table $table): Table { $backupSet = BackupSet::query()->find($this->backupSetId); - $tenantId = $backupSet?->tenant_id ?? Tenant::current()->getKey(); + $tenantId = $backupSet?->tenant_id ?? Tenant::currentOrFail()->getKey(); $existingPolicyIds = $backupSet ? $backupSet->items()->pluck('policy_id')->filter()->all() : []; diff --git a/app/Models/Tenant.php b/app/Models/Tenant.php index 0f6159c..223c83e 100644 --- a/app/Models/Tenant.php +++ b/app/Models/Tenant.php @@ -117,7 +117,7 @@ public function makeCurrent(): void $this->forceFill(['is_current' => true]); } - public static function current(): self + public static function current(): ?self { $filamentTenant = Filament::getTenant(); @@ -146,6 +146,13 @@ public static function current(): self ->where('is_current', true) ->first(); + return $tenant; + } + + public static function currentOrFail(): self + { + $tenant = static::current(); + if (! $tenant) { throw new RuntimeException('No current tenant selected.'); } diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index d52e8f8..c75f6b0 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -26,7 +26,11 @@ public function boot(): void $resolver = app(CapabilityResolver::class); $defineTenantCapability = function (string $capability) use ($resolver): void { - Gate::define($capability, function (User $user, Tenant $tenant) use ($resolver, $capability): bool { + Gate::define($capability, function (User $user, ?Tenant $tenant = null) use ($resolver, $capability): bool { + if (! $tenant instanceof Tenant) { + return false; + } + return $resolver->can($user, $tenant, $capability); }); }; diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index 5664e14..2e9c599 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -8,6 +8,7 @@ use App\Filament\Pages\NoAccess; use App\Filament\Pages\Tenancy\RegisterTenant; use App\Filament\Pages\TenantDashboard; +use App\Filament\Resources\Workspaces\WorkspaceResource; use App\Models\Tenant; use App\Support\Middleware\DenyNonMemberTenantAccess; use Filament\Facades\Filament; @@ -15,6 +16,7 @@ use Filament\Http\Middleware\AuthenticateSession; use Filament\Http\Middleware\DisableBladeIconComponents; use Filament\Http\Middleware\DispatchServingFilamentEvent; +use Filament\Navigation\NavigationItem; use Filament\Panel; use Filament\PanelProvider; use Filament\Support\Colors\Color; @@ -38,6 +40,7 @@ public function panel(Panel $panel): Panel ->path('admin') ->login(Login::class) ->authenticatedRoutes(function (Panel $panel): void { + WorkspaceResource::registerRoutes($panel); ChooseWorkspace::registerRoutes($panel); ChooseTenant::registerRoutes($panel); NoAccess::registerRoutes($panel); @@ -50,10 +53,21 @@ public function panel(Panel $panel): Panel ->colors([ 'primary' => Color::Amber, ]) + ->navigationItems([ + NavigationItem::make('Workspaces') + ->url(fn (): string => route('filament.admin.resources.workspaces.index')) + ->icon('heroicon-o-squares-2x2') + ->group('Settings') + ->sort(10), + ]) ->renderHook( PanelsRenderHook::HEAD_END, fn () => view('filament.partials.livewire-intercept-shim')->render() ) + ->renderHook( + PanelsRenderHook::USER_MENU_PROFILE_AFTER, + fn () => view('filament.partials.workspace-switcher')->render() + ) ->renderHook( PanelsRenderHook::BODY_END, fn () => (bool) config('tenantpilot.bulk_operations.progress_widget_enabled', true) diff --git a/app/Services/Audit/WorkspaceAuditLogger.php b/app/Services/Audit/WorkspaceAuditLogger.php index ac0f466..f70f489 100644 --- a/app/Services/Audit/WorkspaceAuditLogger.php +++ b/app/Services/Audit/WorkspaceAuditLogger.php @@ -19,6 +19,9 @@ public function log( string $status = 'success', ?string $resourceType = null, ?string $resourceId = null, + ?int $actorId = null, + ?string $actorEmail = null, + ?string $actorName = null, ): AuditLog { $metadata = $context['metadata'] ?? []; unset($context['metadata']); @@ -26,9 +29,9 @@ public function log( return AuditLog::create([ 'tenant_id' => null, 'workspace_id' => (int) $workspace->getKey(), - 'actor_id' => $actor?->getKey(), - 'actor_email' => $actor?->email, - 'actor_name' => $actor?->name, + 'actor_id' => $actor?->getKey() ?? $actorId, + 'actor_email' => $actor?->email ?? $actorEmail, + 'actor_name' => $actor?->name ?? $actorName, 'action' => $action, 'resource_type' => $resourceType, 'resource_id' => $resourceId, diff --git a/app/Services/Auth/WorkspaceMembershipManager.php b/app/Services/Auth/WorkspaceMembershipManager.php index 6941777..a98fb9f 100644 --- a/app/Services/Auth/WorkspaceMembershipManager.php +++ b/app/Services/Auth/WorkspaceMembershipManager.php @@ -28,65 +28,82 @@ public function addMember( $this->assertValidRole($role); $this->assertActorCanManage($actor, $workspace); - return DB::transaction(function () use ($workspace, $actor, $member, $role, $source): WorkspaceMembership { - $existing = WorkspaceMembership::query() - ->where('workspace_id', (int) $workspace->getKey()) - ->where('user_id', (int) $member->getKey()) - ->first(); + try { + return DB::transaction(function () use ($workspace, $actor, $member, $role, $source): WorkspaceMembership { + $existing = WorkspaceMembership::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('user_id', (int) $member->getKey()) + ->first(); - if ($existing) { - if ($existing->role !== $role) { - $fromRole = (string) $existing->role; + if ($existing) { + if ($existing->role !== $role) { + $fromRole = (string) $existing->role; - $existing->forceFill([ - 'role' => $role, - ])->save(); + $this->guardLastOwnerDemotion($workspace, $existing, $role); - $this->auditLogger->log( - workspace: $workspace, - action: AuditActionId::WorkspaceMembershipRoleChange->value, - context: [ - 'metadata' => [ - 'member_user_id' => (int) $member->getKey(), - 'from_role' => $fromRole, - 'to_role' => $role, - 'source' => $source, + $existing->forceFill([ + 'role' => $role, + ])->save(); + + $this->auditLogger->log( + workspace: $workspace, + action: AuditActionId::WorkspaceMembershipRoleChange->value, + context: [ + 'metadata' => [ + 'member_user_id' => (int) $member->getKey(), + 'from_role' => $fromRole, + 'to_role' => $role, + 'source' => $source, + ], ], - ], - actor: $actor, - status: 'success', - resourceType: 'workspace', - resourceId: (string) $workspace->getKey(), - ); + actor: $actor, + status: 'success', + resourceType: 'workspace', + resourceId: (string) $workspace->getKey(), + ); + } + + return $existing->refresh(); } - return $existing->refresh(); + $membership = WorkspaceMembership::query()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $member->getKey(), + 'role' => $role, + ]); + + $this->auditLogger->log( + workspace: $workspace, + action: AuditActionId::WorkspaceMembershipAdd->value, + context: [ + 'metadata' => [ + 'member_user_id' => (int) $member->getKey(), + 'role' => $role, + 'source' => $source, + ], + ], + actor: $actor, + status: 'success', + resourceType: 'workspace', + resourceId: (string) $workspace->getKey(), + ); + + return $membership; + }); + } catch (DomainException $exception) { + if ($exception->getMessage() === 'You cannot demote the last remaining owner.') { + $this->auditLastOwnerBlocked( + workspace: $workspace, + actor: $actor, + targetUserId: (int) $member->getKey(), + attemptedRole: $role, + currentRole: WorkspaceRole::Owner->value, + attemptedAction: 'role_change', + ); } - $membership = WorkspaceMembership::query()->create([ - 'workspace_id' => (int) $workspace->getKey(), - 'user_id' => (int) $member->getKey(), - 'role' => $role, - ]); - - $this->auditLogger->log( - workspace: $workspace, - action: AuditActionId::WorkspaceMembershipAdd->value, - context: [ - 'metadata' => [ - 'member_user_id' => (int) $member->getKey(), - 'role' => $role, - 'source' => $source, - ], - ], - actor: $actor, - status: 'success', - resourceType: 'workspace', - resourceId: (string) $workspace->getKey(), - ); - - return $membership; - }); + throw $exception; + } } public function changeRole(Workspace $workspace, User $actor, WorkspaceMembership $membership, string $newRole): WorkspaceMembership @@ -134,20 +151,13 @@ public function changeRole(Workspace $workspace, User $actor, WorkspaceMembershi }); } catch (DomainException $exception) { if ($exception->getMessage() === 'You cannot demote the last remaining owner.') { - $this->auditLogger->log( + $this->auditLastOwnerBlocked( workspace: $workspace, - action: AuditActionId::WorkspaceMembershipLastOwnerBlocked->value, - context: [ - 'metadata' => [ - 'member_user_id' => (int) $membership->user_id, - 'from_role' => (string) $membership->role, - 'attempted_to_role' => $newRole, - ], - ], actor: $actor, - status: 'blocked', - resourceType: 'workspace', - resourceId: (string) $workspace->getKey(), + targetUserId: (int) $membership->user_id, + attemptedRole: $newRole, + currentRole: (string) $membership->role, + attemptedAction: 'role_change', ); } @@ -191,20 +201,13 @@ public function removeMember(Workspace $workspace, User $actor, WorkspaceMembers }); } catch (DomainException $exception) { if ($exception->getMessage() === 'You cannot remove the last remaining owner.') { - $this->auditLogger->log( + $this->auditLastOwnerBlocked( workspace: $workspace, - action: AuditActionId::WorkspaceMembershipLastOwnerBlocked->value, - context: [ - 'metadata' => [ - 'member_user_id' => (int) $membership->user_id, - 'role' => (string) $membership->role, - 'attempted_action' => 'remove', - ], - ], actor: $actor, - status: 'blocked', - resourceType: 'workspace', - resourceId: (string) $workspace->getKey(), + targetUserId: (int) $membership->user_id, + attemptedRole: (string) $membership->role, + currentRole: (string) $membership->role, + attemptedAction: 'remove', ); } @@ -269,4 +272,32 @@ private function guardLastOwnerRemoval(Workspace $workspace, WorkspaceMembership throw new DomainException('You cannot remove the last remaining owner.'); } } + + private function auditLastOwnerBlocked( + Workspace $workspace, + User $actor, + int $targetUserId, + string $attemptedRole, + string $currentRole, + string $attemptedAction, + ): void { + $this->auditLogger->log( + workspace: $workspace, + action: AuditActionId::WorkspaceMembershipLastOwnerBlocked->value, + context: [ + 'metadata' => [ + 'workspace_id' => (int) $workspace->getKey(), + 'actor_user_id' => (int) $actor->getKey(), + 'target_user_id' => $targetUserId, + 'attempted_role' => $attemptedRole, + 'current_role' => $currentRole, + 'attempted_action' => $attemptedAction, + ], + ], + actor: $actor, + status: 'blocked', + resourceType: 'workspace', + resourceId: (string) $workspace->getKey(), + ); + } } diff --git a/app/Support/Audit/AuditActionId.php b/app/Support/Audit/AuditActionId.php index e093b64..c1a8ff5 100644 --- a/app/Support/Audit/AuditActionId.php +++ b/app/Support/Audit/AuditActionId.php @@ -6,6 +6,12 @@ enum AuditActionId: string { + case WorkspaceMembershipAdd = 'workspace_membership.add'; + case WorkspaceMembershipRoleChange = 'workspace_membership.role_change'; + case WorkspaceMembershipRemove = 'workspace_membership.remove'; + case WorkspaceMembershipLastOwnerBlocked = 'workspace_membership.last_owner_blocked'; + case WorkspaceMembershipBreakGlassAssignOwner = 'workspace_membership.break_glass.assign_owner'; + case TenantMembershipAdd = 'tenant_membership.add'; case TenantMembershipRoleChange = 'tenant_membership.role_change'; case TenantMembershipRemove = 'tenant_membership.remove'; diff --git a/app/Support/Middleware/EnsureFilamentTenantSelected.php b/app/Support/Middleware/EnsureFilamentTenantSelected.php index 04fb305..246bd4c 100644 --- a/app/Support/Middleware/EnsureFilamentTenantSelected.php +++ b/app/Support/Middleware/EnsureFilamentTenantSelected.php @@ -10,6 +10,8 @@ use Closure; use Filament\Facades\Filament; use Filament\Models\Contracts\HasTenants; +use Filament\Navigation\NavigationBuilder; +use Filament\Navigation\NavigationItem; use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; @@ -20,6 +22,8 @@ class EnsureFilamentTenantSelected */ public function handle(Request $request, Closure $next): Response { + $panel = Filament::getCurrentOrDefaultPanel(); + if ($request->route()?->hasParameter('tenant')) { $user = $request->user(); @@ -31,8 +35,6 @@ public function handle(Request $request, Closure $next): Response abort(404); } - $panel = Filament::getCurrentOrDefaultPanel(); - if (! $panel->hasTenancy()) { return $next($request); } @@ -72,54 +74,105 @@ public function handle(Request $request, Closure $next): Response Filament::setTenant($tenant, true); + $this->configureNavigationForRequest($panel); + return $next($request); } if (filled(Filament::getTenant())) { + $this->configureNavigationForRequest($panel); + return $next($request); } $user = $request->user(); if (! $user instanceof User) { + $this->configureNavigationForRequest($panel); + return $next($request); } $tenant = null; - try { - $tenant = Tenant::current(); - } catch (\RuntimeException) { - $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 instanceof Tenant && ! app(CapabilityResolver::class)->isMember($user, $tenant)) { - $tenant = null; + 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() - ->whereNull('deleted_at') ->where('status', 'active') ->first(); } if (! $tenant) { - $tenant = $user->tenants() - ->whereNull('deleted_at') - ->first(); + $tenant = $user->tenants()->first(); } if (! $tenant) { - $tenant = $user->tenants() - ->withTrashed() - ->first(); + $tenant = $user->tenants()->withTrashed()->first(); } if ($tenant) { Filament::setTenant($tenant, true); } + $this->configureNavigationForRequest($panel); + return $next($request); } + + private function configureNavigationForRequest(\Filament\Panel $panel): void + { + if (! $panel->hasTenancy()) { + return; + } + + if (filled(Filament::getTenant())) { + $panel->navigation(true); + + return; + } + + $panel->navigation(function (): NavigationBuilder { + return app(NavigationBuilder::class) + ->item( + NavigationItem::make('Workspaces') + ->url(fn (): string => route('filament.admin.resources.workspaces.index')) + ->icon('heroicon-o-squares-2x2') + ->group('Settings') + ->sort(10), + ); + }); + } } diff --git a/app/Support/Workspaces/WorkspaceContext.php b/app/Support/Workspaces/WorkspaceContext.php index b927f3c..6ed0554 100644 --- a/app/Support/Workspaces/WorkspaceContext.php +++ b/app/Support/Workspaces/WorkspaceContext.php @@ -88,10 +88,6 @@ public function resolveInitialWorkspaceFor(User $user, ?Request $request = null) if (! $workspace instanceof Workspace || ! $this->isWorkspaceSelectable($workspace) || ! $this->isMember($user, $workspace)) { $user->forceFill(['last_workspace_id' => null])->save(); - } else { - $session->put(self::SESSION_KEY, (int) $workspace->getKey()); - - return $workspace; } } diff --git a/resources/views/filament/pages/choose-tenant.blade.php b/resources/views/filament/pages/choose-tenant.blade.php index 6e16aa7..4c16619 100644 --- a/resources/views/filament/pages/choose-tenant.blade.php +++ b/resources/views/filament/pages/choose-tenant.blade.php @@ -11,25 +11,62 @@ @if ($tenants->isEmpty())
- No tenants are available for your account. +
No tenants are available for your account.
+
+ @if ($this->canRegisterTenant()) + Register a tenant for this workspace, or switch workspaces. + @else + Switch workspaces, or contact an administrator. + @endif +
+ +
+ @if ($this->canRegisterTenant()) + + Register tenant + + @endif + + + Change workspace + +
@else
@foreach ($tenants as $tenant) -
-
+
+
+ @csrf + +
{{ $tenant->name }}
Continue -
+
@endforeach
diff --git a/resources/views/filament/pages/choose-workspace.blade.php b/resources/views/filament/pages/choose-workspace.blade.php index 77fde00..913b9ba 100644 --- a/resources/views/filament/pages/choose-workspace.blade.php +++ b/resources/views/filament/pages/choose-workspace.blade.php @@ -7,6 +7,14 @@ @php $workspaces = $this->getWorkspaces(); + + $user = auth()->user(); + $recommendedWorkspaceId = $user instanceof \App\Models\User ? (int) ($user->last_workspace_id ?? 0) : 0; + + if ($recommendedWorkspaceId > 0) { + [$recommended, $other] = $workspaces->partition(fn ($workspace) => (int) $workspace->id === $recommendedWorkspaceId); + $workspaces = $recommended->concat($other)->values(); + } @endphp @if ($workspaces->isEmpty()) @@ -17,20 +25,42 @@ @else
@foreach ($workspaces as $workspace) -
-
-
- {{ $workspace->name }} + @php + $isRecommended = $recommendedWorkspaceId > 0 && (int) $workspace->id === $recommendedWorkspaceId; + @endphp + +
+
+ @csrf + + +
+
+ {{ $workspace->name }} +
+ + @if ($isRecommended) +
+ + Last used + +
+ @endif
Continue -
+
@endforeach
diff --git a/resources/views/filament/partials/workspace-switcher.blade.php b/resources/views/filament/partials/workspace-switcher.blade.php new file mode 100644 index 0000000..7c2020f --- /dev/null +++ b/resources/views/filament/partials/workspace-switcher.blade.php @@ -0,0 +1,47 @@ +@php + /** @var \App\Support\Workspaces\WorkspaceContext $workspaceContext */ + $workspaceContext = app(\App\Support\Workspaces\WorkspaceContext::class); + + $user = auth()->user(); + $currentWorkspaceId = $workspaceContext->currentWorkspaceId(request()); + + $workspaces = collect(); + if ($user instanceof \App\Models\User) { + $workspaces = \App\Models\Workspace::query() + ->whereIn('id', function ($query) use ($user): void { + $query->from('workspace_memberships') + ->select('workspace_id') + ->where('user_id', $user->getKey()); + }) + ->whereNull('archived_at') + ->orderBy('name') + ->get(); + } +@endphp + +@if ($workspaces->isNotEmpty()) + +
+
+ @csrf + +
Workspace
+ + + +
Switch workspace
+
+
+
+@endif diff --git a/resources/views/filament/system/pages/repair-workspace-owners.blade.php b/resources/views/filament/system/pages/repair-workspace-owners.blade.php new file mode 100644 index 0000000..44a3a91 --- /dev/null +++ b/resources/views/filament/system/pages/repair-workspace-owners.blade.php @@ -0,0 +1,13 @@ + +
+
+
+

Purpose

+

+ This page exists to recover from broken workspace ownership state (e.g. a workspace with zero owners due to manual DB edits). + Actions here require break-glass mode and are fully audited. +

+
+
+
+
diff --git a/routes/web.php b/routes/web.php index ac4f48c..00df6b5 100644 --- a/routes/web.php +++ b/routes/web.php @@ -4,6 +4,8 @@ use App\Http\Controllers\AdminConsentCallbackController; use App\Http\Controllers\Auth\EntraController; use App\Http\Controllers\RbacDelegatedAuthController; +use App\Http\Controllers\SelectTenantController; +use App\Http\Controllers\SwitchWorkspaceController; use App\Http\Controllers\TenantOnboardingController; use App\Models\Workspace; use App\Support\Middleware\DenyNonMemberTenantAccess; @@ -25,6 +27,30 @@ Route::get('/admin/consent/start', TenantOnboardingController::class) ->name('admin.consent.start'); +// Panel root override: keep the app's workspace-first flow. +// Avoid Filament's tenancy root redirect which otherwise sends users to /admin/register-tenant +// when no default tenant can be resolved. +Route::middleware([ + 'web', + 'panel:admin', + 'ensure-correct-guard:web', + DenyNonMemberTenantAccess::class, + DisableBladeIconComponents::class, + DispatchServingFilamentEvent::class, + FilamentAuthenticate::class, + 'ensure-workspace-selected', +]) + ->get('/admin', function (Request $request) { + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId($request); + + if ($workspaceId === null) { + return redirect()->to('/admin/choose-workspace'); + } + + return redirect()->to('/admin/choose-tenant'); + }) + ->name('admin.home'); + // Fallback route: Filament's layout generates this URL when tenancy registration is enabled. // In this app, package route registration may not always define it early enough, which breaks // rendering on tenant-scoped routes. @@ -92,6 +118,14 @@ }) ->name('admin.legacy.onboarding'); +Route::middleware(['web', 'auth', 'ensure-correct-guard:web']) + ->post('/admin/switch-workspace', SwitchWorkspaceController::class) + ->name('admin.switch-workspace'); + +Route::middleware(['web', 'auth', 'ensure-correct-guard:web', 'ensure-workspace-selected']) + ->post('/admin/select-tenant', SelectTenantController::class) + ->name('admin.select-tenant'); + Route::bind('workspace', function (string $value): Workspace { /** @var WorkspaceResolver $resolver */ $resolver = app(WorkspaceResolver::class); diff --git a/specs/072-managed-tenants-workspace-enforcement/plan.md b/specs/072-managed-tenants-workspace-enforcement/plan.md index cb5fb9f..e00eafe 100644 --- a/specs/072-managed-tenants-workspace-enforcement/plan.md +++ b/specs/072-managed-tenants-workspace-enforcement/plan.md @@ -10,11 +10,13 @@ ## Approach 2. Move Managed Tenants list/onboarding UX to workspace-scoped routes. 3. Make `/admin/managed-tenants/*` legacy-only (redirect to the correct workspace-scoped URL). 4. Enforce workspace/tenant consistency for all `/admin/t/{tenant}` routes (deny-as-not-found on mismatch). +5. Ensure the panel UX makes the active workspace visible and switchable from the topbar. ## Key decisions - **Workspace is not Filament tenancy**; it remains session + middleware. - Hard enforcement is implemented in middleware that runs on tenant-scoped routes. - Prefer redirects over removing routes immediately, to avoid breaking deep links, but ensure they are no longer primary UX. +- Default tenant selection must respect the current workspace context to avoid cross-workspace tenant URLs. ## Files (expected) - `routes/web.php` diff --git a/specs/072-managed-tenants-workspace-enforcement/spec.md b/specs/072-managed-tenants-workspace-enforcement/spec.md index e3f1d68..1c1d43e 100644 --- a/specs/072-managed-tenants-workspace-enforcement/spec.md +++ b/specs/072-managed-tenants-workspace-enforcement/spec.md @@ -12,6 +12,7 @@ ## Mental model (source of truth) ## Goals - Workspace becomes a real, enforced context for all tenant-scoped pages. - Keep Filament tenancy URL space unchanged: `/admin/t/{tenant_external_id}/...`. +- Keep Workspaces UI tenantless: `/admin/workspaces/*` (never under `/admin/t/{tenant}/...`). - Introduce / use a workspace-scoped landing space for portfolio UX: `/admin/w/{workspace}/...`. - Eliminate or redirect legacy unscoped Managed Tenants routes under `/admin/managed-tenants/*`. diff --git a/specs/072-managed-tenants-workspace-enforcement/tasks.md b/specs/072-managed-tenants-workspace-enforcement/tasks.md index f2d5053..c25422a 100644 --- a/specs/072-managed-tenants-workspace-enforcement/tasks.md +++ b/specs/072-managed-tenants-workspace-enforcement/tasks.md @@ -14,7 +14,23 @@ ## Core - [x] T110 Make unscoped `/admin/managed-tenants/*` legacy-only (redirect to workspace-scoped URLs). - [x] T120 Implement hard enforcement: tenant routes require workspace context and tenant.workspace_id match. - [x] T130 Ensure `/admin/choose-tenant` requires selected workspace. +- [x] T140 Move Workspaces UI out of tenant routing (serve at `/admin/workspaces/*`, not `/admin/t/{tenant}/workspaces`). + +## UX follow-ups +- [x] T200 Ensure default tenant selection respects current workspace context. +- [x] T210 Add a workspace switcher in the user menu (link to Choose Workspace). +- [x] T220 Add regression tests for workspace switcher + tenant selection. +- [x] T230 Ensure `/admin` lands on workspace-first flow (avoid redirecting to tenant registration). +- [x] T240 After choosing a workspace with zero tenants, route into tenant registration (not empty Choose Tenant). +- [x] T250 Allow workspace owners to register the first tenant in a workspace (bootstrap). + +## Security hardening (owners / audit / recovery) +- [x] T260 Enforce rule: workspaces can never have 0 owners (block last-owner removal + demotion). +- [x] T270 Audit every blocked last-owner attempt with `workspace_membership.last_owner_blocked` + required metadata. +- [x] T280 Optional: break-glass recovery flow to re-assign a workspace owner (fully audited). ## Validation - [x] T900 Run Pint on dirty files. - [x] T910 Run targeted Pest tests. + +- [x] T920 Run targeted Pest tests for last-owner + recovery flow. diff --git a/tests/Feature/Auth/BreakGlassWorkspaceOwnerRecoveryTest.php b/tests/Feature/Auth/BreakGlassWorkspaceOwnerRecoveryTest.php new file mode 100644 index 0000000..6f73b66 --- /dev/null +++ b/tests/Feature/Auth/BreakGlassWorkspaceOwnerRecoveryTest.php @@ -0,0 +1,89 @@ +create([ + 'tenant_id' => null, + 'external_id' => 'platform', + 'name' => 'Platform', + ]); + + config()->set('tenantpilot.break_glass.enabled', true); + config()->set('tenantpilot.break_glass.ttl_minutes', 15); +}); + +it('can assign a workspace owner via break-glass and audits it', function () { + $platformUser = PlatformUser::factory()->create([ + 'capabilities' => [ + PlatformCapabilities::ACCESS_SYSTEM_PANEL, + PlatformCapabilities::USE_BREAK_GLASS, + ], + ]); + + $this->actingAs($platformUser, 'platform'); + + $workspace = Workspace::factory()->create(); + $targetUser = User::factory()->create(); + + // Ensure the workspace is in a "broken" state: zero owners. + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $targetUser->getKey(), + 'role' => WorkspaceRole::Operator->value, + ]); + + Livewire::test(Dashboard::class) + ->callAction('enter_break_glass', data: [ + 'reason' => 'Recover workspace ownership', + ]); + + Livewire::test(RepairWorkspaceOwners::class) + ->callAction('assign_owner', data: [ + 'workspace_id' => (int) $workspace->getKey(), + 'target_user_id' => (int) $targetUser->getKey(), + 'reason' => 'Fix last owner removed via DB edit', + ]); + + $membership = WorkspaceMembership::query() + ->where('workspace_id', $workspace->getKey()) + ->where('user_id', $targetUser->getKey()) + ->firstOrFail(); + + expect($membership->role)->toBe(WorkspaceRole::Owner->value); + + $audit = AuditLog::query() + ->where('workspace_id', $workspace->getKey()) + ->where('action', 'workspace_membership.break_glass.assign_owner') + ->where('status', 'success') + ->latest('id') + ->first(); + + expect($audit)->not->toBeNull(); + expect($audit->metadata)->toMatchArray([ + 'workspace_id' => (int) $workspace->getKey(), + 'actor_user_id' => (int) $platformUser->getKey(), + 'target_user_id' => (int) $targetUser->getKey(), + 'attempted_role' => WorkspaceRole::Owner->value, + 'source' => 'break_glass', + ]); +}); diff --git a/tests/Feature/Auth/WorkspaceLastOwnerGuardTest.php b/tests/Feature/Auth/WorkspaceLastOwnerGuardTest.php new file mode 100644 index 0000000..d688869 --- /dev/null +++ b/tests/Feature/Auth/WorkspaceLastOwnerGuardTest.php @@ -0,0 +1,96 @@ +create(); + + $actor = User::factory()->create(); + $target = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $actor->getKey(), + 'role' => WorkspaceRole::Manager->value, + ]); + + $targetMembership = WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $target->getKey(), + 'role' => WorkspaceRole::Owner->value, + ]); + + $manager = app(WorkspaceMembershipManager::class); + + expect(fn () => $manager->changeRole($workspace, $actor, $targetMembership, WorkspaceRole::Manager->value)) + ->toThrow(DomainException::class, 'You cannot demote the last remaining owner.'); + + $targetMembership->refresh(); + expect($targetMembership->role)->toBe(WorkspaceRole::Owner->value); + + $audit = AuditLog::query() + ->where('workspace_id', $workspace->getKey()) + ->where('action', 'workspace_membership.last_owner_blocked') + ->where('status', 'blocked') + ->latest('id') + ->first(); + + expect($audit)->not->toBeNull(); + expect($audit->metadata)->toMatchArray([ + 'workspace_id' => (int) $workspace->getKey(), + 'actor_user_id' => (int) $actor->getKey(), + 'target_user_id' => (int) $target->getKey(), + 'attempted_role' => WorkspaceRole::Manager->value, + ]); +}); + +it('blocks removing the last remaining workspace owner and audits it', function () { + $workspace = Workspace::factory()->create(); + + $actor = User::factory()->create(); + $target = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $actor->getKey(), + 'role' => WorkspaceRole::Manager->value, + ]); + + $targetMembership = WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $target->getKey(), + 'role' => WorkspaceRole::Owner->value, + ]); + + $manager = app(WorkspaceMembershipManager::class); + + expect(fn () => $manager->removeMember($workspace, $actor, $targetMembership)) + ->toThrow(DomainException::class, 'You cannot remove the last remaining owner.'); + + expect(WorkspaceMembership::query()->whereKey($targetMembership->getKey())->exists())->toBeTrue(); + + $audit = AuditLog::query() + ->where('workspace_id', $workspace->getKey()) + ->where('action', 'workspace_membership.last_owner_blocked') + ->where('status', 'blocked') + ->latest('id') + ->first(); + + expect($audit)->not->toBeNull(); + expect($audit->metadata)->toMatchArray([ + 'workspace_id' => (int) $workspace->getKey(), + 'actor_user_id' => (int) $actor->getKey(), + 'target_user_id' => (int) $target->getKey(), + 'attempted_action' => 'remove', + ]); +}); diff --git a/tests/Feature/Filament/AdminHomeRedirectsToChooseTenantWhenWorkspaceSelectedTest.php b/tests/Feature/Filament/AdminHomeRedirectsToChooseTenantWhenWorkspaceSelectedTest.php new file mode 100644 index 0000000..a6c6fe5 --- /dev/null +++ b/tests/Feature/Filament/AdminHomeRedirectsToChooseTenantWhenWorkspaceSelectedTest.php @@ -0,0 +1,29 @@ +create(); + + $workspace = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $this + ->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get('/admin') + ->assertRedirect(route('filament.admin.pages.choose-tenant')); +}); diff --git a/tests/Feature/Filament/ChooseTenantEmptyStateRegisterTenantCtaVisibilityTest.php b/tests/Feature/Filament/ChooseTenantEmptyStateRegisterTenantCtaVisibilityTest.php new file mode 100644 index 0000000..df632bf --- /dev/null +++ b/tests/Feature/Filament/ChooseTenantEmptyStateRegisterTenantCtaVisibilityTest.php @@ -0,0 +1,48 @@ +create(); + + $workspace = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'readonly', + ]); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get('/admin/choose-tenant') + ->assertSuccessful() + ->assertSee('No tenants are available') + ->assertDontSee('Register tenant'); +}); + +it('shows the register-tenant CTA for owner workspace members when there are no tenants', function (): void { + $user = User::factory()->create(); + + $workspace = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get('/admin/choose-tenant') + ->assertSuccessful() + ->assertSee('Register tenant'); +}); diff --git a/tests/Feature/Filament/ChooseWorkspaceShowsLastUsedRecommendationTest.php b/tests/Feature/Filament/ChooseWorkspaceShowsLastUsedRecommendationTest.php new file mode 100644 index 0000000..8958113 --- /dev/null +++ b/tests/Feature/Filament/ChooseWorkspaceShowsLastUsedRecommendationTest.php @@ -0,0 +1,45 @@ +create(); + + $workspaceA = Workspace::factory()->create(['name' => 'Workspace A']); + $workspaceB = Workspace::factory()->create(['name' => 'Workspace B']); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspaceA->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspaceB->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $user->forceFill(['last_workspace_id' => (int) $workspaceB->getKey()])->save(); + + $this->actingAs($user) + ->get(route('filament.admin.pages.choose-workspace')) + ->assertOk() + ->assertSee('Last used') + ->assertSeeInOrder([ + 'Workspace B', + 'Workspace A', + ]); +}); diff --git a/tests/Feature/Filament/LoginRedirectsToChooseWorkspaceWhenMultipleWorkspacesTest.php b/tests/Feature/Filament/LoginRedirectsToChooseWorkspaceWhenMultipleWorkspacesTest.php new file mode 100644 index 0000000..4bd094d --- /dev/null +++ b/tests/Feature/Filament/LoginRedirectsToChooseWorkspaceWhenMultipleWorkspacesTest.php @@ -0,0 +1,40 @@ +create(); + + $workspaceA = Workspace::factory()->create(['name' => 'Workspace A']); + $workspaceB = Workspace::factory()->create(['name' => 'Workspace B']); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspaceA->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspaceB->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $user->forceFill(['last_workspace_id' => (int) $workspaceA->getKey()])->save(); + + $this->actingAs($user) + ->get('/admin') + ->assertRedirect(route('filament.admin.pages.choose-workspace')); +}); diff --git a/tests/Feature/Filament/SelectTenantPostPersistsLastUsedTest.php b/tests/Feature/Filament/SelectTenantPostPersistsLastUsedTest.php new file mode 100644 index 0000000..95d942e --- /dev/null +++ b/tests/Feature/Filament/SelectTenantPostPersistsLastUsedTest.php @@ -0,0 +1,65 @@ +create(); + + $workspace = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $tenant = Tenant::factory()->create([ + 'status' => 'active', + 'workspace_id' => $workspace->getKey(), + ]); + + TenantMembership::query()->create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + 'source' => 'manual', + 'source_ref' => null, + 'created_by_user_id' => null, + ]); + + $response = $this + ->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->post(route('admin.select-tenant'), ['tenant_id' => (int) $tenant->getKey()]); + + $response->assertRedirect(TenantDashboard::getUrl(tenant: $tenant)); + + $user->refresh(); + + if (Schema::hasColumn('users', 'last_tenant_id')) { + expect($user->last_tenant_id)->toBe($tenant->getKey()); + + return; + } + + if (Schema::hasTable('user_tenant_preferences')) { + $preference = $user->tenantPreferences() + ->where('tenant_id', $tenant->getKey()) + ->first(); + + expect($preference)->not->toBeNull(); + expect($preference?->last_used_at)->not->toBeNull(); + } +}); diff --git a/tests/Feature/Filament/TenantResourceIndexIsWorkspaceScopedTest.php b/tests/Feature/Filament/TenantResourceIndexIsWorkspaceScopedTest.php new file mode 100644 index 0000000..ce7af0d --- /dev/null +++ b/tests/Feature/Filament/TenantResourceIndexIsWorkspaceScopedTest.php @@ -0,0 +1,73 @@ +create(); + + $workspaceA = Workspace::factory()->create(['name' => 'Workspace A']); + $workspaceB = Workspace::factory()->create(['name' => 'Workspace B']); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspaceA->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspaceB->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $tenantA = Tenant::factory()->create([ + 'workspace_id' => $workspaceA->getKey(), + 'external_id' => '11111111-1111-1111-1111-111111111111', + 'tenant_id' => '11111111-1111-1111-1111-111111111111', + 'name' => 'Tenant A', + 'status' => 'active', + ]); + + $tenantB = Tenant::factory()->create([ + 'workspace_id' => $workspaceB->getKey(), + 'external_id' => '22222222-2222-2222-2222-222222222222', + 'tenant_id' => '22222222-2222-2222-2222-222222222222', + 'name' => 'Tenant B', + 'status' => 'active', + ]); + + TenantMembership::query()->create([ + 'tenant_id' => $tenantA->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + 'source' => 'manual', + 'source_ref' => null, + 'created_by_user_id' => null, + ]); + + TenantMembership::query()->create([ + 'tenant_id' => $tenantB->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + 'source' => 'manual', + 'source_ref' => null, + 'created_by_user_id' => null, + ]); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspaceA->getKey()]) + ->get(route('filament.admin.resources.tenants.index', filamentTenantRouteParams($tenantA))) + ->assertOk() + ->assertSee('Tenant A') + ->assertDontSee('Tenant B'); +}); diff --git a/tests/Feature/Filament/WorkspaceContextTopbarAndTenantSelectionTest.php b/tests/Feature/Filament/WorkspaceContextTopbarAndTenantSelectionTest.php new file mode 100644 index 0000000..9e062c3 --- /dev/null +++ b/tests/Feature/Filament/WorkspaceContextTopbarAndTenantSelectionTest.php @@ -0,0 +1,72 @@ +create(); + + $workspaceA = Workspace::factory()->create(['name' => 'Workspace A']); + $workspaceB = Workspace::factory()->create(['name' => 'Workspace B']); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspaceA->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspaceB->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $tenantA = Tenant::factory()->create([ + 'workspace_id' => $workspaceA->getKey(), + 'status' => 'active', + ]); + + $tenantB = Tenant::factory()->create([ + 'workspace_id' => $workspaceB->getKey(), + 'status' => 'active', + ]); + + $user->tenants()->syncWithoutDetaching([ + $tenantA->getKey() => ['role' => 'owner'], + $tenantB->getKey() => ['role' => 'owner'], + ]); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $workspaceA->getKey()); + $user->forceFill(['last_workspace_id' => (int) $workspaceA->getKey()])->save(); + + Filament::setTenant(null, true); + + $this->actingAs($user) + ->get(route('filament.admin.pages.choose-tenant')) + ->assertOk(); + + expect(Filament::getTenant()) + ->toBeInstanceOf(Tenant::class) + ->and(Filament::getTenant()?->getKey()) + ->toBe($tenantA->getKey()); +}); + +test('user menu renders a workspace switcher when a workspace is selected', function () { + [$user, $tenant] = createUserWithTenant(); + + $workspace = Workspace::query()->whereKey($tenant->workspace_id)->firstOrFail(); + + $this->actingAs($user) + ->get(route('filament.admin.resources.tenants.index', filamentTenantRouteParams($tenant))) + ->assertOk() + ->assertSee($workspace->name) + ->assertSee('Switch workspace') + ->assertSee('name="workspace_id"', escape: false); +}); diff --git a/tests/Feature/Workspaces/ChooseWorkspaceRedirectsToChooseTenantTest.php b/tests/Feature/Workspaces/ChooseWorkspaceRedirectsToChooseTenantTest.php index fae599b..f0cee18 100644 --- a/tests/Feature/Workspaces/ChooseWorkspaceRedirectsToChooseTenantTest.php +++ b/tests/Feature/Workspaces/ChooseWorkspaceRedirectsToChooseTenantTest.php @@ -3,6 +3,8 @@ declare(strict_types=1); use App\Filament\Pages\ChooseWorkspace; +use App\Models\Tenant; +use App\Models\TenantMembership; use App\Models\User; use App\Models\Workspace; use App\Models\WorkspaceMembership; @@ -11,7 +13,7 @@ uses(RefreshDatabase::class); -it('redirects to choose-tenant after selecting a workspace', function (): void { +it('redirects to tenant registration after selecting a workspace with no tenants', function (): void { $user = User::factory()->create(); $workspace = Workspace::factory()->create(); @@ -21,6 +23,36 @@ 'role' => 'owner', ]); + Livewire::actingAs($user) + ->test(ChooseWorkspace::class) + ->call('selectWorkspace', $workspace->getKey()) + ->assertRedirect(route('filament.admin.tenant.registration')); +}); + +it('redirects to choose-tenant after selecting a workspace with tenants', function (): void { + $user = User::factory()->create(); + + $workspace = Workspace::factory()->create(); + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $tenant = Tenant::factory()->create([ + 'status' => 'active', + 'workspace_id' => $workspace->getKey(), + ]); + + TenantMembership::query()->create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + 'source' => 'manual', + 'source_ref' => null, + 'created_by_user_id' => null, + ]); + Livewire::actingAs($user) ->test(ChooseWorkspace::class) ->call('selectWorkspace', $workspace->getKey()) diff --git a/tests/Feature/Workspaces/SwitchWorkspaceRedirectsToTenantRegistrationWhenNoTenantsTest.php b/tests/Feature/Workspaces/SwitchWorkspaceRedirectsToTenantRegistrationWhenNoTenantsTest.php new file mode 100644 index 0000000..09a6886 --- /dev/null +++ b/tests/Feature/Workspaces/SwitchWorkspaceRedirectsToTenantRegistrationWhenNoTenantsTest.php @@ -0,0 +1,30 @@ +create(); + + $workspace = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $this + ->actingAs($user) + ->post(route('admin.switch-workspace'), ['workspace_id' => (int) $workspace->getKey()]) + ->assertRedirect(route('filament.admin.tenant.registration')); + + expect(session(WorkspaceContext::SESSION_KEY))->toBe((int) $workspace->getKey()); +}); diff --git a/tests/Feature/Workspaces/WorkspacesResourceIsTenantlessTest.php b/tests/Feature/Workspaces/WorkspacesResourceIsTenantlessTest.php new file mode 100644 index 0000000..df29fdb --- /dev/null +++ b/tests/Feature/Workspaces/WorkspacesResourceIsTenantlessTest.php @@ -0,0 +1,75 @@ +create(); + + $workspace = Workspace::factory()->create(['slug' => 'acme']); + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get('/admin/workspaces') + ->assertOk(); +}); + +it('serves the Workspaces view page tenantless at /admin/workspaces/{record}', function (): void { + $user = User::factory()->create(); + + $workspace = Workspace::factory()->create(['slug' => 'acme']); + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get('/admin/workspaces/'.(int) $workspace->getKey()) + ->assertOk(); +}); + +it('does not expose the Workspaces UI under the tenant route prefix', function (): void { + $user = User::factory()->create(); + + $workspace = Workspace::factory()->create(['slug' => 'acme']); + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $tenant = Tenant::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'external_id' => '11111111-1111-1111-1111-111111111111', + 'tenant_id' => '11111111-1111-1111-1111-111111111111', + ]); + + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get('/admin/t/11111111-1111-1111-1111-111111111111/workspaces') + ->assertNotFound(); +}); -- 2.45.2 From eb7e6d56f0942cb37dab644e766c80af2c6507c0 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Mon, 2 Feb 2026 19:52:43 +0100 Subject: [PATCH 06/10] fix: workspace nav without tenant param --- app/Providers/Filament/AdminPanelProvider.php | 2 +- app/Support/Middleware/EnsureFilamentTenantSelected.php | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index 7825e37..1e8a536 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -53,7 +53,7 @@ public function panel(Panel $panel): Panel ]) ->navigationItems([ NavigationItem::make('Workspaces') - ->url(fn (): string => route('filament.admin.resources.workspaces.index')) + ->url(fn (): string => ChooseWorkspace::getUrl()) ->icon('heroicon-o-squares-2x2') ->group('Settings') ->sort(10), diff --git a/app/Support/Middleware/EnsureFilamentTenantSelected.php b/app/Support/Middleware/EnsureFilamentTenantSelected.php index 61e58eb..7f8255a 100644 --- a/app/Support/Middleware/EnsureFilamentTenantSelected.php +++ b/app/Support/Middleware/EnsureFilamentTenantSelected.php @@ -2,6 +2,7 @@ namespace App\Support\Middleware; +use App\Filament\Pages\ChooseWorkspace; use App\Models\Tenant; use App\Models\User; use App\Models\Workspace; @@ -166,7 +167,7 @@ private function configureNavigationForRequest(\Filament\Panel $panel): void return app(NavigationBuilder::class) ->item( NavigationItem::make('Workspaces') - ->url(fn (): string => route('filament.admin.resources.workspaces.index')) + ->url(fn (): string => ChooseWorkspace::getUrl()) ->icon('heroicon-o-squares-2x2') ->group('Settings') ->sort(10), -- 2.45.2 From d4e063255711998a4105c5f3ff38ae845b76f457 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Mon, 2 Feb 2026 19:56:06 +0100 Subject: [PATCH 07/10] fix: workspaces nav uses active tenant --- app/Providers/Filament/AdminPanelProvider.php | 10 +++++++++- tests/Feature/Filament/TenantDashboardDbOnlyTest.php | 1 + 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index 1e8a536..406ec27 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -53,7 +53,15 @@ public function panel(Panel $panel): Panel ]) ->navigationItems([ NavigationItem::make('Workspaces') - ->url(fn (): string => ChooseWorkspace::getUrl()) + ->url(function (): string { + $tenant = Filament::getTenant(); + + if ($tenant instanceof Tenant) { + return route('filament.admin.resources.workspaces.index', ['tenant' => $tenant->external_id]); + } + + return ChooseWorkspace::getUrl(); + }) ->icon('heroicon-o-squares-2x2') ->group('Settings') ->sort(10), diff --git a/tests/Feature/Filament/TenantDashboardDbOnlyTest.php b/tests/Feature/Filament/TenantDashboardDbOnlyTest.php index 150e4f9..524f6d9 100644 --- a/tests/Feature/Filament/TenantDashboardDbOnlyTest.php +++ b/tests/Feature/Filament/TenantDashboardDbOnlyTest.php @@ -32,6 +32,7 @@ assertNoOutboundHttp(function () use ($tenant): void { $this->get(TenantDashboard::getUrl(tenant: $tenant)) ->assertOk() + ->assertSee("/admin/t/{$tenant->external_id}/workspaces", false) ->assertSee('Needs Attention') ->assertSee('Recent Operations') ->assertSee('Recent Drift Findings'); -- 2.45.2 From 6079ccb766f017a3e70a43e41538902ce2a480dd Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Mon, 2 Feb 2026 19:59:15 +0100 Subject: [PATCH 08/10] fix: do not auto-select workspace on login --- app/Support/Workspaces/WorkspaceContext.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/Support/Workspaces/WorkspaceContext.php b/app/Support/Workspaces/WorkspaceContext.php index b927f3c..6ed0554 100644 --- a/app/Support/Workspaces/WorkspaceContext.php +++ b/app/Support/Workspaces/WorkspaceContext.php @@ -88,10 +88,6 @@ public function resolveInitialWorkspaceFor(User $user, ?Request $request = null) if (! $workspace instanceof Workspace || ! $this->isWorkspaceSelectable($workspace) || ! $this->isMember($user, $workspace)) { $user->forceFill(['last_workspace_id' => null])->save(); - } else { - $session->put(self::SESSION_KEY, (int) $workspace->getKey()); - - return $workspace; } } -- 2.45.2 From 41672c9a793bacf185adeb2cf39de1bd9b602887 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Mon, 2 Feb 2026 23:58:11 +0100 Subject: [PATCH 09/10] feat: workspace-first admin landing Route /admin based on tenant count in current workspace; add managed-tenants landing; keep tenant selection workspace-scoped; update tests. --- app/Filament/Pages/ChooseWorkspace.php | 38 +++++---- .../Workspaces/ManagedTenantsLanding.php | 85 +++++++++++++++++++ .../Controllers/SwitchWorkspaceController.php | 24 ++++-- .../Auth/PostLoginRedirectResolver.php | 36 +++----- .../EnsureFilamentTenantSelected.php | 12 +++ .../managed-tenants-landing.blade.php | 78 +++++++++++++++++ routes/web.php | 51 ++++++++++- .../tasks.md | 2 +- .../Auth/PostLoginRoutingByMembershipTest.php | 31 +++++-- ...oChooseTenantWhenWorkspaceSelectedTest.php | 73 +++++++++++++++- ...seWorkspaceRedirectsToChooseTenantTest.php | 39 ++++++++- .../ManagedTenantsWorkspaceRoutingTest.php | 36 ++++++++ 12 files changed, 442 insertions(+), 63 deletions(-) create mode 100644 app/Filament/Pages/Workspaces/ManagedTenantsLanding.php create mode 100644 resources/views/filament/pages/workspaces/managed-tenants-landing.blade.php diff --git a/app/Filament/Pages/ChooseWorkspace.php b/app/Filament/Pages/ChooseWorkspace.php index 8de5ab3..33aec13 100644 --- a/app/Filament/Pages/ChooseWorkspace.php +++ b/app/Filament/Pages/ChooseWorkspace.php @@ -9,7 +9,6 @@ use App\Models\WorkspaceMembership; use App\Support\Workspaces\WorkspaceContext; use Filament\Actions\Action; -use Filament\Facades\Filament; use Filament\Forms\Components\TextInput; use Filament\Notifications\Notification; use Filament\Pages\Page; @@ -138,25 +137,34 @@ public function createWorkspace(array $data): void private function redirectAfterWorkspaceSelected(User $user): string { - $tenants = $user->getTenants(Filament::getCurrentOrDefaultPanel()); + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(); - $tenants = $tenants instanceof Collection ? $tenants : collect($tenants); + if ($workspaceId === null) { + return self::getUrl(); + } - if ($tenants->isEmpty()) { - $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(); + $workspace = Workspace::query()->whereKey($workspaceId)->first(); - if ($workspaceId !== null) { - $role = WorkspaceMembership::query() - ->where('workspace_id', $workspaceId) - ->where('user_id', $user->getKey()) - ->value('role'); + if (! $workspace instanceof Workspace) { + return self::getUrl(); + } - if (in_array($role, ['owner', 'manager'], true)) { - return route('filament.admin.tenant.registration'); - } + $tenantsQuery = $user->tenants() + ->where('workspace_id', $workspace->getKey()) + ->where('status', 'active'); + + $tenantCount = (int) $tenantsQuery->count(); + + if ($tenantCount === 0) { + return route('admin.workspace.managed-tenants.index', ['workspace' => $workspace->slug ?? $workspace->getKey()]); + } + + if ($tenantCount === 1) { + $tenant = $tenantsQuery->first(); + + if ($tenant !== null) { + return TenantDashboard::getUrl(tenant: $tenant); } - - return ChooseTenant::getUrl(); } return ChooseTenant::getUrl(); diff --git a/app/Filament/Pages/Workspaces/ManagedTenantsLanding.php b/app/Filament/Pages/Workspaces/ManagedTenantsLanding.php new file mode 100644 index 0000000..922ee8b --- /dev/null +++ b/app/Filament/Pages/Workspaces/ManagedTenantsLanding.php @@ -0,0 +1,85 @@ +workspace = $workspace; + } + + /** + * @return Collection + */ + public function getTenants(): Collection + { + $user = auth()->user(); + + if (! $user instanceof User) { + return Tenant::query()->whereRaw('1 = 0')->get(); + } + + return $user->tenants() + ->where('workspace_id', $this->workspace->getKey()) + ->where('status', 'active') + ->orderBy('name') + ->get(); + } + + public function canRegisterTenant(): bool + { + return RegisterTenantPage::canView(); + } + + public function goToChooseTenant(): void + { + $this->redirect(ChooseTenant::getUrl()); + } + + public function openTenant(int $tenantId): void + { + $user = auth()->user(); + + if (! $user instanceof User) { + abort(403); + } + + $tenant = Tenant::query() + ->where('status', 'active') + ->where('workspace_id', $this->workspace->getKey()) + ->whereKey($tenantId) + ->first(); + + if (! $tenant instanceof Tenant) { + abort(404); + } + + if (! $user->canAccessTenant($tenant)) { + abort(404); + } + + $this->redirect(TenantDashboard::getUrl(tenant: $tenant)); + } +} diff --git a/app/Http/Controllers/SwitchWorkspaceController.php b/app/Http/Controllers/SwitchWorkspaceController.php index 79a1a0f..15dbab8 100644 --- a/app/Http/Controllers/SwitchWorkspaceController.php +++ b/app/Http/Controllers/SwitchWorkspaceController.php @@ -5,11 +5,10 @@ namespace App\Http\Controllers; use App\Filament\Pages\ChooseTenant; -use App\Filament\Pages\Tenancy\RegisterTenant as RegisterTenantPage; +use App\Filament\Pages\TenantDashboard; use App\Models\User; use App\Models\Workspace; use App\Support\Workspaces\WorkspaceContext; -use Filament\Facades\Filament; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -45,15 +44,22 @@ public function __invoke(Request $request): RedirectResponse $context->setCurrentWorkspace($workspace, $user, $request); - $tenants = $user->getTenants(Filament::getCurrentOrDefaultPanel()); - $tenants = $tenants instanceof \Illuminate\Database\Eloquent\Collection ? $tenants : collect($tenants); + $tenantsQuery = $user->tenants() + ->where('workspace_id', $workspace->getKey()) + ->where('status', 'active'); - if ($tenants->isEmpty()) { - if (RegisterTenantPage::canView()) { - return redirect()->route('filament.admin.tenant.registration'); + $tenantCount = (int) $tenantsQuery->count(); + + if ($tenantCount === 0) { + return redirect()->route('admin.workspace.managed-tenants.index', ['workspace' => $workspace->slug ?? $workspace->getKey()]); + } + + if ($tenantCount === 1) { + $tenant = $tenantsQuery->first(); + + if ($tenant !== null) { + return redirect()->to(TenantDashboard::getUrl(tenant: $tenant)); } - - return redirect()->to(ChooseTenant::getUrl()); } return redirect()->to(ChooseTenant::getUrl()); diff --git a/app/Services/Auth/PostLoginRedirectResolver.php b/app/Services/Auth/PostLoginRedirectResolver.php index 06fce10..e45e15c 100644 --- a/app/Services/Auth/PostLoginRedirectResolver.php +++ b/app/Services/Auth/PostLoginRedirectResolver.php @@ -4,39 +4,27 @@ namespace App\Services\Auth; -use App\Filament\Pages\TenantDashboard; -use App\Models\Tenant; use App\Models\User; -use Illuminate\Support\Collection; +use App\Models\WorkspaceMembership; +use Illuminate\Support\Facades\Schema; class PostLoginRedirectResolver { public function resolve(User $user): string { - $tenants = $this->getActiveTenants($user); + $membershipQuery = WorkspaceMembership::query()->where('user_id', $user->getKey()); - if ($tenants->isEmpty()) { + $hasAnyActiveMembership = Schema::hasColumn('workspaces', 'archived_at') + ? $membershipQuery + ->join('workspaces', 'workspace_memberships.workspace_id', '=', 'workspaces.id') + ->whereNull('workspaces.archived_at') + ->exists() + : $membershipQuery->exists(); + + if (! $hasAnyActiveMembership) { return '/admin/no-access'; } - if ($tenants->count() === 1) { - /** @var Tenant $tenant */ - $tenant = $tenants->first(); - - return TenantDashboard::getUrl(tenant: $tenant); - } - - return '/admin/choose-tenant'; - } - - /** - * @return Collection - */ - private function getActiveTenants(User $user): Collection - { - return $user->tenants() - ->where('status', 'active') - ->orderBy('name') - ->get(); + return '/admin'; } } diff --git a/app/Support/Middleware/EnsureFilamentTenantSelected.php b/app/Support/Middleware/EnsureFilamentTenantSelected.php index 7f8255a..b789998 100644 --- a/app/Support/Middleware/EnsureFilamentTenantSelected.php +++ b/app/Support/Middleware/EnsureFilamentTenantSelected.php @@ -25,6 +25,8 @@ public function handle(Request $request, Closure $next): Response { $panel = Filament::getCurrentOrDefaultPanel(); + $path = '/'.ltrim($request->path(), '/'); + if ($request->route()?->hasParameter('tenant')) { $user = $request->user(); @@ -78,6 +80,16 @@ public function handle(Request $request, Closure $next): Response return $next($request); } + 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) + ) { + $this->configureNavigationForRequest($panel); + + return $next($request); + } + if (filled(Filament::getTenant())) { $this->configureNavigationForRequest($panel); diff --git a/resources/views/filament/pages/workspaces/managed-tenants-landing.blade.php b/resources/views/filament/pages/workspaces/managed-tenants-landing.blade.php new file mode 100644 index 0000000..776dd02 --- /dev/null +++ b/resources/views/filament/pages/workspaces/managed-tenants-landing.blade.php @@ -0,0 +1,78 @@ + + +
+
+ Workspace: {{ $this->workspace->name }} +
+ + @php + $tenants = $this->getTenants(); + @endphp + + @if ($tenants->isEmpty()) +
+
No managed tenants yet.
+
+ Add a managed tenant to start inventory, drift, backups, and policy management. +
+ +
+ @if ($this->canRegisterTenant()) + + Add managed tenant + + @endif + + + Change workspace + +
+
+ @else +
+
+ {{ $tenants->count() }} managed tenant{{ $tenants->count() === 1 ? '' : 's' }} +
+ + + Choose tenant + +
+ +
+ @foreach ($tenants as $tenant) +
+
+
+ {{ $tenant->name }} +
+ + + Open + +
+
+ @endforeach +
+ @endif +
+
+
diff --git a/routes/web.php b/routes/web.php index 9b90ef0..bf89564 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,12 +1,14 @@ get('/admin', function (Request $request) { $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId($request); + $user = $request->user(); + + if (! $user instanceof User) { + return redirect()->to('/admin/choose-workspace'); + } + if ($workspaceId === null) { return redirect()->to('/admin/choose-workspace'); } + $workspace = Workspace::query()->whereKey($workspaceId)->first(); + + if (! $workspace instanceof Workspace) { + return redirect()->to('/admin/choose-workspace'); + } + + $tenantsQuery = $user->tenants() + ->where('workspace_id', $workspace->getKey()) + ->where('status', 'active'); + + $tenantCount = (int) $tenantsQuery->count(); + + if ($tenantCount === 0) { + return redirect()->route('admin.workspace.managed-tenants.index', ['workspace' => $workspace->slug ?? $workspace->getKey()]); + } + + if ($tenantCount === 1) { + $tenant = $tenantsQuery->first(); + + if ($tenant !== null) { + return redirect()->to(TenantDashboard::getUrl(tenant: $tenant)); + } + } + return redirect()->to('/admin/choose-tenant'); }) ->name('admin.home'); @@ -137,18 +169,29 @@ Route::middleware(['web', 'auth', 'ensure-workspace-member']) ->prefix('/admin/w/{workspace}') ->group(function (): void { - Route::get('/', fn () => redirect('/admin/choose-tenant')) + Route::get('/', fn () => redirect()->route('admin.workspace.managed-tenants.index', ['workspace' => request()->route('workspace')])) ->name('admin.workspace.home'); Route::get('/ping', fn () => response()->noContent())->name('admin.workspace.ping'); - Route::get('/managed-tenants', fn () => redirect('/admin/choose-tenant')) - ->name('admin.workspace.managed-tenants.index'); - Route::get('/managed-tenants/onboarding', fn () => redirect('/admin/register-tenant')) ->name('admin.workspace.managed-tenants.onboarding'); }); +Route::middleware([ + 'web', + 'panel:admin', + 'ensure-correct-guard:web', + DenyNonMemberTenantAccess::class, + DisableBladeIconComponents::class, + DispatchServingFilamentEvent::class, + FilamentAuthenticate::class, + 'ensure-workspace-member', + 'ensure-filament-tenant-selected', +]) + ->get('/admin/w/{workspace}/managed-tenants', \App\Filament\Pages\Workspaces\ManagedTenantsLanding::class) + ->name('admin.workspace.managed-tenants.index'); + if (app()->runningUnitTests()) { Route::middleware(['web', 'auth', 'ensure-workspace-selected']) ->get('/admin/_test/workspace-context', function (Request $request) { diff --git a/specs/072-managed-tenants-workspace-enforcement/tasks.md b/specs/072-managed-tenants-workspace-enforcement/tasks.md index c25422a..1ee239c 100644 --- a/specs/072-managed-tenants-workspace-enforcement/tasks.md +++ b/specs/072-managed-tenants-workspace-enforcement/tasks.md @@ -21,7 +21,7 @@ ## UX follow-ups - [x] T210 Add a workspace switcher in the user menu (link to Choose Workspace). - [x] T220 Add regression tests for workspace switcher + tenant selection. - [x] T230 Ensure `/admin` lands on workspace-first flow (avoid redirecting to tenant registration). -- [x] T240 After choosing a workspace with zero tenants, route into tenant registration (not empty Choose Tenant). +- [x] T240 After choosing a workspace with zero tenants, route into the workspace Managed Tenants landing (with CTA). - [x] T250 Allow workspace owners to register the first tenant in a workspace (bootstrap). ## Security hardening (owners / audit / recovery) diff --git a/tests/Feature/Auth/PostLoginRoutingByMembershipTest.php b/tests/Feature/Auth/PostLoginRoutingByMembershipTest.php index b37319d..4032432 100644 --- a/tests/Feature/Auth/PostLoginRoutingByMembershipTest.php +++ b/tests/Feature/Auth/PostLoginRoutingByMembershipTest.php @@ -2,10 +2,11 @@ declare(strict_types=1); -use App\Filament\Pages\TenantDashboard; use App\Models\Tenant; use App\Models\TenantMembership; use App\Models\User; +use App\Models\Workspace; +use App\Models\WorkspaceMembership; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Http; @@ -72,7 +73,17 @@ function entra_fake_token_exchange(string $tid, string $oid): void 'entra_object_id' => 'object-1', ]); - $tenant = Tenant::factory()->create(['status' => 'active']); + $workspace = Workspace::factory()->create(); + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $tenant = Tenant::factory()->create([ + 'status' => 'active', + 'workspace_id' => $workspace->getKey(), + ]); TenantMembership::query()->create([ 'tenant_id' => $tenant->getKey(), @@ -89,7 +100,7 @@ function entra_fake_token_exchange(string $tid, string $oid): void ->withSession(['entra_state' => $state]) ->get(route('auth.entra.callback', ['code' => 'code-123', 'state' => $state])); - $response->assertRedirect(TenantDashboard::getUrl(tenant: $tenant)); + $response->assertRedirect('/admin'); }); it('routes to choose-tenant when user has multiple tenant memberships', function () { @@ -101,7 +112,17 @@ function entra_fake_token_exchange(string $tid, string $oid): void 'entra_object_id' => 'object-1', ]); - $tenants = Tenant::factory()->count(2)->create(['status' => 'active']); + $workspace = Workspace::factory()->create(); + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $tenants = Tenant::factory()->count(2)->create([ + 'status' => 'active', + 'workspace_id' => $workspace->getKey(), + ]); foreach ($tenants as $tenant) { TenantMembership::query()->create([ @@ -120,5 +141,5 @@ function entra_fake_token_exchange(string $tid, string $oid): void ->withSession(['entra_state' => $state]) ->get(route('auth.entra.callback', ['code' => 'code-123', 'state' => $state])); - $response->assertRedirect('/admin/choose-tenant'); + $response->assertRedirect('/admin'); }); diff --git a/tests/Feature/Filament/AdminHomeRedirectsToChooseTenantWhenWorkspaceSelectedTest.php b/tests/Feature/Filament/AdminHomeRedirectsToChooseTenantWhenWorkspaceSelectedTest.php index a6c6fe5..87aecdb 100644 --- a/tests/Feature/Filament/AdminHomeRedirectsToChooseTenantWhenWorkspaceSelectedTest.php +++ b/tests/Feature/Filament/AdminHomeRedirectsToChooseTenantWhenWorkspaceSelectedTest.php @@ -2,6 +2,9 @@ declare(strict_types=1); +use App\Filament\Pages\TenantDashboard; +use App\Models\Tenant; +use App\Models\TenantMembership; use App\Models\User; use App\Models\Workspace; use App\Models\WorkspaceMembership; @@ -10,7 +13,7 @@ uses(RefreshDatabase::class); -it('redirects /admin to choose-tenant when a workspace is selected', function (): void { +it('redirects /admin to the workspace managed-tenants landing when a workspace is selected and has no tenants', function (): void { $user = User::factory()->create(); $workspace = Workspace::factory()->create(); @@ -25,5 +28,71 @@ ->actingAs($user) ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) ->get('/admin') - ->assertRedirect(route('filament.admin.pages.choose-tenant')); + ->assertRedirect(route('admin.workspace.managed-tenants.index', ['workspace' => $workspace->slug ?? $workspace->getKey()])); +}); + +it('redirects /admin to choose-tenant when a workspace is selected and has multiple tenants', function (): void { + $user = User::factory()->create(); + + $workspace = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $tenants = Tenant::factory()->count(2)->create([ + 'status' => 'active', + 'workspace_id' => $workspace->getKey(), + ]); + + foreach ($tenants as $tenant) { + TenantMembership::query()->create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + 'source' => 'manual', + 'source_ref' => null, + 'created_by_user_id' => null, + ]); + } + + $this + ->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get('/admin') + ->assertRedirect('/admin/choose-tenant'); +}); + +it('redirects /admin to the tenant dashboard when a workspace is selected and has exactly one tenant', function (): void { + $user = User::factory()->create(); + + $workspace = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $tenant = Tenant::factory()->create([ + 'status' => 'active', + 'workspace_id' => $workspace->getKey(), + ]); + + TenantMembership::query()->create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + 'source' => 'manual', + 'source_ref' => null, + 'created_by_user_id' => null, + ]); + + $this + ->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get('/admin') + ->assertRedirect(TenantDashboard::getUrl(tenant: $tenant)); }); diff --git a/tests/Feature/Workspaces/ChooseWorkspaceRedirectsToChooseTenantTest.php b/tests/Feature/Workspaces/ChooseWorkspaceRedirectsToChooseTenantTest.php index f0cee18..149ff6f 100644 --- a/tests/Feature/Workspaces/ChooseWorkspaceRedirectsToChooseTenantTest.php +++ b/tests/Feature/Workspaces/ChooseWorkspaceRedirectsToChooseTenantTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Filament\Pages\ChooseWorkspace; +use App\Filament\Pages\TenantDashboard; use App\Models\Tenant; use App\Models\TenantMembership; use App\Models\User; @@ -13,7 +14,7 @@ uses(RefreshDatabase::class); -it('redirects to tenant registration after selecting a workspace with no tenants', function (): void { +it('redirects to the workspace managed-tenants landing after selecting a workspace with no tenants', function (): void { $user = User::factory()->create(); $workspace = Workspace::factory()->create(); @@ -26,10 +27,10 @@ Livewire::actingAs($user) ->test(ChooseWorkspace::class) ->call('selectWorkspace', $workspace->getKey()) - ->assertRedirect(route('filament.admin.tenant.registration')); + ->assertRedirect(route('admin.workspace.managed-tenants.index', ['workspace' => $workspace->slug ?? $workspace->getKey()])); }); -it('redirects to choose-tenant after selecting a workspace with tenants', function (): void { +it('redirects to the tenant dashboard after selecting a workspace with exactly one tenant', function (): void { $user = User::factory()->create(); $workspace = Workspace::factory()->create(); @@ -53,6 +54,38 @@ 'created_by_user_id' => null, ]); + Livewire::actingAs($user) + ->test(ChooseWorkspace::class) + ->call('selectWorkspace', $workspace->getKey()) + ->assertRedirect(TenantDashboard::getUrl(tenant: $tenant)); +}); + +it('redirects to choose-tenant after selecting a workspace with multiple tenants', function (): void { + $user = User::factory()->create(); + + $workspace = Workspace::factory()->create(); + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $tenants = Tenant::factory()->count(2)->create([ + 'status' => 'active', + 'workspace_id' => $workspace->getKey(), + ]); + + foreach ($tenants as $tenant) { + TenantMembership::query()->create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + 'source' => 'manual', + 'source_ref' => null, + 'created_by_user_id' => null, + ]); + } + Livewire::actingAs($user) ->test(ChooseWorkspace::class) ->call('selectWorkspace', $workspace->getKey()) diff --git a/tests/Feature/Workspaces/ManagedTenantsWorkspaceRoutingTest.php b/tests/Feature/Workspaces/ManagedTenantsWorkspaceRoutingTest.php index c93f556..18ab164 100644 --- a/tests/Feature/Workspaces/ManagedTenantsWorkspaceRoutingTest.php +++ b/tests/Feature/Workspaces/ManagedTenantsWorkspaceRoutingTest.php @@ -33,6 +33,42 @@ ->assertRedirect("/admin/w/{$workspace->slug}/managed-tenants"); }); +it('keeps the managed-tenants landing tenantless even if the user has a tenant in another workspace', function (): void { + $user = User::factory()->create(); + + $workspaceEmpty = Workspace::factory()->create(['slug' => 'empty']); + $workspaceOther = Workspace::factory()->create(['slug' => 'other']); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspaceEmpty->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspaceOther->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $tenantInOther = Tenant::factory()->create([ + 'status' => 'active', + 'workspace_id' => (int) $workspaceOther->getKey(), + 'external_id' => 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', + 'tenant_id' => 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', + ]); + + $user->tenants()->syncWithoutDetaching([ + $tenantInOther->getKey() => ['role' => 'owner'], + ]); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspaceEmpty->getKey()]) + ->get('/admin/w/'.$workspaceEmpty->slug.'/managed-tenants') + ->assertSuccessful() + ->assertDontSee('/admin/t/'.$tenantInOther->external_id, false); +}); + it('returns 404 on tenant routes when workspace context is missing', function (): void { $user = User::factory()->create(); -- 2.45.2 From 0915efc386f99c59be01b937bde6265f2f403869 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Tue, 3 Feb 2026 00:49:45 +0100 Subject: [PATCH 10/10] fix: membership UI show email + domain --- .../TenantMembershipsRelationManager.php | 25 +- .../WorkspaceMembershipsRelationManager.php | 33 ++- app/Support/Rbac/WorkspaceUiEnforcement.php | 230 ++++++++++++++++++ .../tasks.md | 4 + ...rshipsRelationManagerUiEnforcementTest.php | 80 ++++++ 5 files changed, 360 insertions(+), 12 deletions(-) create mode 100644 app/Support/Rbac/WorkspaceUiEnforcement.php create mode 100644 tests/Feature/Rbac/WorkspaceMembershipsRelationManagerUiEnforcementTest.php diff --git a/app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php b/app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php index 78ddf84..19ef4ab 100644 --- a/app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php +++ b/app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php @@ -25,11 +25,22 @@ public function table(Table $table): Table return $table ->modifyQueryUsing(fn (Builder $query) => $query->with('user')) ->columns([ - Tables\Columns\TextColumn::make('user.name') + Tables\Columns\TextColumn::make('user.email') ->label(__('User')) ->searchable(), - Tables\Columns\TextColumn::make('user.email') - ->label(__('Email')) + Tables\Columns\TextColumn::make('user_domain') + ->label(__('Domain')) + ->getStateUsing(function (TenantMembership $record): ?string { + $email = $record->user?->email; + + if (! is_string($email) || $email === '' || ! str_contains($email, '@')) { + return null; + } + + return (string) str($email)->after('@')->lower(); + }), + Tables\Columns\TextColumn::make('user.name') + ->label(__('Name')) ->toggleable(isToggledHiddenByDefault: true), Tables\Columns\TextColumn::make('role') ->badge() @@ -49,7 +60,13 @@ public function table(Table $table): Table ->label(__('User')) ->required() ->searchable() - ->options(fn () => User::query()->orderBy('name')->pluck('name', 'id')->all()), + ->options(fn () => User::query() + ->orderBy('email') + ->get(['id', 'name', 'email']) + ->mapWithKeys(fn (User $user): array => [ + (string) $user->id => trim((string) ($user->name ? "{$user->name} ({$user->email})" : $user->email)), + ]) + ->all()), Forms\Components\Select::make('role') ->label(__('Role')) ->required() diff --git a/app/Filament/Resources/Workspaces/RelationManagers/WorkspaceMembershipsRelationManager.php b/app/Filament/Resources/Workspaces/RelationManagers/WorkspaceMembershipsRelationManager.php index 4be9fc1..2664006 100644 --- a/app/Filament/Resources/Workspaces/RelationManagers/WorkspaceMembershipsRelationManager.php +++ b/app/Filament/Resources/Workspaces/RelationManagers/WorkspaceMembershipsRelationManager.php @@ -8,7 +8,7 @@ use App\Services\Auth\WorkspaceMembershipManager; use App\Support\Auth\Capabilities; use App\Support\Auth\WorkspaceRole; -use App\Support\Rbac\UiEnforcement; +use App\Support\Rbac\WorkspaceUiEnforcement; use Filament\Actions\Action; use Filament\Forms; use Filament\Notifications\Notification; @@ -26,11 +26,22 @@ public function table(Table $table): Table return $table ->modifyQueryUsing(fn (Builder $query) => $query->with('user')) ->columns([ - Tables\Columns\TextColumn::make('user.name') + Tables\Columns\TextColumn::make('user.email') ->label(__('User')) ->searchable(), - Tables\Columns\TextColumn::make('user.email') - ->label(__('Email')) + Tables\Columns\TextColumn::make('user_domain') + ->label(__('Domain')) + ->getStateUsing(function (WorkspaceMembership $record): ?string { + $email = $record->user?->email; + + if (! is_string($email) || $email === '' || ! str_contains($email, '@')) { + return null; + } + + return (string) str($email)->after('@')->lower(); + }), + Tables\Columns\TextColumn::make('user.name') + ->label(__('Name')) ->toggleable(isToggledHiddenByDefault: true), Tables\Columns\TextColumn::make('role') ->badge() @@ -38,7 +49,7 @@ public function table(Table $table): Table Tables\Columns\TextColumn::make('created_at')->since(), ]) ->headerActions([ - UiEnforcement::forTableAction( + WorkspaceUiEnforcement::forTableAction( Action::make('add_member') ->label(__('Add member')) ->icon('heroicon-o-plus') @@ -47,7 +58,13 @@ public function table(Table $table): Table ->label(__('User')) ->required() ->searchable() - ->options(fn () => User::query()->orderBy('name')->pluck('name', 'id')->all()), + ->options(fn () => User::query() + ->orderBy('email') + ->get(['id', 'name', 'email']) + ->mapWithKeys(fn (User $user): array => [ + (string) $user->id => trim((string) ($user->name ? "{$user->name} ({$user->email})" : $user->email)), + ]) + ->all()), Forms\Components\Select::make('role') ->label(__('Role')) ->required() @@ -105,7 +122,7 @@ public function table(Table $table): Table ->apply(), ]) ->actions([ - UiEnforcement::forTableAction( + WorkspaceUiEnforcement::forTableAction( Action::make('change_role') ->label(__('Change role')) ->icon('heroicon-o-pencil') @@ -159,7 +176,7 @@ public function table(Table $table): Table ->tooltip('You do not have permission to manage workspace memberships.') ->apply(), - UiEnforcement::forTableAction( + WorkspaceUiEnforcement::forTableAction( Action::make('remove') ->label(__('Remove')) ->color('danger') diff --git a/app/Support/Rbac/WorkspaceUiEnforcement.php b/app/Support/Rbac/WorkspaceUiEnforcement.php new file mode 100644 index 0000000..2696de2 --- /dev/null +++ b/app/Support/Rbac/WorkspaceUiEnforcement.php @@ -0,0 +1,230 @@ +action = $action; + } + + /** + * Create enforcement for a table action. + * + * @param Action $action The Filament action to wrap + * @param Model|Closure $record The owner record or a closure that returns it + */ + public static function forTableAction(Action $action, Model|Closure $record): self + { + $instance = new self($action); + $instance->record = $record; + + return $instance; + } + + public function requireMembership(bool $require = true): self + { + $this->requireMembership = $require; + + return $this; + } + + /** + * @throws \InvalidArgumentException If capability is not in the canonical registry + */ + public function requireCapability(string $capability): self + { + if (! Capabilities::isKnown($capability)) { + throw new \InvalidArgumentException( + "Unknown capability: {$capability}. Use constants from ".Capabilities::class + ); + } + + $this->capability = $capability; + + return $this; + } + + public function destructive(): self + { + $this->isDestructive = true; + + return $this; + } + + public function tooltip(string $message): self + { + $this->customTooltip = $message; + + return $this; + } + + public function apply(): Action + { + $this->applyVisibility(); + $this->applyDisabledState(); + $this->applyDestructiveConfirmation(); + $this->applyServerSideGuard(); + + return $this->action; + } + + private function applyVisibility(): void + { + if (! $this->requireMembership) { + return; + } + + $this->action->visible(function (?Model $record = null): bool { + $context = $this->resolveContextWithRecord($record); + + return $context->isMember; + }); + } + + private function applyDisabledState(): void + { + if ($this->capability === null) { + return; + } + + $tooltip = $this->customTooltip ?? AuthUiTooltips::insufficientPermission(); + + $this->action->disabled(function (?Model $record = null): bool { + $context = $this->resolveContextWithRecord($record); + + if (! $context->isMember) { + return true; + } + + return ! $context->hasCapability; + }); + + $this->action->tooltip(function (?Model $record = null) use ($tooltip): ?string { + $context = $this->resolveContextWithRecord($record); + + if ($context->isMember && ! $context->hasCapability) { + return $tooltip; + } + + return null; + }); + } + + private function applyDestructiveConfirmation(): void + { + if (! $this->isDestructive) { + return; + } + + $this->action->requiresConfirmation(); + $this->action->modalHeading(UiTooltips::DESTRUCTIVE_CONFIRM_TITLE); + $this->action->modalDescription(UiTooltips::DESTRUCTIVE_CONFIRM_DESCRIPTION); + } + + private function applyServerSideGuard(): void + { + $this->action->before(function (?Model $record = null): void { + $context = $this->resolveContextWithRecord($record); + + if ($context->shouldDenyAsNotFound()) { + abort(404); + } + + if ($context->shouldDenyAsForbidden()) { + abort(403); + } + }); + } + + private function resolveContextWithRecord(?Model $record = null): WorkspaceAccessContext + { + $user = auth()->user(); + $workspace = $this->resolveWorkspaceWithRecord($record); + + if (! $user instanceof User || ! $workspace instanceof Workspace) { + return new WorkspaceAccessContext( + user: null, + workspace: null, + isMember: false, + hasCapability: false, + ); + } + + /** @var WorkspaceCapabilityResolver $resolver */ + $resolver = app(WorkspaceCapabilityResolver::class); + + $isMember = $resolver->isMember($user, $workspace); + + $hasCapability = true; + if ($this->capability !== null && $isMember) { + $hasCapability = $resolver->can($user, $workspace, $this->capability); + } + + return new WorkspaceAccessContext( + user: $user, + workspace: $workspace, + isMember: $isMember, + hasCapability: $hasCapability, + ); + } + + private function resolveWorkspaceWithRecord(?Model $record = null): ?Workspace + { + if ($record instanceof Workspace) { + return $record; + } + + if ($this->record !== null) { + try { + $resolved = $this->record instanceof Closure + ? ($this->record)() + : $this->record; + + if ($resolved instanceof Workspace) { + return $resolved; + } + } catch (Throwable) { + return null; + } + } + + return null; + } +} diff --git a/specs/072-managed-tenants-workspace-enforcement/tasks.md b/specs/072-managed-tenants-workspace-enforcement/tasks.md index 1ee239c..74860ea 100644 --- a/specs/072-managed-tenants-workspace-enforcement/tasks.md +++ b/specs/072-managed-tenants-workspace-enforcement/tasks.md @@ -29,6 +29,10 @@ ## Security hardening (owners / audit / recovery) - [x] T270 Audit every blocked last-owner attempt with `workspace_membership.last_owner_blocked` + required metadata. - [x] T280 Optional: break-glass recovery flow to re-assign a workspace owner (fully audited). +## Follow-up bugfix +- [x] T300 Fix Workspaces → Memberships UI enforcement to use workspace capabilities (not tenant capabilities). +- [x] T310 Add regression tests for WorkspaceMemberships relation manager action enable/disable. + ## Validation - [x] T900 Run Pint on dirty files. - [x] T910 Run targeted Pest tests. diff --git a/tests/Feature/Rbac/WorkspaceMembershipsRelationManagerUiEnforcementTest.php b/tests/Feature/Rbac/WorkspaceMembershipsRelationManagerUiEnforcementTest.php new file mode 100644 index 0000000..facf7d5 --- /dev/null +++ b/tests/Feature/Rbac/WorkspaceMembershipsRelationManagerUiEnforcementTest.php @@ -0,0 +1,80 @@ +create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + ]); + + $this->actingAs($user); + + $otherUser = User::factory()->create(); + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $otherUser->getKey(), + 'role' => 'readonly', + ]); + + Livewire::test(WorkspaceMembershipsRelationManager::class, [ + 'ownerRecord' => $workspace, + 'pageClass' => EditWorkspace::class, + ]) + ->assertTableActionVisible('add_member') + ->assertTableActionEnabled('add_member') + ->assertTableActionVisible('change_role') + ->assertTableActionEnabled('change_role') + ->assertTableActionVisible('remove') + ->assertTableActionEnabled('remove'); + }); + + it('shows membership actions as visible but disabled for readonly members', function () { + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'readonly', + ]); + + $this->actingAs($user); + + Livewire::test(WorkspaceMembershipsRelationManager::class, [ + 'ownerRecord' => $workspace, + 'pageClass' => EditWorkspace::class, + ]) + ->assertTableActionVisible('add_member') + ->assertTableActionDisabled('add_member') + ->assertTableActionExists('add_member', function (Action $action): bool { + return $action->getTooltip() === 'You do not have permission to manage workspace memberships.'; + }) + ->assertTableActionVisible('change_role') + ->assertTableActionDisabled('change_role') + ->assertTableActionExists('change_role', function (Action $action): bool { + return $action->getTooltip() === 'You do not have permission to manage workspace memberships.'; + }) + ->assertTableActionVisible('remove') + ->assertTableActionDisabled('remove') + ->assertTableActionExists('remove', function (Action $action): bool { + return $action->getTooltip() === 'You do not have permission to manage workspace memberships.'; + }); + }); +}); -- 2.45.2