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(); +});