Implements workspace-first enforcement and UX: - Workspace selected before tenant flows; /admin routes into choose-workspace/choose-tenant - Tenant lists and default tenant selection are scoped to current workspace - Workspaces UI is tenantless at /admin/workspaces Security hardening: - Workspaces can never have 0 owners (blocks last-owner removal/demotion) - Blocked attempts are audited with action_id=workspace_membership.last_owner_blocked + required metadata - Optional break-glass recovery page to re-assign workspace owner (audited) Tests: - Added/updated Pest feature tests covering redirects, scoping, tenantless workspaces, last-owner guards, and break-glass recovery. Notes: - Filament v5 strict Page property signatures respected in RepairWorkspaceOwners. Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box> Reviewed-on: #86
205 lines
7.4 KiB
PHP
205 lines
7.4 KiB
PHP
<?php
|
|
|
|
use App\Filament\Pages\Tenancy\RegisterTenant;
|
|
use App\Filament\Pages\TenantDashboard;
|
|
use App\Http\Controllers\AdminConsentCallbackController;
|
|
use App\Http\Controllers\Auth\EntraController;
|
|
use App\Http\Controllers\RbacDelegatedAuthController;
|
|
use App\Http\Controllers\SelectTenantController;
|
|
use App\Http\Controllers\SwitchWorkspaceController;
|
|
use App\Http\Controllers\TenantOnboardingController;
|
|
use App\Models\User;
|
|
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 () {
|
|
return view('welcome');
|
|
});
|
|
|
|
Route::get('/admin/consent/callback', AdminConsentCallbackController::class)
|
|
->name('admin.consent.callback');
|
|
|
|
Route::get('/admin/consent/start', TenantOnboardingController::class)
|
|
->name('admin.consent.start');
|
|
// Panel root override: keep the app's workspace-first flow.
|
|
// Avoid Filament's tenancy root redirect which otherwise sends users to /admin/register-tenant
|
|
// when no default tenant can be resolved.
|
|
Route::middleware([
|
|
'web',
|
|
'panel:admin',
|
|
'ensure-correct-guard:web',
|
|
DenyNonMemberTenantAccess::class,
|
|
DisableBladeIconComponents::class,
|
|
DispatchServingFilamentEvent::class,
|
|
FilamentAuthenticate::class,
|
|
'ensure-workspace-selected',
|
|
])
|
|
->get('/admin', function (Request $request) {
|
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId($request);
|
|
|
|
$user = $request->user();
|
|
|
|
if (! $user instanceof User) {
|
|
return redirect()->to('/admin/choose-workspace');
|
|
}
|
|
|
|
if ($workspaceId === null) {
|
|
return redirect()->to('/admin/choose-workspace');
|
|
}
|
|
|
|
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
|
|
|
if (! $workspace instanceof Workspace) {
|
|
return redirect()->to('/admin/choose-workspace');
|
|
}
|
|
|
|
$tenantsQuery = $user->tenants()
|
|
->where('workspace_id', $workspace->getKey())
|
|
->where('status', 'active');
|
|
|
|
$tenantCount = (int) $tenantsQuery->count();
|
|
|
|
if ($tenantCount === 0) {
|
|
return redirect()->route('admin.workspace.managed-tenants.index', ['workspace' => $workspace->slug ?? $workspace->getKey()]);
|
|
}
|
|
|
|
if ($tenantCount === 1) {
|
|
$tenant = $tenantsQuery->first();
|
|
|
|
if ($tenant !== null) {
|
|
return redirect()->to(TenantDashboard::getUrl(tenant: $tenant));
|
|
}
|
|
}
|
|
|
|
return redirect()->to('/admin/choose-tenant');
|
|
})
|
|
->name('admin.home');
|
|
// 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');
|
|
|
|
Route::get('/admin/rbac/callback', [RbacDelegatedAuthController::class, 'callback'])
|
|
->name('admin.rbac.callback');
|
|
|
|
Route::get('/auth/entra/redirect', [EntraController::class, 'redirect'])
|
|
->name('auth.entra.redirect');
|
|
|
|
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::middleware(['web', 'auth', 'ensure-correct-guard:web'])
|
|
->post('/admin/switch-workspace', SwitchWorkspaceController::class)
|
|
->name('admin.switch-workspace');
|
|
|
|
Route::middleware(['web', 'auth', 'ensure-correct-guard:web', 'ensure-workspace-selected'])
|
|
->post('/admin/select-tenant', SelectTenantController::class)
|
|
->name('admin.select-tenant');
|
|
Route::bind('workspace', function (string $value): Workspace {
|
|
/** @var WorkspaceResolver $resolver */
|
|
$resolver = app(WorkspaceResolver::class);
|
|
|
|
$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()->route('admin.workspace.managed-tenants.index', ['workspace' => request()->route('workspace')]))
|
|
->name('admin.workspace.home');
|
|
|
|
Route::get('/ping', fn () => response()->noContent())->name('admin.workspace.ping');
|
|
|
|
Route::get('/managed-tenants/onboarding', fn () => redirect('/admin/register-tenant'))
|
|
->name('admin.workspace.managed-tenants.onboarding');
|
|
});
|
|
|
|
Route::middleware([
|
|
'web',
|
|
'panel:admin',
|
|
'ensure-correct-guard:web',
|
|
DenyNonMemberTenantAccess::class,
|
|
DisableBladeIconComponents::class,
|
|
DispatchServingFilamentEvent::class,
|
|
FilamentAuthenticate::class,
|
|
'ensure-workspace-member',
|
|
'ensure-filament-tenant-selected',
|
|
])
|
|
->get('/admin/w/{workspace}/managed-tenants', \App\Filament\Pages\Workspaces\ManagedTenantsLanding::class)
|
|
->name('admin.workspace.managed-tenants.index');
|
|
|
|
if (app()->runningUnitTests()) {
|
|
Route::middleware(['web', 'auth', 'ensure-workspace-selected'])
|
|
->get('/admin/_test/workspace-context', function (Request $request) {
|
|
$workspaceId = app(\App\Support\Workspaces\WorkspaceContext::class)->currentWorkspaceId($request);
|
|
|
|
return response()->json([
|
|
'workspace_id' => $workspaceId,
|
|
]);
|
|
});
|
|
}
|