From 717e2d95a37bfd7f5ca0e8b156f7e23b9d911a01 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Mon, 2 Feb 2026 10:58:01 +0100 Subject: [PATCH 1/4] 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. From ea526b255ad7084444456260f2e4da01664dd4f7 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Mon, 2 Feb 2026 10:59:10 +0100 Subject: [PATCH 2/4] 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], ]); From b2f419bdb2230d6641d2743748347fc311759dcc Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Mon, 2 Feb 2026 10:59:24 +0100 Subject: [PATCH 3/4] 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(); +}); From b60a8cea045f121514158ef908f828639095cfd0 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Mon, 2 Feb 2026 10:59:34 +0100 Subject: [PATCH 4/4] 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. +
-
-
+ +