## Summary - add the Spec 194 governance action catalog, friction classes, reason policies, and regression guards - align exception, review, evidence, finding, tenant, provider connection, and system run actions to the shared semantics model - add focused feature, RBAC, audit, unit, and browser coverage, including the tenant detail triage header consistency update ## Verification - ran the focused Spec 194 verification pack from the quickstart and task plan - ran targeted tenant triage coverage after the detail-header update - ran `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` ## Filament Notes - Filament v5 / Livewire v4 compliance preserved - provider registration remains in `apps/platform/bootstrap/providers.php` - globally searchable resources were not changed - destructive actions remain confirmation-gated and server-authorized - no new Filament assets were introduced; the existing `cd apps/platform && php artisan filament:assets` deploy step stays unchanged Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #229
509 lines
18 KiB
PHP
509 lines
18 KiB
PHP
<?php
|
|
|
|
use App\Filament\Pages\WorkspaceOverview;
|
|
use App\Http\Controllers\AdminConsentCallbackController;
|
|
use App\Http\Controllers\Auth\EntraController;
|
|
use App\Http\Controllers\ClearTenantContextController;
|
|
use App\Http\Controllers\OpenFindingExceptionsQueueController;
|
|
use App\Http\Controllers\RbacDelegatedAuthController;
|
|
use App\Http\Controllers\ReviewPackDownloadController;
|
|
use App\Http\Controllers\SelectTenantController;
|
|
use App\Http\Controllers\SwitchWorkspaceController;
|
|
use App\Http\Controllers\TenantOnboardingController;
|
|
use App\Http\Middleware\SuppressDebugbarForSmokeRequests;
|
|
use App\Models\ProviderConnection;
|
|
use App\Models\Tenant;
|
|
use App\Models\TenantOnboardingSession;
|
|
use App\Models\User;
|
|
use App\Models\Workspace;
|
|
use App\Services\Onboarding\OnboardingDraftResolver;
|
|
use App\Support\Auth\WorkspaceRole;
|
|
use App\Services\Tenants\TenantOperabilityService;
|
|
use App\Support\Tenants\TenantOperabilityQuestion;
|
|
use App\Support\Tenants\TenantPageCategory;
|
|
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\Auth;
|
|
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');
|
|
|
|
// Avoid Filament's tenancy root redirect which otherwise sends users into legacy flows.
|
|
// when no default tenant can be resolved.
|
|
Route::middleware([
|
|
'web',
|
|
'panel:admin',
|
|
'ensure-correct-guard:web',
|
|
DisableBladeIconComponents::class,
|
|
DispatchServingFilamentEvent::class,
|
|
FilamentAuthenticate::class,
|
|
'ensure-workspace-selected',
|
|
])
|
|
->get('/admin', WorkspaceOverview::class)
|
|
->name('admin.home');
|
|
|
|
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');
|
|
|
|
$makeSmokeCookie = static fn () => cookie()->make(
|
|
SuppressDebugbarForSmokeRequests::COOKIE_NAME,
|
|
SuppressDebugbarForSmokeRequests::COOKIE_VALUE,
|
|
120,
|
|
);
|
|
|
|
$resolveSmokeTenant = static function (?string $identifier): ?Tenant {
|
|
$identifier = trim((string) $identifier);
|
|
|
|
if ($identifier === '') {
|
|
return null;
|
|
}
|
|
|
|
return Tenant::query()
|
|
->withTrashed()
|
|
->where(function ($query) use ($identifier): void {
|
|
$query->where('external_id', $identifier)
|
|
->orWhere('tenant_id', $identifier);
|
|
|
|
if (ctype_digit($identifier)) {
|
|
$query->orWhereKey((int) $identifier);
|
|
}
|
|
})
|
|
->first();
|
|
};
|
|
|
|
$resolveSmokeWorkspace = static function (?string $identifier, ?Tenant $tenant = null): ?Workspace {
|
|
if ($tenant instanceof Tenant) {
|
|
return Workspace::query()->whereKey($tenant->workspace_id)->first();
|
|
}
|
|
|
|
$identifier = trim((string) $identifier);
|
|
|
|
if ($identifier === '') {
|
|
return null;
|
|
}
|
|
|
|
return Workspace::query()
|
|
->where(function ($query) use ($identifier): void {
|
|
$query->where('slug', $identifier);
|
|
|
|
if (ctype_digit($identifier)) {
|
|
$query->orWhereKey((int) $identifier);
|
|
}
|
|
})
|
|
->first();
|
|
};
|
|
|
|
$resolveSmokeRedirect = static function (?string $redirect, ?Tenant $tenant = null): string {
|
|
$fallback = $tenant instanceof Tenant && ! $tenant->trashed()
|
|
? '/admin/t/'.$tenant->external_id
|
|
: '/admin';
|
|
|
|
$redirect = trim((string) $redirect);
|
|
|
|
if ($redirect === '') {
|
|
return $fallback;
|
|
}
|
|
|
|
$parsedRedirect = parse_url($redirect);
|
|
|
|
if ($parsedRedirect === false || isset($parsedRedirect['scheme']) || isset($parsedRedirect['host'])) {
|
|
return $fallback;
|
|
}
|
|
|
|
$path = '/'.ltrim((string) ($parsedRedirect['path'] ?? ''), '/');
|
|
|
|
if ($path !== '/admin' && ! str_starts_with($path, '/admin/')) {
|
|
return $fallback;
|
|
}
|
|
|
|
$query = isset($parsedRedirect['query']) ? '?'.$parsedRedirect['query'] : '';
|
|
$fragment = isset($parsedRedirect['fragment']) ? '#'.$parsedRedirect['fragment'] : '';
|
|
|
|
return $path.$query.$fragment;
|
|
};
|
|
|
|
$resolveSmokeUser = static function (?string $email, ?Workspace $workspace = null, ?Tenant $tenant = null): ?User {
|
|
$email = trim((string) $email);
|
|
|
|
if ($email !== '') {
|
|
$user = User::query()->where('email', $email)->first();
|
|
|
|
return $user instanceof User ? $user : null;
|
|
}
|
|
|
|
$scopedWorkspace = $workspace;
|
|
|
|
if (! $scopedWorkspace instanceof Workspace && $tenant instanceof Tenant) {
|
|
$scopedWorkspace = Workspace::query()->whereKey($tenant->workspace_id)->first();
|
|
}
|
|
|
|
if (! $scopedWorkspace instanceof Workspace) {
|
|
return null;
|
|
}
|
|
|
|
$rolePriority = [
|
|
WorkspaceRole::Owner->value => 0,
|
|
WorkspaceRole::Manager->value => 1,
|
|
WorkspaceRole::Operator->value => 2,
|
|
WorkspaceRole::Readonly->value => 3,
|
|
];
|
|
|
|
$users = User::query()
|
|
->whereHas('workspaceMemberships', function ($query) use ($scopedWorkspace): void {
|
|
$query->where('workspace_id', (int) $scopedWorkspace->getKey());
|
|
})
|
|
->when($tenant instanceof Tenant, function ($query) use ($tenant): void {
|
|
$query->whereHas('tenantMemberships', function ($membershipQuery) use ($tenant): void {
|
|
$membershipQuery->where('tenant_id', (int) $tenant->getKey());
|
|
});
|
|
})
|
|
->with(['workspaceMemberships' => function ($query) use ($scopedWorkspace): void {
|
|
$query->where('workspace_id', (int) $scopedWorkspace->getKey());
|
|
}])
|
|
->get()
|
|
->filter(function (User $user) use ($tenant): bool {
|
|
return ! $tenant instanceof Tenant || $user->canAccessTenant($tenant);
|
|
})
|
|
->sortBy(function (User $user) use ($rolePriority): array {
|
|
$role = $user->workspaceMemberships->first()?->role;
|
|
|
|
return [
|
|
$rolePriority[(string) $role] ?? 99,
|
|
(int) $user->getKey(),
|
|
];
|
|
})
|
|
->values();
|
|
|
|
$user = $users->first();
|
|
|
|
return $user instanceof User ? $user : null;
|
|
};
|
|
|
|
$completeSmokeLogin = static function (
|
|
Request $request,
|
|
?string $email = null,
|
|
?string $tenantIdentifier = null,
|
|
?string $workspaceIdentifier = null,
|
|
?string $redirect = null,
|
|
) use (
|
|
$makeSmokeCookie,
|
|
$resolveSmokeRedirect,
|
|
$resolveSmokeTenant,
|
|
$resolveSmokeUser,
|
|
$resolveSmokeWorkspace,
|
|
): \Illuminate\Http\RedirectResponse {
|
|
$tenant = $resolveSmokeTenant($tenantIdentifier);
|
|
$workspace = $resolveSmokeWorkspace($workspaceIdentifier, $tenant);
|
|
$user = $resolveSmokeUser($email, $workspace, $tenant);
|
|
|
|
abort_unless($user instanceof User, 404);
|
|
|
|
$workspaceContext = app(WorkspaceContext::class);
|
|
|
|
if (! $workspace instanceof Workspace) {
|
|
$workspace = $workspaceContext->resolveInitialWorkspaceFor($user, $request);
|
|
}
|
|
|
|
abort_unless($workspace instanceof Workspace, 404);
|
|
abort_unless($workspaceContext->isMember($user, $workspace), 404);
|
|
|
|
if ($tenant instanceof Tenant) {
|
|
abort_unless((int) $tenant->workspace_id === (int) $workspace->getKey(), 404);
|
|
abort_unless($user->canAccessTenant($tenant), 404);
|
|
}
|
|
|
|
Auth::guard('web')->login($user);
|
|
$request->session()->regenerate();
|
|
$request->session()->put(
|
|
SuppressDebugbarForSmokeRequests::SESSION_KEY,
|
|
SuppressDebugbarForSmokeRequests::COOKIE_VALUE,
|
|
);
|
|
|
|
$workspaceContext->setCurrentWorkspace($workspace, $user, $request);
|
|
|
|
if ($tenant instanceof Tenant) {
|
|
$workspaceContext->rememberTenantContext($tenant, $request);
|
|
} else {
|
|
$workspaceContext->clearRememberedTenantContext($request);
|
|
}
|
|
|
|
return redirect()
|
|
->to($resolveSmokeRedirect($redirect, $tenant))
|
|
->withCookie($makeSmokeCookie());
|
|
};
|
|
|
|
Route::get('/admin/local/smoke-login', function (Request $request) use ($completeSmokeLogin) {
|
|
abort_unless(app()->environment(['local', 'testing']), 404);
|
|
|
|
$fixture = config('tenantpilot.backup_health.browser_smoke_fixture');
|
|
$defaultEmail = is_array($fixture) ? data_get($fixture, 'user.email') : null;
|
|
$defaultTenant = is_array($fixture)
|
|
? (data_get($fixture, 'blocked_drillthrough.tenant_external_id') ?? data_get($fixture, 'blocked_drillthrough.tenant_id'))
|
|
: null;
|
|
$defaultWorkspace = is_array($fixture) ? data_get($fixture, 'workspace.slug') : null;
|
|
|
|
return $completeSmokeLogin(
|
|
$request,
|
|
email: (string) ($request->query('email', $defaultEmail ?? '')),
|
|
tenantIdentifier: (string) ($request->query('tenant', $defaultTenant ?? '')),
|
|
workspaceIdentifier: (string) ($request->query('workspace', $defaultWorkspace ?? '')),
|
|
redirect: (string) ($request->query('redirect', '')),
|
|
);
|
|
})->name('admin.local.smoke-login');
|
|
|
|
Route::get('/admin/local/backup-health-browser-fixture-login', function (Request $request) use ($completeSmokeLogin) {
|
|
abort_unless(app()->environment(['local', 'testing']), 404);
|
|
|
|
$fixture = config('tenantpilot.backup_health.browser_smoke_fixture');
|
|
$userEmail = is_array($fixture) ? data_get($fixture, 'user.email') : null;
|
|
$tenantRouteKey = is_array($fixture)
|
|
? (data_get($fixture, 'blocked_drillthrough.tenant_id') ?? data_get($fixture, 'blocked_drillthrough.tenant_external_id'))
|
|
: null;
|
|
|
|
abort_unless(is_string($userEmail) && $userEmail !== '', 404);
|
|
abort_unless(is_string($tenantRouteKey) && $tenantRouteKey !== '', 404);
|
|
|
|
return $completeSmokeLogin(
|
|
$request,
|
|
email: $userEmail,
|
|
tenantIdentifier: $tenantRouteKey,
|
|
workspaceIdentifier: is_array($fixture) ? data_get($fixture, 'workspace.slug') : null,
|
|
);
|
|
})->name('admin.local.backup-health-browser-fixture-login');
|
|
|
|
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'])
|
|
->get('/admin/finding-exceptions/open-queue/{tenant}', OpenFindingExceptionsQueueController::class)
|
|
->name('admin.finding-exceptions.open-queue');
|
|
|
|
Route::middleware(['web', 'auth', 'ensure-correct-guard:web', 'ensure-workspace-selected'])
|
|
->post('/admin/select-tenant', SelectTenantController::class)
|
|
->name('admin.select-tenant');
|
|
|
|
Route::middleware(['web', 'auth', 'ensure-correct-guard:web', 'ensure-workspace-selected'])
|
|
->post('/admin/clear-tenant-context', ClearTenantContextController::class)
|
|
->name('admin.clear-tenant-context');
|
|
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::bind('onboardingDraft', function (string $value): TenantOnboardingSession {
|
|
$user = auth()->user();
|
|
|
|
abort_unless($user instanceof \App\Models\User, 403);
|
|
|
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
|
|
|
abort_unless(is_int($workspaceId), 404);
|
|
|
|
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
|
|
|
abort_unless($workspace instanceof Workspace, 404);
|
|
|
|
return app(OnboardingDraftResolver::class)->resolve((int) $value, $user, $workspace);
|
|
});
|
|
|
|
$authorizeManagedTenantRoute = function (Tenant $tenant, Request $request): void {
|
|
$user = $request->user();
|
|
|
|
abort_unless($user instanceof User, 403);
|
|
|
|
$workspaceContext = app(WorkspaceContext::class);
|
|
$workspaceId = $workspaceContext->currentWorkspaceId($request);
|
|
|
|
abort_unless(is_int($workspaceId), 404);
|
|
abort_unless((int) $tenant->workspace_id === $workspaceId, 404);
|
|
|
|
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
|
|
|
abort_unless($workspace instanceof Workspace, 404);
|
|
abort_unless($workspaceContext->isMember($user, $workspace), 404);
|
|
|
|
$allowed = app(TenantOperabilityService::class)->outcomeFor(
|
|
tenant: $tenant,
|
|
question: TenantOperabilityQuestion::TenantBoundViewability,
|
|
actor: $user,
|
|
workspaceId: $workspaceId,
|
|
lane: TenantPageCategory::TenantBound->lane(),
|
|
)->allowed;
|
|
|
|
abort_unless($allowed, 404);
|
|
};
|
|
|
|
Route::middleware(['web', 'auth', 'ensure-correct-guard:web', '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::middleware([
|
|
'web',
|
|
'panel:admin',
|
|
'ensure-correct-guard:web',
|
|
DisableBladeIconComponents::class,
|
|
DispatchServingFilamentEvent::class,
|
|
FilamentAuthenticate::class,
|
|
])
|
|
->get('/admin/onboarding', \App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class)
|
|
->name('admin.onboarding');
|
|
|
|
Route::middleware([
|
|
'web',
|
|
'panel:admin',
|
|
'ensure-correct-guard:web',
|
|
DisableBladeIconComponents::class,
|
|
DispatchServingFilamentEvent::class,
|
|
FilamentAuthenticate::class,
|
|
])
|
|
->get('/admin/onboarding/{onboardingDraft}', \App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class)
|
|
->name('admin.onboarding.draft');
|
|
|
|
Route::middleware([
|
|
'web',
|
|
'panel:admin',
|
|
'ensure-correct-guard:web',
|
|
DisableBladeIconComponents::class,
|
|
DispatchServingFilamentEvent::class,
|
|
FilamentAuthenticate::class,
|
|
'ensure-workspace-selected',
|
|
])
|
|
->get('/admin/operations', \App\Filament\Pages\Monitoring\Operations::class)
|
|
->name('admin.operations.index');
|
|
|
|
Route::middleware([
|
|
'web',
|
|
'panel:admin',
|
|
'ensure-correct-guard:web',
|
|
DisableBladeIconComponents::class,
|
|
DispatchServingFilamentEvent::class,
|
|
FilamentAuthenticate::class,
|
|
'ensure-workspace-selected',
|
|
])
|
|
->get('/admin/evidence/overview', \App\Filament\Pages\Monitoring\EvidenceOverview::class)
|
|
->name('admin.evidence.overview');
|
|
|
|
Route::middleware([
|
|
'web',
|
|
'panel:admin',
|
|
'ensure-correct-guard:web',
|
|
DisableBladeIconComponents::class,
|
|
DispatchServingFilamentEvent::class,
|
|
FilamentAuthenticate::class,
|
|
'ensure-workspace-selected',
|
|
])
|
|
->prefix('/admin/tenants/{tenant:external_id}/provider-connections')
|
|
->group(function () use ($authorizeManagedTenantRoute): void {
|
|
Route::get('/', function (Tenant $tenant, Request $request) use ($authorizeManagedTenantRoute) {
|
|
$authorizeManagedTenantRoute($tenant, $request);
|
|
|
|
return redirect()->to('/admin/provider-connections?tenant_id='.$tenant->external_id);
|
|
})->name('admin.provider-connections.legacy-index');
|
|
|
|
Route::get('/create', function (Tenant $tenant, Request $request) use ($authorizeManagedTenantRoute) {
|
|
$authorizeManagedTenantRoute($tenant, $request);
|
|
|
|
return redirect()->to('/admin/provider-connections/create?tenant_id='.$tenant->external_id);
|
|
})->name('admin.provider-connections.legacy-create');
|
|
|
|
Route::get('/{record}/edit', function (Tenant $tenant, mixed $record, Request $request) use ($authorizeManagedTenantRoute) {
|
|
$authorizeManagedTenantRoute($tenant, $request);
|
|
|
|
$connection = ProviderConnection::query()
|
|
->whereKey((int) $record)
|
|
->where('tenant_id', (int) $tenant->getKey())
|
|
->where('workspace_id', (int) $tenant->workspace_id)
|
|
->first();
|
|
|
|
abort_unless($connection instanceof ProviderConnection, 404);
|
|
|
|
return redirect()->to('/admin/provider-connections/'.$connection->getKey().'/edit?tenant_id='.$tenant->external_id);
|
|
})->name('admin.provider-connections.legacy-edit');
|
|
});
|
|
|
|
Route::middleware([
|
|
'web',
|
|
'panel:admin',
|
|
'ensure-correct-guard:web',
|
|
DisableBladeIconComponents::class,
|
|
DispatchServingFilamentEvent::class,
|
|
FilamentAuthenticate::class,
|
|
'ensure-workspace-selected',
|
|
'ensure-filament-tenant-selected',
|
|
])
|
|
->get('/admin/audit-log', \App\Filament\Pages\Monitoring\AuditLog::class)
|
|
->name('admin.monitoring.audit-log');
|
|
|
|
Route::middleware([
|
|
'web',
|
|
'panel:admin',
|
|
'ensure-correct-guard:web',
|
|
DisableBladeIconComponents::class,
|
|
DispatchServingFilamentEvent::class,
|
|
FilamentAuthenticate::class,
|
|
'ensure-workspace-selected',
|
|
])
|
|
->get('/admin/operations/{run}', \App\Filament\Pages\Operations\TenantlessOperationRunViewer::class)
|
|
->name('admin.operations.view');
|
|
|
|
Route::middleware([
|
|
'web',
|
|
'panel:admin',
|
|
'ensure-correct-guard:web',
|
|
DisableBladeIconComponents::class,
|
|
DispatchServingFilamentEvent::class,
|
|
FilamentAuthenticate::class,
|
|
'ensure-workspace-member',
|
|
])
|
|
->get('/admin/w/{workspace}/managed-tenants', \App\Filament\Pages\Workspaces\ManagedTenantsLanding::class)
|
|
->name('admin.workspace.managed-tenants.index');
|
|
|
|
Route::middleware(['signed'])
|
|
->get('/admin/review-packs/{reviewPack}/download', ReviewPackDownloadController::class)
|
|
->name('admin.review-packs.download');
|
|
|
|
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,
|
|
]);
|
|
});
|
|
}
|