TenantAtlas/app/Filament/Pages/BaselineCompareLanding.php
ahmido 45a804970e feat: complete admin canonical tenant rollout (#165)
## Summary
- complete Spec 136 canonical admin tenant rollout across admin-visible and shared Filament surfaces
- add the shared panel-aware tenant resolver helper, persisted filter-state synchronization, and admin navigation segregation for tenant-sensitive resources
- expand regression, guard, and parity coverage for admin-path tenant resolution, stale filters, workspace-wide tenant-default surfaces, and panel split behavior

## Validation
- `vendor/bin/sail artisan test --compact tests/Feature/Guards/AdminTenantResolverGuardTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/Filament/TableStatePersistenceTest.php`
- `vendor/bin/sail artisan test --compact --filter='CanonicalAdminTenantFilterState|PolicyResource|BackupSchedule|BackupSet|FindingResource|BaselineCompareLanding|RestoreRunResource|InventoryItemResource|PolicyVersionResource|ProviderConnectionResource|TenantDiagnostics|InventoryCoverage|InventoryKpiHeader|AuditLog|EntraGroup'`
- `vendor/bin/sail bin pint --dirty --format agent`

## Notes
- Livewire v4.0+ compliance is preserved with Filament v5.
- Provider registration remains unchanged in `bootstrap/providers.php`.
- `PolicyResource` and `PolicyVersionResource` have admin global search disabled explicitly; `EntraGroupResource` keeps admin-aware scoped search with a View page.
- Destructive and governance-sensitive actions retain existing confirmation and authorization behavior while using canonical tenant parity.
- No new assets were introduced, so deployment asset strategy is unchanged and does not add new `filament:assets` work.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #165
2026-03-13 08:09:20 +00:00

370 lines
13 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Filament\Pages;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Resources\FindingResource;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\Baselines\BaselineCompareService;
use App\Support\Auth\Capabilities;
use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Baselines\BaselineCompareStats;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\UiEnforcement;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use UnitEnum;
class BaselineCompareLanding extends Page
{
use ResolvesPanelTenantContext;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-scale';
protected static string|UnitEnum|null $navigationGroup = 'Governance';
protected static ?string $navigationLabel = 'Baseline Compare';
protected static ?int $navigationSort = 10;
protected static ?string $title = 'Baseline Compare';
protected string $view = 'filament.pages.baseline-compare-landing';
public ?string $state = null;
public ?string $message = null;
public ?string $reasonCode = null;
public ?string $reasonMessage = null;
public ?string $profileName = null;
public ?int $profileId = null;
public ?int $snapshotId = null;
public ?int $duplicateNamePoliciesCount = null;
public ?int $operationRunId = null;
public ?int $findingsCount = null;
/** @var array<string, int>|null */
public ?array $severityCounts = null;
public ?string $lastComparedAt = null;
public ?string $lastComparedIso = null;
public ?string $failureReason = null;
public ?string $coverageStatus = null;
public ?int $uncoveredTypesCount = null;
/** @var list<string>|null */
public ?array $uncoveredTypes = null;
public ?string $fidelity = null;
public ?int $evidenceGapsCount = null;
/** @var array<string, int>|null */
public ?array $evidenceGapsTopReasons = null;
/** @var array<string, int>|null */
public ?array $rbacRoleDefinitionSummary = null;
public static function canAccess(): bool
{
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
$tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant instanceof Tenant) {
return false;
}
$resolver = app(CapabilityResolver::class);
return $resolver->can($user, $tenant, Capabilities::TENANT_VIEW);
}
public function mount(): void
{
$this->refreshStats();
}
public function refreshStats(): void
{
$stats = BaselineCompareStats::forTenant(static::resolveTenantContextForCurrentPanel());
$this->state = $stats->state;
$this->message = $stats->message;
$this->profileName = $stats->profileName;
$this->profileId = $stats->profileId;
$this->snapshotId = $stats->snapshotId;
$this->duplicateNamePoliciesCount = $stats->duplicateNamePoliciesCount;
$this->operationRunId = $stats->operationRunId;
$this->findingsCount = $stats->findingsCount;
$this->severityCounts = $stats->severityCounts !== [] ? $stats->severityCounts : null;
$this->lastComparedAt = $stats->lastComparedHuman;
$this->lastComparedIso = $stats->lastComparedIso;
$this->failureReason = $stats->failureReason;
$this->reasonCode = $stats->reasonCode;
$this->reasonMessage = $stats->reasonMessage;
$this->coverageStatus = $stats->coverageStatus;
$this->uncoveredTypesCount = $stats->uncoveredTypesCount;
$this->uncoveredTypes = $stats->uncoveredTypes !== [] ? $stats->uncoveredTypes : null;
$this->fidelity = $stats->fidelity;
$this->evidenceGapsCount = $stats->evidenceGapsCount;
$this->evidenceGapsTopReasons = $stats->evidenceGapsTopReasons !== [] ? $stats->evidenceGapsTopReasons : null;
$this->rbacRoleDefinitionSummary = $stats->rbacRoleDefinitionSummary !== [] ? $stats->rbacRoleDefinitionSummary : null;
}
/**
* Computed view data exposed to the Blade template.
*
* Moves presentational logic out of Blade `@php` blocks so the
* template only receives ready-to-render values.
*
* @return array<string, mixed>
*/
protected function getViewData(): array
{
$hasCoverageWarnings = in_array($this->coverageStatus, ['warning', 'unproven'], true);
$evidenceGapsCountValue = (int) ($this->evidenceGapsCount ?? 0);
$hasEvidenceGaps = $evidenceGapsCountValue > 0;
$hasWarnings = $hasCoverageWarnings || $hasEvidenceGaps;
$hasRbacRoleDefinitionSummary = is_array($this->rbacRoleDefinitionSummary)
&& array_sum($this->rbacRoleDefinitionSummary) > 0;
$evidenceGapsSummary = null;
$evidenceGapsTooltip = null;
if ($hasEvidenceGaps && is_array($this->evidenceGapsTopReasons) && $this->evidenceGapsTopReasons !== []) {
$parts = [];
foreach (array_slice($this->evidenceGapsTopReasons, 0, 5, true) as $reason => $count) {
if (! is_string($reason) || $reason === '' || ! is_numeric($count)) {
continue;
}
$parts[] = $reason.' ('.((int) $count).')';
}
if ($parts !== []) {
$evidenceGapsSummary = implode(', ', $parts);
$evidenceGapsTooltip = __('baseline-compare.evidence_gaps_tooltip', ['summary' => $evidenceGapsSummary]);
}
}
// Derive the colour class for the findings-count stat card.
// Only show danger-red when high-severity findings exist;
// use warning-orange for low/medium-only, and success-green for zero.
$findingsColorClass = $this->resolveFindingsColorClass($hasWarnings);
// "Why no findings" explanation when count is zero.
$whyNoFindingsMessage = filled($this->reasonMessage) ? (string) $this->reasonMessage : null;
$whyNoFindingsFallback = ! $hasWarnings
? __('baseline-compare.no_findings_all_clear')
: ($hasCoverageWarnings
? __('baseline-compare.no_findings_coverage_warnings')
: ($hasEvidenceGaps
? __('baseline-compare.no_findings_evidence_gaps')
: __('baseline-compare.no_findings_default')));
$whyNoFindingsColor = $hasWarnings
? 'text-warning-600 dark:text-warning-400'
: 'text-success-600 dark:text-success-400';
if ($this->reasonCode === 'no_subjects_in_scope') {
$whyNoFindingsColor = 'text-gray-600 dark:text-gray-400';
}
return [
'hasCoverageWarnings' => $hasCoverageWarnings,
'evidenceGapsCountValue' => $evidenceGapsCountValue,
'hasEvidenceGaps' => $hasEvidenceGaps,
'hasWarnings' => $hasWarnings,
'hasRbacRoleDefinitionSummary' => $hasRbacRoleDefinitionSummary,
'evidenceGapsSummary' => $evidenceGapsSummary,
'evidenceGapsTooltip' => $evidenceGapsTooltip,
'findingsColorClass' => $findingsColorClass,
'whyNoFindingsMessage' => $whyNoFindingsMessage,
'whyNoFindingsFallback' => $whyNoFindingsFallback,
'whyNoFindingsColor' => $whyNoFindingsColor,
];
}
/**
* Resolve the Tailwind colour class for the Total Findings stat.
*
* - Red (danger) only when high-severity findings exist
* - Orange (warning) for medium/low-only findings or when warnings present
* - Green (success) when fully clear
*/
private function resolveFindingsColorClass(bool $hasWarnings): string
{
$count = (int) ($this->findingsCount ?? 0);
if ($count === 0) {
return $hasWarnings
? 'text-warning-600 dark:text-warning-400'
: 'text-success-600 dark:text-success-400';
}
$hasHigh = ($this->severityCounts['high'] ?? 0) > 0;
return $hasHigh
? 'text-danger-600 dark:text-danger-400'
: 'text-warning-600 dark:text-warning-400';
}
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
->satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Compare Now (confirmation modal, capability-gated).')
->exempt(ActionSurfaceSlot::InspectAffordance, 'This is a tenant-scoped landing page, not a record inspect surface.')
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'This page does not render table rows with secondary actions.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'This page has no bulk actions.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Page renders explicit empty states for missing tenant, missing assignment, and missing snapshot, with guidance messaging.')
->exempt(ActionSurfaceSlot::DetailHeader, 'This page does not have a record detail header; it uses a page header action instead.');
}
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
return [
$this->compareNowAction(),
];
}
private function compareNowAction(): Action
{
$isFullContent = false;
if (is_int($this->profileId) && $this->profileId > 0) {
$profile = \App\Models\BaselineProfile::query()->find($this->profileId);
$mode = $profile?->capture_mode instanceof BaselineCaptureMode
? $profile->capture_mode
: (is_string($profile?->capture_mode) ? BaselineCaptureMode::tryFrom($profile->capture_mode) : null);
$isFullContent = $mode === BaselineCaptureMode::FullContent;
}
$label = $isFullContent ? 'Compare now (full content)' : 'Compare now';
$modalDescription = $isFullContent
? 'This will refresh content evidence on demand (redacted) before comparing the current tenant inventory against the assigned baseline snapshot.'
: 'This will compare the current tenant inventory against the assigned baseline snapshot and generate drift findings.';
$action = Action::make('compareNow')
->label($label)
->icon('heroicon-o-play')
->requiresConfirmation()
->modalHeading($label)
->modalDescription($modalDescription)
->disabled(fn (): bool => ! in_array($this->state, ['idle', 'ready', 'failed'], true))
->action(function (): void {
$user = auth()->user();
if (! $user instanceof User) {
Notification::make()->title('Not authenticated')->danger()->send();
return;
}
$tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant instanceof Tenant) {
Notification::make()->title('Select a tenant to compare baselines')->danger()->send();
return;
}
$service = app(BaselineCompareService::class);
$result = $service->startCompare($tenant, $user);
if (! ($result['ok'] ?? false)) {
Notification::make()
->title('Cannot start comparison')
->body('Reason: '.($result['reason_code'] ?? 'unknown'))
->danger()
->send();
return;
}
$run = $result['run'] ?? null;
if ($run instanceof OperationRun) {
$this->operationRunId = (int) $run->getKey();
}
$this->state = 'comparing';
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::queuedToast($run instanceof OperationRun ? (string) $run->type : 'baseline_compare')
->actions($run instanceof OperationRun ? [
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($run, $tenant)),
] : [])
->send();
});
return UiEnforcement::forAction($action)
->requireCapability(Capabilities::TENANT_SYNC)
->preserveDisabled()
->apply();
}
public function getFindingsUrl(): ?string
{
$tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant instanceof Tenant) {
return null;
}
return FindingResource::getUrl('index', tenant: $tenant);
}
public function getRunUrl(): ?string
{
if ($this->operationRunId === null) {
return null;
}
$tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant instanceof Tenant) {
return null;
}
return OperationRunLinks::view($this->operationRunId, $tenant);
}
}