feat: workspace-first managed tenants + RBAC membership UI fixes (072) #87
125
app/Support/Middleware/EnsureFilamentTenantSelected.php
Normal file
125
app/Support/Middleware/EnsureFilamentTenantSelected.php
Normal file
@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Middleware;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Closure;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Models\Contracts\HasTenants;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureFilamentTenantSelected
|
||||
{
|
||||
/**
|
||||
* @param Closure(Request): Response $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if ($request->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);
|
||||
}
|
||||
}
|
||||
100
routes/web.php
100
routes/web.php
@ -1,9 +1,18 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Pages\Tenancy\RegisterTenant;
|
||||
use App\Http\Controllers\AdminConsentCallbackController;
|
||||
use App\Http\Controllers\Auth\EntraController;
|
||||
use App\Http\Controllers\RbacDelegatedAuthController;
|
||||
use App\Http\Controllers\TenantOnboardingController;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Middleware\DenyNonMemberTenantAccess;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use App\Support\Workspaces\WorkspaceResolver;
|
||||
use Filament\Http\Middleware\Authenticate as FilamentAuthenticate;
|
||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||
use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get('/', function () {
|
||||
@ -16,6 +25,24 @@
|
||||
Route::get('/admin/consent/start', TenantOnboardingController::class)
|
||||
->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,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
8
tests/Feature/AdminNewRedirectTest.php
Normal file
8
tests/Feature/AdminNewRedirectTest.php
Normal file
@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
it('redirects /admin/new to /admin/login for guests', function (): void {
|
||||
$this->get('/admin/new')
|
||||
->assertRedirect('/admin/login');
|
||||
});
|
||||
38
tests/Feature/ManagedTenants/AuthorizationSemanticsTest.php
Normal file
38
tests/Feature/ManagedTenants/AuthorizationSemanticsTest.php
Normal file
@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
|
||||
it('returns 403 for a member without managed-tenant manage capability when accessing edit', function (): void {
|
||||
$tenant = Tenant::factory()->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();
|
||||
});
|
||||
17
tests/Feature/ManagedTenants/OnboardingRedirectTest.php
Normal file
17
tests/Feature/ManagedTenants/OnboardingRedirectTest.php
Normal file
@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Tenant;
|
||||
|
||||
it('redirects /admin/new to the canonical managed-tenant onboarding page', function (): void {
|
||||
$tenant = Tenant::factory()->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');
|
||||
});
|
||||
@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
Http::preventStrayRequests();
|
||||
});
|
||||
|
||||
it('redirects legacy managed-tenants entry to workspace landing when workspace is selected', function (): void {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$workspace = Workspace::factory()->create(['slug' => 'acme']);
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => $workspace->getKey(),
|
||||
'user_id' => $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
||||
->get('/admin/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();
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user