feat(spec-118): resumable baseline evidence + snapshot UX

This commit is contained in:
Ahmed Darrazi 2026-03-04 23:22:16 +01:00
parent 559bba09a0
commit e3a062c1a2
35 changed files with 2581 additions and 156 deletions

View File

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\PolicyVersion;
use App\Support\Baselines\PolicyVersionCapturePurpose;
use Illuminate\Console\Command;
class PruneBaselineEvidencePolicyVersionsCommand extends Command
{
/**
* @var string
*/
protected $signature = 'tenantpilot:baseline-evidence:prune {--days= : Number of days to retain baseline evidence policy versions}';
/**
* @var string
*/
protected $description = 'Soft-delete baseline-capture/baseline-compare policy versions older than the configured retention window';
public function handle(): int
{
$days = (int) ($this->option('days') ?: config('tenantpilot.baselines.full_content_capture.retention_days', 90));
if ($days < 1) {
$this->error('Retention days must be at least 1.');
return self::FAILURE;
}
$cutoff = now()->subDays($days);
$deleted = PolicyVersion::query()
->whereNull('deleted_at')
->whereIn('capture_purpose', [
PolicyVersionCapturePurpose::BaselineCapture->value,
PolicyVersionCapturePurpose::BaselineCompare->value,
])
->where('captured_at', '<', $cutoff)
->delete();
$this->info("Pruned {$deleted} baseline evidence policy version(s) older than {$days} days.");
return self::SUCCESS;
}
}

View File

@ -44,12 +44,18 @@ class BaselineCompareLanding extends Page
public ?string $message = null; public ?string $message = null;
public ?string $reasonCode = null;
public ?string $reasonMessage = null;
public ?string $profileName = null; public ?string $profileName = null;
public ?int $profileId = null; public ?int $profileId = null;
public ?int $snapshotId = null; public ?int $snapshotId = null;
public ?int $duplicateNamePoliciesCount = null;
public ?int $operationRunId = null; public ?int $operationRunId = null;
public ?int $findingsCount = null; public ?int $findingsCount = null;
@ -110,12 +116,15 @@ public function refreshStats(): void
$this->profileName = $stats->profileName; $this->profileName = $stats->profileName;
$this->profileId = $stats->profileId; $this->profileId = $stats->profileId;
$this->snapshotId = $stats->snapshotId; $this->snapshotId = $stats->snapshotId;
$this->duplicateNamePoliciesCount = $stats->duplicateNamePoliciesCount;
$this->operationRunId = $stats->operationRunId; $this->operationRunId = $stats->operationRunId;
$this->findingsCount = $stats->findingsCount; $this->findingsCount = $stats->findingsCount;
$this->severityCounts = $stats->severityCounts !== [] ? $stats->severityCounts : null; $this->severityCounts = $stats->severityCounts !== [] ? $stats->severityCounts : null;
$this->lastComparedAt = $stats->lastComparedHuman; $this->lastComparedAt = $stats->lastComparedHuman;
$this->lastComparedIso = $stats->lastComparedIso; $this->lastComparedIso = $stats->lastComparedIso;
$this->failureReason = $stats->failureReason; $this->failureReason = $stats->failureReason;
$this->reasonCode = $stats->reasonCode;
$this->reasonMessage = $stats->reasonMessage;
$this->coverageStatus = $stats->coverageStatus; $this->coverageStatus = $stats->coverageStatus;
$this->uncoveredTypesCount = $stats->uncoveredTypesCount; $this->uncoveredTypesCount = $stats->uncoveredTypesCount;
@ -126,6 +135,101 @@ public function refreshStats(): void
$this->evidenceGapsTopReasons = $stats->evidenceGapsTopReasons !== [] ? $stats->evidenceGapsTopReasons : null; $this->evidenceGapsTopReasons = $stats->evidenceGapsTopReasons !== [] ? $stats->evidenceGapsTopReasons : 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;
$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,
'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 public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{ {
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly) return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)

View File

@ -9,10 +9,16 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\Auth\CapabilityResolver; use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Baselines\BaselineEvidenceCaptureResumeService;
use App\Support\Auth\Capabilities;
use App\Support\OperateHub\OperateHubShell; use App\Support\OperateHub\OperateHubShell;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Actions\ActionGroup; use Filament\Actions\ActionGroup;
use Filament\Notifications\Notification;
use Filament\Pages\Page; use Filament\Pages\Page;
use Filament\Schemas\Components\EmbeddedSchema; use Filament\Schemas\Components\EmbeddedSchema;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
@ -105,6 +111,8 @@ protected function getHeaderActions(): array
->color('gray'); ->color('gray');
} }
$actions[] = $this->resumeCaptureAction();
return $actions; return $actions;
} }
@ -139,4 +147,120 @@ public function content(Schema $schema): Schema
EmbeddedSchema::make('infolist'), EmbeddedSchema::make('infolist'),
]); ]);
} }
private function resumeCaptureAction(): Action
{
return Action::make('resumeCapture')
->label('Resume capture')
->icon('heroicon-o-forward')
->requiresConfirmation()
->modalHeading('Resume capture')
->modalDescription('This will start a follow-up operation to capture remaining baseline evidence for this scope.')
->visible(fn (): bool => $this->canResumeCapture())
->action(function (): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
if (! isset($this->run)) {
Notification::make()
->title('Run not loaded')
->danger()
->send();
return;
}
$service = app(BaselineEvidenceCaptureResumeService::class);
$result = $service->resume($this->run, $user);
if (! ($result['ok'] ?? false)) {
$reason = is_string($result['reason_code'] ?? null) ? (string) $result['reason_code'] : 'unknown';
Notification::make()
->title('Cannot resume capture')
->body('Reason: '.str_replace('.', ' ', $reason))
->danger()
->send();
return;
}
$run = $result['run'] ?? null;
if (! $run instanceof OperationRun) {
Notification::make()
->title('Cannot resume capture')
->body('Reason: missing operation run')
->danger()
->send();
return;
}
$viewAction = Action::make('view_run')
->label('View run')
->url(OperationRunLinks::tenantlessView($run));
if (! $run->wasRecentlyCreated && in_array((string) $run->status, ['queued', 'running'], true)) {
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::alreadyQueuedToast((string) $run->type)
->actions([$viewAction])
->send();
return;
}
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::queuedToast((string) $run->type)
->actions([$viewAction])
->send();
});
}
private function canResumeCapture(): bool
{
if (! isset($this->run)) {
return false;
}
if ((string) $this->run->status !== 'completed') {
return false;
}
if (! in_array((string) $this->run->type, ['baseline_capture', 'baseline_compare'], true)) {
return false;
}
$context = is_array($this->run->context) ? $this->run->context : [];
$tokenKey = (string) $this->run->type === 'baseline_capture'
? 'baseline_capture.resume_token'
: 'baseline_compare.resume_token';
$token = data_get($context, $tokenKey);
if (! is_string($token) || trim($token) === '') {
return false;
}
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
$workspace = $this->run->workspace;
if (! $workspace instanceof \App\Models\Workspace) {
return false;
}
$resolver = app(WorkspaceCapabilityResolver::class);
return $resolver->isMember($user, $workspace)
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE);
}
} }

View File

@ -0,0 +1,275 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\BaselineSnapshotResource\Pages;
use App\Models\BaselineSnapshot;
use App\Models\User;
use App\Models\Workspace;
use App\Support\Auth\Capabilities;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions\ViewAction;
use Filament\Facades\Filament;
use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use UnitEnum;
class BaselineSnapshotResource extends Resource
{
protected static bool $isDiscovered = false;
protected static bool $isScopedToTenant = false;
protected static ?string $model = BaselineSnapshot::class;
protected static ?string $slug = 'baseline-snapshots';
protected static bool $isGloballySearchable = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-camera';
protected static string|UnitEnum|null $navigationGroup = 'Governance';
protected static ?string $navigationLabel = 'Baseline Snapshots';
protected static ?int $navigationSort = 2;
public static function shouldRegisterNavigation(): bool
{
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
return false;
}
return parent::shouldRegisterNavigation();
}
public static function canViewAny(): bool
{
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
$workspace = self::resolveWorkspace();
if (! $workspace instanceof Workspace) {
return false;
}
$resolver = app(\App\Services\Auth\WorkspaceCapabilityResolver::class);
return $resolver->isMember($user, $workspace)
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_VIEW);
}
public static function canCreate(): bool
{
return false;
}
public static function canEdit(Model $record): bool
{
return false;
}
public static function canDelete(Model $record): bool
{
return false;
}
public static function canView(Model $record): bool
{
return self::canViewAny();
}
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
->exempt(ActionSurfaceSlot::ListHeader, 'Snapshots are created by capture runs; no list-header actions.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Snapshots are immutable; no row actions besides view.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Snapshots are immutable; no bulk actions.')
->exempt(ActionSurfaceSlot::ListEmptyState, 'Empty-state CTA is intentionally omitted; snapshots appear after baseline captures.')
->exempt(ActionSurfaceSlot::DetailHeader, 'View page is informational and currently has no header actions.');
}
public static function getEloquentQuery(): Builder
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
return parent::getEloquentQuery()
->with('baselineProfile')
->when(
$workspaceId !== null,
fn (Builder $query): Builder => $query->where('workspace_id', (int) $workspaceId),
)
->when(
$workspaceId === null,
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
);
}
public static function form(Schema $schema): Schema
{
return $schema;
}
public static function table(Table $table): Table
{
return $table
->defaultSort('captured_at', 'desc')
->columns([
TextColumn::make('id')
->label('Snapshot')
->formatStateUsing(static fn (?int $state): string => $state ? '#'.$state : '—')
->sortable(),
TextColumn::make('baselineProfile.name')
->label('Baseline')
->wrap()
->placeholder('—'),
TextColumn::make('captured_at')
->label('Captured')
->since()
->sortable(),
TextColumn::make('fidelity_summary')
->label('Fidelity')
->getStateUsing(static fn (BaselineSnapshot $record): string => self::fidelitySummary($record))
->wrap(),
TextColumn::make('snapshot_state')
->label('State')
->badge()
->getStateUsing(static fn (BaselineSnapshot $record): string => self::stateLabel($record))
->color(static fn (BaselineSnapshot $record): string => self::hasGaps($record) ? 'warning' : 'success'),
])
->actions([
ViewAction::make()->label('View'),
])
->bulkActions([]);
}
public static function infolist(Schema $schema): Schema
{
return $schema
->schema([
Section::make('Snapshot')
->schema([
TextEntry::make('id')
->label('Snapshot')
->formatStateUsing(static fn (?int $state): string => $state ? '#'.$state : '—'),
TextEntry::make('baselineProfile.name')
->label('Baseline'),
TextEntry::make('captured_at')
->label('Captured')
->dateTime(),
TextEntry::make('snapshot_state')
->label('State')
->badge()
->getStateUsing(static fn (BaselineSnapshot $record): string => self::stateLabel($record))
->color(static fn (BaselineSnapshot $record): string => self::hasGaps($record) ? 'warning' : 'success'),
TextEntry::make('fidelity_summary')
->label('Fidelity')
->getStateUsing(static fn (BaselineSnapshot $record): string => self::fidelitySummary($record)),
TextEntry::make('evidence_gaps')
->label('Evidence gaps')
->getStateUsing(static fn (BaselineSnapshot $record): int => self::gapsCount($record)),
TextEntry::make('snapshot_identity_hash')
->label('Identity hash')
->copyable()
->columnSpanFull(),
])
->columns(2)
->columnSpanFull(),
Section::make('Summary')
->schema([
ViewEntry::make('summary_jsonb')
->label('')
->view('filament.infolists.entries.snapshot-json')
->state(static fn (BaselineSnapshot $record): array => is_array($record->summary_jsonb) ? $record->summary_jsonb : [])
->columnSpanFull(),
])
->columnSpanFull(),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListBaselineSnapshots::route('/'),
'view' => Pages\ViewBaselineSnapshot::route('/{record}'),
];
}
private static function resolveWorkspace(): ?Workspace
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if ($workspaceId === null) {
return null;
}
return Workspace::query()->whereKey($workspaceId)->first();
}
private static function summary(BaselineSnapshot $snapshot): array
{
return is_array($snapshot->summary_jsonb) ? $snapshot->summary_jsonb : [];
}
private static function fidelityCounts(BaselineSnapshot $snapshot): array
{
$summary = self::summary($snapshot);
$counts = $summary['fidelity_counts'] ?? null;
$counts = is_array($counts) ? $counts : [];
$content = $counts['content'] ?? 0;
$meta = $counts['meta'] ?? 0;
return [
'content' => is_numeric($content) ? (int) $content : 0,
'meta' => is_numeric($meta) ? (int) $meta : 0,
];
}
private static function fidelitySummary(BaselineSnapshot $snapshot): string
{
$counts = self::fidelityCounts($snapshot);
return sprintf('Content %d, Meta %d', (int) ($counts['content'] ?? 0), (int) ($counts['meta'] ?? 0));
}
private static function gapsCount(BaselineSnapshot $snapshot): int
{
$summary = self::summary($snapshot);
$gaps = $summary['gaps'] ?? null;
$gaps = is_array($gaps) ? $gaps : [];
$count = $gaps['count'] ?? 0;
return is_numeric($count) ? (int) $count : 0;
}
private static function hasGaps(BaselineSnapshot $snapshot): bool
{
return self::gapsCount($snapshot) > 0;
}
private static function stateLabel(BaselineSnapshot $snapshot): string
{
return self::hasGaps($snapshot) ? 'Captured with gaps' : 'Complete';
}
}

View File

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\BaselineSnapshotResource\Pages;
use App\Filament\Resources\BaselineSnapshotResource;
use Filament\Resources\Pages\ListRecords;
class ListBaselineSnapshots extends ListRecords
{
protected static string $resource = BaselineSnapshotResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

View File

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\BaselineSnapshotResource\Pages;
use App\Filament\Resources\BaselineSnapshotResource;
use Filament\Resources\Pages\ViewRecord;
class ViewBaselineSnapshot extends ViewRecord
{
protected static string $resource = BaselineSnapshotResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

View File

@ -10,6 +10,7 @@
use App\Models\VerificationCheckAcknowledgement; use App\Models\VerificationCheckAcknowledgement;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\Baselines\BaselineCompareReasonCode;
use App\Support\OperateHub\OperateHubShell; use App\Support\OperateHub\OperateHubShell;
use App\Support\OperationCatalog; use App\Support\OperationCatalog;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
@ -218,6 +219,30 @@ public static function infolist(Schema $schema): Schema
'warnings', 'unproven' => 'warning', 'warnings', 'unproven' => 'warning',
default => 'gray', default => 'gray',
}), }),
TextEntry::make('baseline_compare_why_no_findings')
->label('Why no findings')
->getStateUsing(function (OperationRun $record): ?string {
$context = is_array($record->context) ? $record->context : [];
$code = data_get($context, 'baseline_compare.reason_code');
$code = is_string($code) ? trim($code) : null;
$code = $code !== '' ? $code : null;
if ($code === null) {
return null;
}
$enum = BaselineCompareReasonCode::tryFrom($code);
$message = $enum?->message();
return ($message !== null ? $message.' (' : '').$code.($message !== null ? ')' : '');
})
->visible(function (OperationRun $record): bool {
$context = is_array($record->context) ? $record->context : [];
$code = data_get($context, 'baseline_compare.reason_code');
return is_string($code) && trim($code) !== '';
})
->columnSpanFull(),
TextEntry::make('baseline_compare_uncovered_types') TextEntry::make('baseline_compare_uncovered_types')
->label('Uncovered types') ->label('Uncovered types')
->getStateUsing(function (OperationRun $record): ?string { ->getStateUsing(function (OperationRun $record): ?string {

View File

@ -22,14 +22,13 @@ class ListPolicies extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
$this->makeSyncAction() $this->makeSyncAction(),
->visible(fn (): bool => $this->getFilteredTableQuery()->exists()),
]; ];
} }
protected function getTableEmptyStateActions(): array protected function getTableEmptyStateActions(): array
{ {
return [$this->makeSyncAction('syncEmpty')]; return [$this->makeSyncAction()];
} }
private function makeSyncAction(string $name = 'sync'): Actions\Action private function makeSyncAction(string $name = 'sync'): Actions\Action

View File

@ -20,6 +20,7 @@
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Badges\TagBadgeDomain; use App\Support\Badges\TagBadgeDomain;
use App\Support\Badges\TagBadgeRenderer; use App\Support\Badges\TagBadgeRenderer;
use App\Support\Baselines\PolicyVersionCapturePurpose;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
@ -825,10 +826,29 @@ public static function table(Table $table): Table
public static function getEloquentQuery(): Builder public static function getEloquentQuery(): Builder
{ {
$tenantId = Tenant::currentOrFail()->getKey(); $tenant = Tenant::currentOrFail();
$tenantId = $tenant->getKey();
$user = auth()->user();
$resolver = app(CapabilityResolver::class);
$canSeeBaselinePurposeEvidence = $user instanceof User
&& (
$resolver->can($user, $tenant, Capabilities::TENANT_SYNC)
|| $resolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW)
);
return parent::getEloquentQuery() return parent::getEloquentQuery()
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId)) ->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
->when(! $canSeeBaselinePurposeEvidence, function (Builder $query): Builder {
return $query->where(function (Builder $query): void {
$query
->whereNull('capture_purpose')
->orWhereNotIn('capture_purpose', [
PolicyVersionCapturePurpose::BaselineCapture->value,
PolicyVersionCapturePurpose::BaselineCompare->value,
]);
});
})
->with('policy'); ->with('policy');
} }

View File

@ -284,17 +284,29 @@ private function collectInventorySubjects(
/** @var array<string, array{tenant_subject_external_id: string, workspace_subject_external_id: string, subject_key: string, policy_type: string, display_name: ?string, category: ?string, platform: ?string}> $inventoryByKey */ /** @var array<string, array{tenant_subject_external_id: string, workspace_subject_external_id: string, subject_key: string, policy_type: string, display_name: ?string, category: ?string, platform: ?string}> $inventoryByKey */
$inventoryByKey = []; $inventoryByKey = [];
$subjectsTotal = 0;
/** @var array<string, int> $gaps */ /** @var array<string, int> $gaps */
$gaps = []; $gaps = [];
/**
* Ensure we only include unambiguous subjects when matching by subject_key (derived from display name).
*
* When multiple inventory items share the same "policy_type|subject_key" we cannot reliably map them
* across tenants, so we treat them as an evidence gap and exclude them from the snapshot.
*
* @var array<string, true> $ambiguousKeys
*/
$ambiguousKeys = [];
/**
* @var array<string, string> $subjectKeyToInventoryKey
*/
$subjectKeyToInventoryKey = [];
$query->orderBy('policy_type') $query->orderBy('policy_type')
->orderBy('external_id') ->orderBy('external_id')
->chunk(500, function ($inventoryItems) use (&$inventoryByKey, &$subjectsTotal, &$gaps): void { ->chunk(500, function ($inventoryItems) use (&$inventoryByKey, &$gaps, &$ambiguousKeys, &$subjectKeyToInventoryKey): void {
foreach ($inventoryItems as $inventoryItem) { foreach ($inventoryItems as $inventoryItem) {
$subjectsTotal++;
$metaJsonb = is_array($inventoryItem->meta_jsonb) ? $inventoryItem->meta_jsonb : []; $metaJsonb = is_array($inventoryItem->meta_jsonb) ? $inventoryItem->meta_jsonb : [];
$displayName = is_string($inventoryItem->display_name) ? $inventoryItem->display_name : null; $displayName = is_string($inventoryItem->display_name) ? $inventoryItem->display_name : null;
$subjectKey = BaselineSubjectKey::fromDisplayName($displayName); $subjectKey = BaselineSubjectKey::fromDisplayName($displayName);
@ -305,18 +317,37 @@ private function collectInventorySubjects(
continue; continue;
} }
$policyType = (string) $inventoryItem->policy_type;
$logicalKey = $policyType.'|'.$subjectKey;
if (array_key_exists($logicalKey, $ambiguousKeys)) {
continue;
}
if (array_key_exists($logicalKey, $subjectKeyToInventoryKey)) {
$ambiguousKeys[$logicalKey] = true;
$previousKey = $subjectKeyToInventoryKey[$logicalKey];
unset($subjectKeyToInventoryKey[$logicalKey], $inventoryByKey[$previousKey]);
$gaps['ambiguous_match'] = ($gaps['ambiguous_match'] ?? 0) + 1;
continue;
}
$workspaceSafeId = BaselineSubjectKey::workspaceSafeSubjectExternalId( $workspaceSafeId = BaselineSubjectKey::workspaceSafeSubjectExternalId(
policyType: (string) $inventoryItem->policy_type, policyType: $policyType,
subjectKey: $subjectKey, subjectKey: $subjectKey,
); );
$key = (string) $inventoryItem->policy_type.'|'.(string) $inventoryItem->external_id; $key = $policyType.'|'.(string) $inventoryItem->external_id;
$subjectKeyToInventoryKey[$logicalKey] = $key;
$inventoryByKey[$key] = [ $inventoryByKey[$key] = [
'tenant_subject_external_id' => (string) $inventoryItem->external_id, 'tenant_subject_external_id' => (string) $inventoryItem->external_id,
'workspace_subject_external_id' => $workspaceSafeId, 'workspace_subject_external_id' => $workspaceSafeId,
'subject_key' => $subjectKey, 'subject_key' => $subjectKey,
'policy_type' => (string) $inventoryItem->policy_type, 'policy_type' => $policyType,
'display_name' => $displayName, 'display_name' => $displayName,
'category' => is_string($inventoryItem->category) ? $inventoryItem->category : null, 'category' => is_string($inventoryItem->category) ? $inventoryItem->category : null,
'platform' => is_string($inventoryItem->platform) ? $inventoryItem->platform : null, 'platform' => is_string($inventoryItem->platform) ? $inventoryItem->platform : null,
@ -335,7 +366,7 @@ private function collectInventorySubjects(
)); ));
return [ return [
'subjects_total' => $subjectsTotal, 'subjects_total' => count($subjects),
'subjects' => $subjects, 'subjects' => $subjects,
'inventory_by_key' => $inventoryByKey, 'inventory_by_key' => $inventoryByKey,
'gaps' => $gaps, 'gaps' => $gaps,

View File

@ -25,6 +25,7 @@
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Services\Settings\SettingsResolver; use App\Services\Settings\SettingsResolver;
use App\Support\Baselines\BaselineCaptureMode; use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Baselines\BaselineCompareReasonCode;
use App\Support\Baselines\BaselineFullContentRolloutGate; use App\Support\Baselines\BaselineFullContentRolloutGate;
use App\Support\Baselines\BaselineScope; use App\Support\Baselines\BaselineScope;
use App\Support\Baselines\BaselineSubjectKey; use App\Support\Baselines\BaselineSubjectKey;
@ -120,7 +121,43 @@ public function handle(
: BaselineCaptureMode::Opportunistic; : BaselineCaptureMode::Opportunistic;
if ($captureMode === BaselineCaptureMode::FullContent) { if ($captureMode === BaselineCaptureMode::FullContent) {
$rolloutGate->assertEnabled(); try {
$rolloutGate->assertEnabled();
} catch (RuntimeException) {
$this->auditStarted(
auditLogger: $auditLogger,
tenant: $tenant,
profile: $profile,
initiator: $initiator,
captureMode: $captureMode,
subjectsTotal: 0,
effectiveScope: $effectiveScope,
);
$effectiveTypeCount = count($effectiveTypes);
$gapCount = max(1, $effectiveTypeCount);
$this->completeWithCoverageWarning(
operationRunService: $operationRunService,
auditLogger: $auditLogger,
tenant: $tenant,
profile: $profile,
initiator: $initiator,
inventorySyncRun: null,
coverageProof: false,
effectiveTypes: $effectiveTypes,
coveredTypes: [],
uncoveredTypes: $effectiveTypes,
errorsRecorded: $gapCount,
captureMode: $captureMode,
reasonCode: BaselineCompareReasonCode::RolloutDisabled,
evidenceGapsByReason: [
BaselineCompareReasonCode::RolloutDisabled->value => $gapCount,
],
);
return;
}
} }
if ($effectiveTypes === []) { if ($effectiveTypes === []) {
@ -147,6 +184,8 @@ public function handle(
uncoveredTypes: [], uncoveredTypes: [],
errorsRecorded: 1, errorsRecorded: 1,
captureMode: $captureMode, captureMode: $captureMode,
reasonCode: BaselineCompareReasonCode::NoSubjectsInScope,
evidenceGapsByReason: [],
); );
return; return;
@ -414,6 +453,18 @@ public function handle(
? EvidenceProvenance::FidelityMeta ? EvidenceProvenance::FidelityMeta
: EvidenceProvenance::FidelityContent; : EvidenceProvenance::FidelityContent;
$reasonCode = null;
if ($subjectsTotal === 0) {
$reasonCode = BaselineCompareReasonCode::NoSubjectsInScope;
} elseif (count($driftResults) === 0) {
$reasonCode = match (true) {
$uncoveredTypes !== [] => BaselineCompareReasonCode::CoverageUnproven,
$resumeToken !== null || $gapsCount > 0 => BaselineCompareReasonCode::EvidenceCaptureIncomplete,
default => BaselineCompareReasonCode::NoDriftDetected,
};
}
$updatedContext = is_array($this->operationRun->context) ? $this->operationRun->context : []; $updatedContext = is_array($this->operationRun->context) ? $this->operationRun->context : [];
$updatedContext['baseline_compare'] = array_merge( $updatedContext['baseline_compare'] = array_merge(
is_array($updatedContext['baseline_compare'] ?? null) ? $updatedContext['baseline_compare'] : [], is_array($updatedContext['baseline_compare'] ?? null) ? $updatedContext['baseline_compare'] : [],
@ -437,6 +488,7 @@ public function handle(
...$baselineCoverage, ...$baselineCoverage,
], ],
'fidelity' => $overallFidelity, 'fidelity' => $overallFidelity,
'reason_code' => $reasonCode?->value,
], ],
); );
$updatedContext['findings'] = array_merge( $updatedContext['findings'] = array_merge(
@ -568,6 +620,8 @@ private function completeWithCoverageWarning(
array $uncoveredTypes, array $uncoveredTypes,
int $errorsRecorded, int $errorsRecorded,
BaselineCaptureMode $captureMode, BaselineCaptureMode $captureMode,
BaselineCompareReasonCode $reasonCode = BaselineCompareReasonCode::CoverageUnproven,
?array $evidenceGapsByReason = null,
): void { ): void {
$summaryCounts = [ $summaryCounts = [
'total' => 0, 'total' => 0,
@ -599,8 +653,8 @@ private function completeWithCoverageWarning(
'throttled' => 0, 'throttled' => 0,
]; ];
$evidenceGapsByReason = [ $evidenceGapsByReason ??= [
'coverage_unproven' => max(1, $errorsRecorded), BaselineCompareReasonCode::CoverageUnproven->value => max(1, $errorsRecorded),
]; ];
$updatedContext['baseline_compare'] = array_merge( $updatedContext['baseline_compare'] = array_merge(
@ -615,6 +669,7 @@ private function completeWithCoverageWarning(
...$evidenceGapsByReason, ...$evidenceGapsByReason,
], ],
'resume_token' => null, 'resume_token' => null,
'reason_code' => $reasonCode->value,
'coverage' => [ 'coverage' => [
'effective_types' => array_values($effectiveTypes), 'effective_types' => array_values($effectiveTypes),
'covered_types' => array_values($coveredTypes), 'covered_types' => array_values($coveredTypes),

View File

@ -13,6 +13,7 @@
use App\Filament\Resources\AlertDestinationResource; use App\Filament\Resources\AlertDestinationResource;
use App\Filament\Resources\AlertRuleResource; use App\Filament\Resources\AlertRuleResource;
use App\Filament\Resources\BaselineProfileResource; use App\Filament\Resources\BaselineProfileResource;
use App\Filament\Resources\BaselineSnapshotResource;
use App\Filament\Resources\InventoryItemResource; use App\Filament\Resources\InventoryItemResource;
use App\Filament\Resources\PolicyResource; use App\Filament\Resources\PolicyResource;
use App\Filament\Resources\ProviderConnectionResource; use App\Filament\Resources\ProviderConnectionResource;
@ -179,6 +180,7 @@ public function panel(Panel $panel): Panel
AlertDeliveryResource::class, AlertDeliveryResource::class,
WorkspaceResource::class, WorkspaceResource::class,
BaselineProfileResource::class, BaselineProfileResource::class,
BaselineSnapshotResource::class,
]) ])
->discoverClusters(in: app_path('Filament/Clusters'), for: 'App\\Filament\\Clusters') ->discoverClusters(in: app_path('Filament/Clusters'), for: 'App\\Filament\\Clusters')
->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources') ->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources')

View File

@ -9,6 +9,7 @@
use App\Services\Intune\PolicyCaptureOrchestrator; use App\Services\Intune\PolicyCaptureOrchestrator;
use App\Support\Baselines\BaselineEvidenceResumeToken; use App\Support\Baselines\BaselineEvidenceResumeToken;
use App\Support\Baselines\PolicyVersionCapturePurpose; use App\Support\Baselines\PolicyVersionCapturePurpose;
use Throwable;
final class BaselineContentCapturePhase final class BaselineContentCapturePhase
{ {
@ -37,7 +38,11 @@ public function capture(
?int $baselineProfileId = null, ?int $baselineProfileId = null,
?string $createdBy = null, ?string $createdBy = null,
): array { ): array {
$subjects = array_values($subjects);
$maxItemsPerRun = max(0, (int) ($budgets['max_items_per_run'] ?? 0)); $maxItemsPerRun = max(0, (int) ($budgets['max_items_per_run'] ?? 0));
$maxConcurrency = max(1, (int) ($budgets['max_concurrency'] ?? 1));
$maxRetries = max(0, (int) ($budgets['max_retries'] ?? 0));
$offset = 0; $offset = 0;
@ -46,6 +51,10 @@ public function capture(
$offset = is_numeric($state['offset'] ?? null) ? max(0, (int) $state['offset']) : 0; $offset = is_numeric($state['offset'] ?? null) ? max(0, (int) $state['offset']) : 0;
} }
if ($offset >= count($subjects)) {
$offset = 0;
}
$remaining = array_slice($subjects, $offset); $remaining = array_slice($subjects, $offset);
$batch = $maxItemsPerRun > 0 ? array_slice($remaining, 0, $maxItemsPerRun) : []; $batch = $maxItemsPerRun > 0 ? array_slice($remaining, 0, $maxItemsPerRun) : [];
@ -60,52 +69,104 @@ public function capture(
/** @var array<string, int> $gaps */ /** @var array<string, int> $gaps */
$gaps = []; $gaps = [];
foreach ($batch as $subject) { /**
$policyType = trim((string) ($subject['policy_type'] ?? '')); * @var array<string, true> $seen
$externalId = trim((string) ($subject['subject_external_id'] ?? '')); */
$seen = [];
if ($policyType === '' || $externalId === '') { foreach (array_chunk($batch, $maxConcurrency) as $chunk) {
$gaps['invalid_subject'] = ($gaps['invalid_subject'] ?? 0) + 1; foreach ($chunk as $subject) {
$stats['failed']++; $policyType = trim((string) ($subject['policy_type'] ?? ''));
$externalId = trim((string) ($subject['subject_external_id'] ?? ''));
continue; if ($policyType === '' || $externalId === '') {
$gaps['invalid_subject'] = ($gaps['invalid_subject'] ?? 0) + 1;
$stats['failed']++;
continue;
}
$subjectKey = $policyType.'|'.$externalId;
if (isset($seen[$subjectKey])) {
$gaps['duplicate_subject'] = ($gaps['duplicate_subject'] ?? 0) + 1;
$stats['skipped']++;
continue;
}
$seen[$subjectKey] = true;
$policy = Policy::query()
->where('tenant_id', (int) $tenant->getKey())
->where('policy_type', $policyType)
->where('external_id', $externalId)
->first();
if (! $policy instanceof Policy) {
$gaps['policy_not_found'] = ($gaps['policy_not_found'] ?? 0) + 1;
$stats['failed']++;
continue;
}
$attempt = 0;
$result = null;
while (true) {
try {
$result = $this->captureOrchestrator->capture(
policy: $policy,
tenant: $tenant,
includeAssignments: true,
includeScopeTags: true,
createdBy: $createdBy,
metadata: [
'capture_source' => 'baseline_evidence',
],
capturePurpose: $purpose,
operationRunId: $operationRunId,
baselineProfileId: $baselineProfileId,
);
} catch (Throwable $throwable) {
$result = [
'failure' => [
'reason' => $throwable->getMessage(),
'status' => is_numeric($throwable->getCode()) ? (int) $throwable->getCode() : null,
],
];
}
if (! (is_array($result) && array_key_exists('failure', $result))) {
$stats['succeeded']++;
break;
}
$failure = is_array($result['failure'] ?? null) ? $result['failure'] : [];
$status = is_numeric($failure['status'] ?? null) ? (int) $failure['status'] : null;
$isThrottled = in_array($status, [429, 503], true);
if ($isThrottled && $attempt < $maxRetries) {
$delayMs = $this->retryDelayMs($attempt);
usleep($delayMs * 1000);
$attempt++;
continue;
}
if ($isThrottled) {
$gaps['throttled'] = ($gaps['throttled'] ?? 0) + 1;
$stats['throttled']++;
} else {
$gaps['capture_failed'] = ($gaps['capture_failed'] ?? 0) + 1;
$stats['failed']++;
}
break;
}
} }
$policy = Policy::query()
->where('tenant_id', (int) $tenant->getKey())
->where('policy_type', $policyType)
->where('external_id', $externalId)
->first();
if (! $policy instanceof Policy) {
$gaps['policy_not_found'] = ($gaps['policy_not_found'] ?? 0) + 1;
$stats['failed']++;
continue;
}
$result = $this->captureOrchestrator->capture(
policy: $policy,
tenant: $tenant,
includeAssignments: true,
includeScopeTags: true,
createdBy: $createdBy,
metadata: [
'capture_source' => 'baseline_evidence',
],
capturePurpose: $purpose,
operationRunId: $operationRunId,
baselineProfileId: $baselineProfileId,
);
if (is_array($result) && array_key_exists('failure', $result)) {
$gaps['capture_failed'] = ($gaps['capture_failed'] ?? 0) + 1;
$stats['failed']++;
continue;
}
$stats['succeeded']++;
} }
$processed = $offset + count($batch); $processed = $offset + count($batch);
@ -114,7 +175,13 @@ public function capture(
if ($processed < count($subjects)) { if ($processed < count($subjects)) {
$resumeTokenOut = BaselineEvidenceResumeToken::encode([ $resumeTokenOut = BaselineEvidenceResumeToken::encode([
'offset' => $processed, 'offset' => $processed,
'total' => count($subjects),
]); ]);
$remainingCount = max(0, count($subjects) - $processed);
if ($remainingCount > 0) {
$gaps['budget_exhausted'] = ($gaps['budget_exhausted'] ?? 0) + $remainingCount;
}
} }
ksort($gaps); ksort($gaps);
@ -125,5 +192,17 @@ public function capture(
'resume_token' => $resumeTokenOut, 'resume_token' => $resumeTokenOut,
]; ];
} }
}
private function retryDelayMs(int $attempt): int
{
$attempt = max(0, $attempt);
$baseDelayMs = 500;
$maxDelayMs = 30_000;
$delayMs = (int) min($maxDelayMs, $baseDelayMs * (2 ** $attempt));
$jitterMs = random_int(0, 250);
return $delayMs + $jitterMs;
}
}

View File

@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace App\Services\Baselines;
use App\Jobs\CaptureBaselineSnapshotJob;
use App\Jobs\CompareBaselineToTenantJob;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Support\Auth\Capabilities;
use App\Support\Baselines\BaselineFullContentRolloutGate;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
final class BaselineEvidenceCaptureResumeService
{
public function __construct(
private readonly OperationRunService $runs,
private readonly WorkspaceCapabilityResolver $workspaceCapabilities,
private readonly BaselineFullContentRolloutGate $rolloutGate,
private readonly AuditLogger $auditLogger,
) {}
/**
* Start a follow-up baseline capture / compare run from an existing run + resume token.
*
* @return array{ok: bool, run?: OperationRun, reason_code?: string}
*/
public function resume(OperationRun $priorRun, User $initiator): array
{
$runType = trim((string) $priorRun->type);
if (! in_array($runType, [OperationRunType::BaselineCapture->value, OperationRunType::BaselineCompare->value], true)) {
return ['ok' => false, 'reason_code' => 'baseline.resume.unsupported_run_type'];
}
if ($priorRun->status !== OperationRunStatus::Completed->value) {
return ['ok' => false, 'reason_code' => 'baseline.resume.run_not_completed'];
}
$tenantId = (int) ($priorRun->tenant_id ?? 0);
if ($tenantId <= 0) {
return ['ok' => false, 'reason_code' => 'baseline.resume.missing_tenant'];
}
$tenant = Tenant::query()->whereKey($tenantId)->first();
if (! $tenant instanceof Tenant) {
return ['ok' => false, 'reason_code' => 'baseline.resume.tenant_not_found'];
}
$workspaceId = (int) ($tenant->workspace_id ?? 0);
if ($workspaceId <= 0) {
return ['ok' => false, 'reason_code' => 'baseline.resume.missing_workspace'];
}
$workspace = Workspace::query()->whereKey($workspaceId)->first();
if (! $workspace instanceof Workspace) {
return ['ok' => false, 'reason_code' => 'baseline.resume.workspace_not_found'];
}
if (! $this->workspaceCapabilities->isMember($initiator, $workspace)) {
return ['ok' => false, 'reason_code' => 'baseline.resume.not_workspace_member'];
}
if (! $this->workspaceCapabilities->can($initiator, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE)) {
return ['ok' => false, 'reason_code' => 'baseline.resume.forbidden'];
}
$this->rolloutGate->assertEnabled();
$context = is_array($priorRun->context) ? $priorRun->context : [];
$profileId = (int) ($context['baseline_profile_id'] ?? 0);
if ($profileId <= 0) {
return ['ok' => false, 'reason_code' => 'baseline.resume.missing_profile'];
}
$resumeSection = $runType === OperationRunType::BaselineCapture->value ? 'baseline_capture' : 'baseline_compare';
$resumeToken = data_get($context, "{$resumeSection}.resume_token");
if (! is_string($resumeToken) || trim($resumeToken) === '') {
return ['ok' => false, 'reason_code' => 'baseline.resume.missing_resume_token'];
}
$newContext = [];
foreach (['target_scope', 'baseline_profile_id', 'baseline_snapshot_id', 'source_tenant_id', 'effective_scope', 'capture_mode'] as $key) {
if (array_key_exists($key, $context)) {
$newContext[$key] = $context[$key];
}
}
$newContext['resume_from_operation_run_id'] = (int) $priorRun->getKey();
$newContext[$resumeSection] = [
'resume_token' => $resumeToken,
'resume_from_operation_run_id' => (int) $priorRun->getKey(),
];
$run = $this->runs->ensureRunWithIdentity(
tenant: $tenant,
type: $runType,
identityInputs: [
'baseline_profile_id' => $profileId,
],
context: $newContext,
initiator: $initiator,
);
if ($run->wasRecentlyCreated) {
match ($runType) {
OperationRunType::BaselineCapture->value => CaptureBaselineSnapshotJob::dispatch($run),
OperationRunType::BaselineCompare->value => CompareBaselineToTenantJob::dispatch($run),
default => null,
};
}
$this->auditLogger->log(
tenant: $tenant,
action: 'baseline.evidence.resume.started',
context: [
'metadata' => [
'prior_operation_run_id' => (int) $priorRun->getKey(),
'operation_run_id' => (int) $run->getKey(),
'baseline_profile_id' => $profileId,
'run_type' => $runType,
],
],
actorId: (int) $initiator->getKey(),
actorEmail: (string) $initiator->email,
actorName: (string) $initiator->name,
resourceType: 'operation_run',
resourceId: (string) $priorRun->getKey(),
);
return ['ok' => true, 'run' => $run];
}
}

View File

@ -16,11 +16,10 @@ public function message(): string
{ {
return match ($this) { return match ($this) {
self::NoSubjectsInScope => 'No subjects were in scope for this comparison.', self::NoSubjectsInScope => 'No subjects were in scope for this comparison.',
self::CoverageUnproven => 'Coverage proof was not available, so missing-policy outcomes were suppressed.', self::CoverageUnproven => 'Coverage proof was missing or incomplete, so some findings were suppressed for safety.',
self::EvidenceCaptureIncomplete => 'Evidence capture was incomplete, so some drift evaluation may have been suppressed.', self::EvidenceCaptureIncomplete => 'Evidence capture was incomplete, so some drift evaluation may have been suppressed.',
self::RolloutDisabled => 'Full-content baseline compare is currently disabled by rollout configuration.', self::RolloutDisabled => 'Full-content baseline compare is currently disabled by rollout configuration.',
self::NoDriftDetected => 'No drift was detected for in-scope subjects.', self::NoDriftDetected => 'No drift was detected for in-scope subjects.',
}; };
} }
} }

View File

@ -7,8 +7,10 @@
use App\Models\BaselineProfile; use App\Models\BaselineProfile;
use App\Models\BaselineTenantAssignment; use App\Models\BaselineTenantAssignment;
use App\Models\Finding; use App\Models\Finding;
use App\Models\InventoryItem;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use Illuminate\Support\Facades\Cache;
final class BaselineCompareStats final class BaselineCompareStats
{ {
@ -23,12 +25,15 @@ private function __construct(
public readonly ?string $profileName, public readonly ?string $profileName,
public readonly ?int $profileId, public readonly ?int $profileId,
public readonly ?int $snapshotId, public readonly ?int $snapshotId,
public readonly ?int $duplicateNamePoliciesCount,
public readonly ?int $operationRunId, public readonly ?int $operationRunId,
public readonly ?int $findingsCount, public readonly ?int $findingsCount,
public readonly array $severityCounts, public readonly array $severityCounts,
public readonly ?string $lastComparedHuman, public readonly ?string $lastComparedHuman,
public readonly ?string $lastComparedIso, public readonly ?string $lastComparedIso,
public readonly ?string $failureReason, public readonly ?string $failureReason,
public readonly ?string $reasonCode = null,
public readonly ?string $reasonMessage = null,
public readonly ?string $coverageStatus = null, public readonly ?string $coverageStatus = null,
public readonly ?int $uncoveredTypesCount = null, public readonly ?int $uncoveredTypesCount = null,
public readonly array $uncoveredTypes = [], public readonly array $uncoveredTypes = [],
@ -67,12 +72,23 @@ public static function forTenant(?Tenant $tenant): self
$profileId = (int) $profile->getKey(); $profileId = (int) $profile->getKey();
$snapshotId = $profile->active_snapshot_id !== null ? (int) $profile->active_snapshot_id : null; $snapshotId = $profile->active_snapshot_id !== null ? (int) $profile->active_snapshot_id : null;
$profileScope = BaselineScope::fromJsonb(
is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null,
);
$overrideScope = $assignment->override_scope_jsonb !== null
? BaselineScope::fromJsonb(is_array($assignment->override_scope_jsonb) ? $assignment->override_scope_jsonb : null)
: null;
$effectiveScope = BaselineScope::effective($profileScope, $overrideScope);
$duplicateNamePoliciesCount = self::duplicateNamePoliciesCount($tenant, $effectiveScope);
if ($snapshotId === null) { if ($snapshotId === null) {
return self::empty( return self::empty(
'no_snapshot', 'no_snapshot',
'The baseline profile has no active snapshot yet. A workspace manager needs to capture a snapshot first.', 'The baseline profile has no active snapshot yet. A workspace manager needs to capture a snapshot first.',
profileName: $profileName, profileName: $profileName,
profileId: $profileId, profileId: $profileId,
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
); );
} }
@ -84,6 +100,7 @@ public static function forTenant(?Tenant $tenant): self
[$coverageStatus, $uncoveredTypes, $fidelity] = self::coverageInfoForRun($latestRun); [$coverageStatus, $uncoveredTypes, $fidelity] = self::coverageInfoForRun($latestRun);
[$evidenceGapsCount, $evidenceGapsTopReasons] = self::evidenceGapSummaryForRun($latestRun); [$evidenceGapsCount, $evidenceGapsTopReasons] = self::evidenceGapSummaryForRun($latestRun);
[$reasonCode, $reasonMessage] = self::reasonInfoForRun($latestRun);
// Active run (queued/running) // Active run (queued/running)
if ($latestRun instanceof OperationRun && in_array($latestRun->status, ['queued', 'running'], true)) { if ($latestRun instanceof OperationRun && in_array($latestRun->status, ['queued', 'running'], true)) {
@ -93,12 +110,15 @@ public static function forTenant(?Tenant $tenant): self
profileName: $profileName, profileName: $profileName,
profileId: $profileId, profileId: $profileId,
snapshotId: $snapshotId, snapshotId: $snapshotId,
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
operationRunId: (int) $latestRun->getKey(), operationRunId: (int) $latestRun->getKey(),
findingsCount: null, findingsCount: null,
severityCounts: [], severityCounts: [],
lastComparedHuman: null, lastComparedHuman: null,
lastComparedIso: null, lastComparedIso: null,
failureReason: null, failureReason: null,
reasonCode: $reasonCode,
reasonMessage: $reasonMessage,
coverageStatus: $coverageStatus, coverageStatus: $coverageStatus,
uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0, uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0,
uncoveredTypes: $uncoveredTypes, uncoveredTypes: $uncoveredTypes,
@ -121,12 +141,15 @@ public static function forTenant(?Tenant $tenant): self
profileName: $profileName, profileName: $profileName,
profileId: $profileId, profileId: $profileId,
snapshotId: $snapshotId, snapshotId: $snapshotId,
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
operationRunId: (int) $latestRun->getKey(), operationRunId: (int) $latestRun->getKey(),
findingsCount: null, findingsCount: null,
severityCounts: [], severityCounts: [],
lastComparedHuman: $latestRun->finished_at?->diffForHumans(), lastComparedHuman: $latestRun->finished_at?->diffForHumans(),
lastComparedIso: $latestRun->finished_at?->toIso8601String(), lastComparedIso: $latestRun->finished_at?->toIso8601String(),
failureReason: (string) $failureReason, failureReason: (string) $failureReason,
reasonCode: $reasonCode,
reasonMessage: $reasonMessage,
coverageStatus: $coverageStatus, coverageStatus: $coverageStatus,
uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0, uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0,
uncoveredTypes: $uncoveredTypes, uncoveredTypes: $uncoveredTypes,
@ -171,12 +194,15 @@ public static function forTenant(?Tenant $tenant): self
profileName: $profileName, profileName: $profileName,
profileId: $profileId, profileId: $profileId,
snapshotId: $snapshotId, snapshotId: $snapshotId,
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
operationRunId: $latestRun instanceof OperationRun ? (int) $latestRun->getKey() : null, operationRunId: $latestRun instanceof OperationRun ? (int) $latestRun->getKey() : null,
findingsCount: $totalFindings, findingsCount: $totalFindings,
severityCounts: $severityCounts, severityCounts: $severityCounts,
lastComparedHuman: $lastComparedHuman, lastComparedHuman: $lastComparedHuman,
lastComparedIso: $lastComparedIso, lastComparedIso: $lastComparedIso,
failureReason: null, failureReason: null,
reasonCode: $reasonCode,
reasonMessage: $reasonMessage,
coverageStatus: $coverageStatus, coverageStatus: $coverageStatus,
uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0, uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0,
uncoveredTypes: $uncoveredTypes, uncoveredTypes: $uncoveredTypes,
@ -195,12 +221,15 @@ public static function forTenant(?Tenant $tenant): self
profileName: $profileName, profileName: $profileName,
profileId: $profileId, profileId: $profileId,
snapshotId: $snapshotId, snapshotId: $snapshotId,
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
operationRunId: (int) $latestRun->getKey(), operationRunId: (int) $latestRun->getKey(),
findingsCount: 0, findingsCount: 0,
severityCounts: $severityCounts, severityCounts: $severityCounts,
lastComparedHuman: $lastComparedHuman, lastComparedHuman: $lastComparedHuman,
lastComparedIso: $lastComparedIso, lastComparedIso: $lastComparedIso,
failureReason: null, failureReason: null,
reasonCode: $reasonCode,
reasonMessage: $reasonMessage,
coverageStatus: $coverageStatus, coverageStatus: $coverageStatus,
uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0, uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0,
uncoveredTypes: $uncoveredTypes, uncoveredTypes: $uncoveredTypes,
@ -216,12 +245,15 @@ public static function forTenant(?Tenant $tenant): self
profileName: $profileName, profileName: $profileName,
profileId: $profileId, profileId: $profileId,
snapshotId: $snapshotId, snapshotId: $snapshotId,
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
operationRunId: null, operationRunId: null,
findingsCount: null, findingsCount: null,
severityCounts: $severityCounts, severityCounts: $severityCounts,
lastComparedHuman: $lastComparedHuman, lastComparedHuman: $lastComparedHuman,
lastComparedIso: $lastComparedIso, lastComparedIso: $lastComparedIso,
failureReason: null, failureReason: null,
reasonCode: $reasonCode,
reasonMessage: $reasonMessage,
coverageStatus: $coverageStatus, coverageStatus: $coverageStatus,
uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0, uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0,
uncoveredTypes: $uncoveredTypes, uncoveredTypes: $uncoveredTypes,
@ -278,6 +310,7 @@ public static function forWidget(?Tenant $tenant): self
profileName: (string) $profile->name, profileName: (string) $profile->name,
profileId: (int) $profile->getKey(), profileId: (int) $profile->getKey(),
snapshotId: $profile->active_snapshot_id !== null ? (int) $profile->active_snapshot_id : null, snapshotId: $profile->active_snapshot_id !== null ? (int) $profile->active_snapshot_id : null,
duplicateNamePoliciesCount: null,
operationRunId: $latestRun instanceof OperationRun ? (int) $latestRun->getKey() : null, operationRunId: $latestRun instanceof OperationRun ? (int) $latestRun->getKey() : null,
findingsCount: $totalFindings, findingsCount: $totalFindings,
severityCounts: [ severityCounts: [
@ -291,6 +324,64 @@ public static function forWidget(?Tenant $tenant): self
); );
} }
private static function duplicateNamePoliciesCount(Tenant $tenant, BaselineScope $effectiveScope): int
{
$policyTypes = $effectiveScope->allTypes();
if ($policyTypes === []) {
return 0;
}
$compute = static function () use ($tenant, $policyTypes): int {
/**
* @var array<string, int> $countsByKey
*/
$countsByKey = [];
InventoryItem::query()
->where('tenant_id', (int) $tenant->getKey())
->whereIn('policy_type', $policyTypes)
->whereNotNull('display_name')
->select(['id', 'policy_type', 'display_name'])
->orderBy('id')
->chunkById(1_000, function ($inventoryItems) use (&$countsByKey): void {
foreach ($inventoryItems as $inventoryItem) {
$displayName = is_string($inventoryItem->display_name) ? $inventoryItem->display_name : null;
$subjectKey = BaselineSubjectKey::fromDisplayName($displayName);
if ($subjectKey === null) {
continue;
}
$logicalKey = (string) $inventoryItem->policy_type.'|'.$subjectKey;
$countsByKey[$logicalKey] = ($countsByKey[$logicalKey] ?? 0) + 1;
}
});
$duplicatePolicies = 0;
foreach ($countsByKey as $count) {
if ($count > 1) {
$duplicatePolicies += $count;
}
}
return $duplicatePolicies;
};
if (app()->environment('testing')) {
return $compute();
}
$cacheKey = sprintf(
'baseline_compare:tenant:%d:duplicate_names:%s',
(int) $tenant->getKey(),
hash('sha256', implode('|', $policyTypes)),
);
return (int) Cache::remember($cacheKey, now()->addSeconds(60), $compute);
}
/** /**
* @return array{0: ?string, 1: list<string>, 2: ?string} * @return array{0: ?string, 1: list<string>, 2: ?string}
*/ */
@ -335,6 +426,31 @@ private static function coverageInfoForRun(?OperationRun $run): array
return [$coverageStatus, $uncoveredTypes, $fidelity]; return [$coverageStatus, $uncoveredTypes, $fidelity];
} }
/**
* @return array{0: ?string, 1: ?string}
*/
private static function reasonInfoForRun(?OperationRun $run): array
{
if (! $run instanceof OperationRun) {
return [null, null];
}
$context = is_array($run->context) ? $run->context : [];
$baselineCompare = $context['baseline_compare'] ?? null;
if (! is_array($baselineCompare)) {
return [null, null];
}
$reasonCode = $baselineCompare['reason_code'] ?? null;
$reasonCode = is_string($reasonCode) ? trim($reasonCode) : null;
$reasonCode = $reasonCode !== '' ? $reasonCode : null;
$enum = $reasonCode !== null ? BaselineCompareReasonCode::tryFrom($reasonCode) : null;
return [$reasonCode, $enum?->message()];
}
/** /**
* @return array{0: ?int, 1: array<string, int>} * @return array{0: ?int, 1: array<string, int>}
*/ */
@ -393,6 +509,7 @@ private static function empty(
?string $message, ?string $message,
?string $profileName = null, ?string $profileName = null,
?int $profileId = null, ?int $profileId = null,
?int $duplicateNamePoliciesCount = null,
): self { ): self {
return new self( return new self(
state: $state, state: $state,
@ -400,6 +517,7 @@ private static function empty(
profileName: $profileName, profileName: $profileName,
profileId: $profileId, profileId: $profileId,
snapshotId: null, snapshotId: null,
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
operationRunId: null, operationRunId: null,
findingsCount: null, findingsCount: null,
severityCounts: [], severityCounts: [],

View File

@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
return [
/*
|--------------------------------------------------------------------------
| Baseline Compare Landing Page
|--------------------------------------------------------------------------
*/
// Duplicate-name warning banner
'duplicate_warning_title' => 'Warning',
'duplicate_warning_body_plural' => ':count policies in this tenant share the same display name. :app cannot match them to the baseline. Please rename the duplicates in the Microsoft Intune portal.',
'duplicate_warning_body_singular' => ':count policy in this tenant shares the same display name. :app cannot match it to the baseline. Please rename the duplicate in the Microsoft Intune portal.',
// Stats card labels
'stat_assigned_baseline' => 'Assigned Baseline',
'stat_total_findings' => 'Total Findings',
'stat_last_compared' => 'Last Compared',
'stat_last_compared_never' => 'Never',
'stat_error' => 'Error',
// Badges
'badge_snapshot' => 'Snapshot #:id',
'badge_coverage_ok' => 'Coverage: OK',
'badge_coverage_warnings' => 'Coverage: Warnings',
'badge_fidelity' => 'Fidelity: :level',
'badge_evidence_gaps' => 'Evidence gaps: :count',
'evidence_gaps_tooltip' => 'Top gaps: :summary',
// Comparing state
'comparing_indicator' => 'Comparing…',
// Why-no-findings explanations
'no_findings_all_clear' => 'All clear',
'no_findings_coverage_warnings' => 'Coverage warnings',
'no_findings_evidence_gaps' => 'Evidence gaps',
'no_findings_default' => 'No findings',
// Coverage warning banner
'coverage_warning_title' => 'Comparison completed with warnings',
'coverage_unproven_body' => 'Coverage proof was missing or unreadable for the last comparison run, so findings were suppressed for safety.',
'coverage_incomplete_body' => 'Findings were skipped for :count policy :types due to incomplete coverage.',
'coverage_uncovered_label' => 'Uncovered: :list',
// Failed banner
'failed_title' => 'Comparison Failed',
'failed_body_default' => 'The last baseline comparison failed. Review the run details or retry.',
// Critical drift banner
'critical_drift_title' => 'Critical Drift Detected',
'critical_drift_body' => 'The current tenant state deviates from baseline :profile. :count high-severity :findings require immediate attention.',
// Empty states
'empty_no_tenant' => 'No Tenant Selected',
'empty_no_assignment' => 'No Baseline Assigned',
'empty_no_snapshot' => 'No Snapshot Available',
// Findings section
'findings_description' => 'The tenant configuration drifted from the baseline profile.',
// No drift
'no_drift_title' => 'No Drift Detected',
'no_drift_body' => 'The tenant configuration matches the baseline profile. Everything looks good.',
// Coverage warnings (no findings)
'coverage_warnings_title' => 'Coverage Warnings',
'coverage_warnings_body' => 'The last comparison completed with warnings and produced no drift findings. Run Inventory Sync again to establish full coverage before interpreting results.',
// Idle
'idle_title' => 'Ready to Compare',
// Buttons
'button_view_run' => 'View run',
'button_view_failed_run' => 'View failed run',
'button_view_findings' => 'View all findings',
'button_review_last_run' => 'Review last run',
];

View File

@ -5,44 +5,40 @@
@endif @endif
@php @php
$hasCoverageWarnings = in_array(($coverageStatus ?? null), ['warning', 'unproven'], true); $duplicateNamePoliciesCountValue = (int) ($duplicateNamePoliciesCount ?? 0);
$evidenceGapsCountValue = (int) ($evidenceGapsCount ?? 0);
$hasEvidenceGaps = $evidenceGapsCountValue > 0;
$hasWarnings = $hasCoverageWarnings || $hasEvidenceGaps;
$evidenceGapsSummary = null;
$evidenceGapsTooltip = null;
if ($hasEvidenceGaps && is_array($evidenceGapsTopReasons ?? null) && $evidenceGapsTopReasons !== []) {
$parts = [];
foreach (array_slice($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 = 'Top gaps: '.$evidenceGapsSummary;
}
}
@endphp @endphp
@if ($duplicateNamePoliciesCountValue > 0)
<div role="alert" class="rounded-lg border border-warning-300 bg-warning-50 p-4 dark:border-warning-700 dark:bg-warning-950/40">
<div class="flex items-start gap-3">
<x-heroicon-s-exclamation-triangle class="h-6 w-6 shrink-0 text-warning-600 dark:text-warning-400" />
<div class="flex flex-col gap-1">
<div class="text-base font-semibold text-warning-900 dark:text-warning-200">
{{ __('baseline-compare.duplicate_warning_title') }}
</div>
<div class="text-sm text-warning-800 dark:text-warning-300">
{{ __($duplicateNamePoliciesCountValue === 1 ? 'baseline-compare.duplicate_warning_body_singular' : 'baseline-compare.duplicate_warning_body_plural', [
'count' => $duplicateNamePoliciesCountValue,
'app' => config('app.name', 'TenantPilot'),
]) }}
</div>
</div>
</div>
</div>
@endif
{{-- Row 1: Stats Overview --}} {{-- Row 1: Stats Overview --}}
@if (in_array($state, ['ready', 'idle', 'comparing', 'failed'])) @if (in_array($state, ['ready', 'idle', 'comparing', 'failed']))
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
{{-- Stat: Assigned Baseline --}} {{-- Stat: Assigned Baseline --}}
<x-filament::section> <x-filament::section>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">Assigned Baseline</div> <div class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ __('baseline-compare.stat_assigned_baseline') }}</div>
<div class="text-lg font-semibold text-gray-950 dark:text-white">{{ $profileName ?? '—' }}</div> <div class="text-lg font-semibold text-gray-950 dark:text-white">{{ $profileName ?? '—' }}</div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
@if ($snapshotId) @if ($snapshotId)
<x-filament::badge color="success" size="sm" class="w-fit"> <x-filament::badge color="success" size="sm" class="w-fit">
Snapshot #{{ $snapshotId }} {{ __('baseline-compare.badge_snapshot', ['id' => $snapshotId]) }}
</x-filament::badge> </x-filament::badge>
@endif @endif
@ -52,26 +48,26 @@
size="sm" size="sm"
class="w-fit" class="w-fit"
> >
Coverage: {{ $coverageStatus === 'ok' ? 'OK' : 'Warnings' }} {{ $coverageStatus === 'ok' ? __('baseline-compare.badge_coverage_ok') : __('baseline-compare.badge_coverage_warnings') }}
</x-filament::badge> </x-filament::badge>
@endif @endif
@if (filled($fidelity)) @if (filled($fidelity))
<x-filament::badge color="gray" size="sm" class="w-fit"> <x-filament::badge color="gray" size="sm" class="w-fit">
Fidelity: {{ Str::title($fidelity) }} {{ __('baseline-compare.badge_fidelity', ['level' => Str::title($fidelity)]) }}
</x-filament::badge> </x-filament::badge>
@endif @endif
@if ($hasEvidenceGaps) @if ($hasEvidenceGaps)
<x-filament::badge color="warning" size="sm" class="w-fit" :title="$evidenceGapsTooltip"> <x-filament::badge color="warning" size="sm" class="w-fit" :title="$evidenceGapsTooltip">
Evidence gaps: {{ $evidenceGapsCountValue }} {{ __('baseline-compare.badge_evidence_gaps', ['count' => $evidenceGapsCountValue]) }}
</x-filament::badge> </x-filament::badge>
@endif @endif
</div> </div>
@if ($hasEvidenceGaps && filled($evidenceGapsSummary)) @if ($hasEvidenceGaps && filled($evidenceGapsSummary))
<div class="mt-1 text-xs text-warning-700 dark:text-warning-300"> <div class="mt-1 text-xs text-warning-700 dark:text-warning-300">
Top gaps: {{ $evidenceGapsSummary }} {{ __('baseline-compare.evidence_gaps_tooltip', ['summary' => $evidenceGapsSummary]) }}
</div> </div>
@endif @endif
</div> </div>
@ -80,25 +76,21 @@ class="w-fit"
{{-- Stat: Total Findings --}} {{-- Stat: Total Findings --}}
<x-filament::section> <x-filament::section>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">Total Findings</div> <div class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ __('baseline-compare.stat_total_findings') }}</div>
@if ($state === 'failed') @if ($state === 'failed')
<div class="text-lg font-semibold text-danger-600 dark:text-danger-400">Error</div> <div class="text-lg font-semibold text-danger-600 dark:text-danger-400">{{ __('baseline-compare.stat_error') }}</div>
@else @else
<div class="text-3xl font-bold {{ ($findingsCount ?? 0) > 0 ? 'text-danger-600 dark:text-danger-400' : ($hasWarnings ? 'text-warning-600 dark:text-warning-400' : 'text-success-600 dark:text-success-400') }}"> <div class="text-3xl font-bold {{ $findingsColorClass }}">
{{ $findingsCount ?? 0 }} {{ $findingsCount ?? 0 }}
</div> </div>
@endif @endif
@if ($state === 'comparing') @if ($state === 'comparing')
<div class="flex items-center gap-1 text-sm text-info-600 dark:text-info-400"> <div class="flex items-center gap-1 text-sm text-info-600 dark:text-info-400">
<x-filament::loading-indicator class="h-3 w-3" /> <x-filament::loading-indicator class="h-3 w-3" />
Comparing… {{ __('baseline-compare.comparing_indicator') }}
</div> </div>
@elseif (($findingsCount ?? 0) === 0 && $state === 'ready' && ! $hasWarnings) @elseif (($findingsCount ?? 0) === 0 && $state === 'ready')
<span class="text-sm text-success-600 dark:text-success-400">All clear</span> <span class="text-sm {{ $whyNoFindingsColor }}">{{ $whyNoFindingsMessage ?? $whyNoFindingsFallback }}</span>
@elseif ($state === 'ready' && $hasCoverageWarnings)
<span class="text-sm text-warning-600 dark:text-warning-400">Coverage warnings</span>
@elseif ($state === 'ready' && $hasEvidenceGaps)
<span class="text-sm text-warning-600 dark:text-warning-400">Evidence gaps</span>
@endif @endif
</div> </div>
</x-filament::section> </x-filament::section>
@ -106,13 +98,13 @@ class="w-fit"
{{-- Stat: Last Compared --}} {{-- Stat: Last Compared --}}
<x-filament::section> <x-filament::section>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">Last Compared</div> <div class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ __('baseline-compare.stat_last_compared') }}</div>
<div class="text-lg font-semibold text-gray-950 dark:text-white" @if ($lastComparedIso) title="{{ $lastComparedIso }}" @endif> <div class="text-lg font-semibold text-gray-950 dark:text-white" @if ($lastComparedIso) title="{{ $lastComparedIso }}" @endif>
{{ $lastComparedAt ?? 'Never' }} {{ $lastComparedAt ?? __('baseline-compare.stat_last_compared_never') }}
</div> </div>
@if ($this->getRunUrl()) @if ($this->getRunUrl())
<x-filament::link :href="$this->getRunUrl()" size="sm"> <x-filament::link :href="$this->getRunUrl()" size="sm">
View run {{ __('baseline-compare.button_view_run') }}
</x-filament::link> </x-filament::link>
@endif @endif
</div> </div>
@ -122,23 +114,28 @@ class="w-fit"
{{-- Coverage warnings banner --}} {{-- Coverage warnings banner --}}
@if ($state === 'ready' && $hasCoverageWarnings) @if ($state === 'ready' && $hasCoverageWarnings)
<div class="rounded-lg border border-warning-300 bg-warning-50 p-4 dark:border-warning-700 dark:bg-warning-950/40"> <div role="alert" class="rounded-lg border border-warning-300 bg-warning-50 p-4 dark:border-warning-700 dark:bg-warning-950/40">
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<x-heroicon-s-exclamation-triangle class="h-6 w-6 shrink-0 text-warning-600 dark:text-warning-400" /> <x-heroicon-s-exclamation-triangle class="h-6 w-6 shrink-0 text-warning-600 dark:text-warning-400" />
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<div class="text-base font-semibold text-warning-900 dark:text-warning-200"> <div class="text-base font-semibold text-warning-900 dark:text-warning-200">
Comparison completed with warnings {{ __('baseline-compare.coverage_warning_title') }}
</div> </div>
<div class="text-sm text-warning-800 dark:text-warning-300"> <div class="text-sm text-warning-800 dark:text-warning-300">
@if (($coverageStatus ?? null) === 'unproven') @if (($coverageStatus ?? null) === 'unproven')
Coverage proof was missing or unreadable for the last comparison run, so findings were suppressed for safety. {{ __('baseline-compare.coverage_unproven_body') }}
@else @else
Findings were skipped for {{ (int) ($uncoveredTypesCount ?? 0) }} policy {{ Str::plural('type', (int) ($uncoveredTypesCount ?? 0)) }} due to incomplete coverage. {{ __('baseline-compare.coverage_incomplete_body', [
'count' => (int) ($uncoveredTypesCount ?? 0),
'types' => Str::plural('type', (int) ($uncoveredTypesCount ?? 0)),
]) }}
@endif @endif
@if (! empty($uncoveredTypes)) @if (! empty($uncoveredTypes))
<div class="mt-2 text-xs text-warning-800 dark:text-warning-300"> <div class="mt-2 text-xs text-warning-800 dark:text-warning-300">
Uncovered: {{ implode(', ', array_slice($uncoveredTypes, 0, 6)) }}@if (count($uncoveredTypes) > 6)@endif {{ __('baseline-compare.coverage_uncovered_label', [
'list' => implode(', ', array_slice($uncoveredTypes, 0, 6)) . (count($uncoveredTypes) > 6 ? '…' : ''),
]) }}
</div> </div>
@endif @endif
</div> </div>
@ -152,7 +149,7 @@ class="w-fit"
icon="heroicon-o-queue-list" icon="heroicon-o-queue-list"
size="sm" size="sm"
> >
View run {{ __('baseline-compare.button_view_run') }}
</x-filament::button> </x-filament::button>
</div> </div>
@endif @endif
@ -163,15 +160,15 @@ class="w-fit"
{{-- Failed run banner --}} {{-- Failed run banner --}}
@if ($state === 'failed') @if ($state === 'failed')
<div class="rounded-lg border border-danger-300 bg-danger-50 p-4 dark:border-danger-700 dark:bg-danger-950/50"> <div role="alert" class="rounded-lg border border-danger-300 bg-danger-50 p-4 dark:border-danger-700 dark:bg-danger-950/50">
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<x-heroicon-s-x-circle class="h-6 w-6 shrink-0 text-danger-600 dark:text-danger-400" /> <x-heroicon-s-x-circle class="h-6 w-6 shrink-0 text-danger-600 dark:text-danger-400" />
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<div class="text-base font-semibold text-danger-800 dark:text-danger-200"> <div class="text-base font-semibold text-danger-800 dark:text-danger-200">
Comparison Failed {{ __('baseline-compare.failed_title') }}
</div> </div>
<div class="text-sm text-danger-700 dark:text-danger-300"> <div class="text-sm text-danger-700 dark:text-danger-300">
{{ $failureReason ?? 'The last baseline comparison failed. Review the run details or retry.' }} {{ $failureReason ?? __('baseline-compare.failed_body_default') }}
</div> </div>
<div class="mt-2 flex items-center gap-3"> <div class="mt-2 flex items-center gap-3">
@if ($this->getRunUrl()) @if ($this->getRunUrl())
@ -183,7 +180,7 @@ class="w-fit"
icon="heroicon-o-queue-list" icon="heroicon-o-queue-list"
size="sm" size="sm"
> >
View failed run {{ __('baseline-compare.button_view_failed_run') }}
</x-filament::button> </x-filament::button>
@endif @endif
</div> </div>
@ -194,16 +191,19 @@ class="w-fit"
{{-- Critical drift banner --}} {{-- Critical drift banner --}}
@if ($state === 'ready' && ($severityCounts['high'] ?? 0) > 0) @if ($state === 'ready' && ($severityCounts['high'] ?? 0) > 0)
<div class="rounded-lg border border-danger-300 bg-danger-50 p-4 dark:border-danger-700 dark:bg-danger-950/50"> <div role="alert" class="rounded-lg border border-danger-300 bg-danger-50 p-4 dark:border-danger-700 dark:bg-danger-950/50">
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<x-heroicon-s-exclamation-triangle class="h-6 w-6 shrink-0 text-danger-600 dark:text-danger-400" /> <x-heroicon-s-exclamation-triangle class="h-6 w-6 shrink-0 text-danger-600 dark:text-danger-400" />
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<div class="text-base font-semibold text-danger-800 dark:text-danger-200"> <div class="text-base font-semibold text-danger-800 dark:text-danger-200">
Critical Drift Detected {{ __('baseline-compare.critical_drift_title') }}
</div> </div>
<div class="text-sm text-danger-700 dark:text-danger-300"> <div class="text-sm text-danger-700 dark:text-danger-300">
The current tenant state deviates from baseline <strong>{{ $profileName }}</strong>. {{ __('baseline-compare.critical_drift_body', [
{{ $severityCounts['high'] }} high-severity {{ Str::plural('finding', $severityCounts['high']) }} require immediate attention. 'profile' => $profileName,
'count' => $severityCounts['high'],
'findings' => Str::plural('finding', $severityCounts['high']),
]) }}
</div> </div>
</div> </div>
</div> </div>
@ -216,13 +216,13 @@ class="w-fit"
<div class="flex flex-col items-center justify-center gap-3 py-8 text-center"> <div class="flex flex-col items-center justify-center gap-3 py-8 text-center">
@if ($state === 'no_tenant') @if ($state === 'no_tenant')
<x-heroicon-o-building-office class="h-12 w-12 text-gray-400 dark:text-gray-500" /> <x-heroicon-o-building-office class="h-12 w-12 text-gray-400 dark:text-gray-500" />
<div class="text-lg font-semibold text-gray-950 dark:text-white">No Tenant Selected</div> <div class="text-lg font-semibold text-gray-950 dark:text-white">{{ __('baseline-compare.empty_no_tenant') }}</div>
@elseif ($state === 'no_assignment') @elseif ($state === 'no_assignment')
<x-heroicon-o-link-slash class="h-12 w-12 text-gray-400 dark:text-gray-500" /> <x-heroicon-o-link-slash class="h-12 w-12 text-gray-400 dark:text-gray-500" />
<div class="text-lg font-semibold text-gray-950 dark:text-white">No Baseline Assigned</div> <div class="text-lg font-semibold text-gray-950 dark:text-white">{{ __('baseline-compare.empty_no_assignment') }}</div>
@elseif ($state === 'no_snapshot') @elseif ($state === 'no_snapshot')
<x-heroicon-o-camera class="h-12 w-12 text-warning-400 dark:text-warning-500" /> <x-heroicon-o-camera class="h-12 w-12 text-warning-400 dark:text-warning-500" />
<div class="text-lg font-semibold text-gray-950 dark:text-white">No Snapshot Available</div> <div class="text-lg font-semibold text-gray-950 dark:text-white">{{ __('baseline-compare.empty_no_snapshot') }}</div>
@endif @endif
<div class="max-w-md text-sm text-gray-500 dark:text-gray-400">{{ $message }}</div> <div class="max-w-md text-sm text-gray-500 dark:text-gray-400">{{ $message }}</div>
</div> </div>
@ -236,7 +236,7 @@ class="w-fit"
{{ $findingsCount }} {{ Str::plural('Finding', $findingsCount) }} {{ $findingsCount }} {{ Str::plural('Finding', $findingsCount) }}
</x-slot> </x-slot>
<x-slot name="description"> <x-slot name="description">
The tenant configuration drifted from the baseline profile. {{ __('baseline-compare.findings_description') }}
</x-slot> </x-slot>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
@ -269,7 +269,7 @@ class="w-fit"
icon="heroicon-o-eye" icon="heroicon-o-eye"
size="sm" size="sm"
> >
View all findings {{ __('baseline-compare.button_view_findings') }}
</x-filament::button> </x-filament::button>
@endif @endif
@ -282,7 +282,7 @@ class="w-fit"
icon="heroicon-o-queue-list" icon="heroicon-o-queue-list"
size="sm" size="sm"
> >
Review last run {{ __('baseline-compare.button_review_last_run') }}
</x-filament::button> </x-filament::button>
@endif @endif
</div> </div>
@ -295,9 +295,9 @@ class="w-fit"
<x-filament::section> <x-filament::section>
<div class="flex flex-col items-center justify-center gap-3 py-6 text-center"> <div class="flex flex-col items-center justify-center gap-3 py-6 text-center">
<x-heroicon-o-check-circle class="h-12 w-12 text-success-500" /> <x-heroicon-o-check-circle class="h-12 w-12 text-success-500" />
<div class="text-lg font-semibold text-gray-950 dark:text-white">No Drift Detected</div> <div class="text-lg font-semibold text-gray-950 dark:text-white">{{ __('baseline-compare.no_drift_title') }}</div>
<div class="max-w-md text-sm text-gray-500 dark:text-gray-400"> <div class="max-w-md text-sm text-gray-500 dark:text-gray-400">
The tenant configuration matches the baseline profile. Everything looks good. {{ __('baseline-compare.no_drift_body') }}
</div> </div>
@if ($this->getRunUrl()) @if ($this->getRunUrl())
<x-filament::button <x-filament::button
@ -308,7 +308,7 @@ class="w-fit"
icon="heroicon-o-queue-list" icon="heroicon-o-queue-list"
size="sm" size="sm"
> >
Review last run {{ __('baseline-compare.button_review_last_run') }}
</x-filament::button> </x-filament::button>
@endif @endif
</div> </div>
@ -320,9 +320,9 @@ class="w-fit"
<x-filament::section> <x-filament::section>
<div class="flex flex-col items-center justify-center gap-3 py-6 text-center"> <div class="flex flex-col items-center justify-center gap-3 py-6 text-center">
<x-heroicon-o-exclamation-triangle class="h-12 w-12 text-warning-500" /> <x-heroicon-o-exclamation-triangle class="h-12 w-12 text-warning-500" />
<div class="text-lg font-semibold text-gray-950 dark:text-white">Coverage Warnings</div> <div class="text-lg font-semibold text-gray-950 dark:text-white">{{ __('baseline-compare.coverage_warnings_title') }}</div>
<div class="max-w-md text-sm text-gray-500 dark:text-gray-400"> <div class="max-w-md text-sm text-gray-500 dark:text-gray-400">
The last comparison completed with warnings and produced no drift findings. Run Inventory Sync again to establish full coverage before interpreting results. {{ __('baseline-compare.coverage_warnings_body') }}
</div> </div>
@if ($this->getRunUrl()) @if ($this->getRunUrl())
<x-filament::button <x-filament::button
@ -333,7 +333,7 @@ class="w-fit"
icon="heroicon-o-queue-list" icon="heroicon-o-queue-list"
size="sm" size="sm"
> >
Review last run {{ __('baseline-compare.button_review_last_run') }}
</x-filament::button> </x-filament::button>
@endif @endif
</div> </div>
@ -345,7 +345,7 @@ class="w-fit"
<x-filament::section> <x-filament::section>
<div class="flex flex-col items-center justify-center gap-3 py-6 text-center"> <div class="flex flex-col items-center justify-center gap-3 py-6 text-center">
<x-heroicon-o-play class="h-12 w-12 text-gray-400 dark:text-gray-500" /> <x-heroicon-o-play class="h-12 w-12 text-gray-400 dark:text-gray-500" />
<div class="text-lg font-semibold text-gray-950 dark:text-white">Ready to Compare</div> <div class="text-lg font-semibold text-gray-950 dark:text-white">{{ __('baseline-compare.idle_title') }}</div>
<div class="max-w-md text-sm text-gray-500 dark:text-gray-400"> <div class="max-w-md text-sm text-gray-500 dark:text-gray-400">
{{ $message }} {{ $message }}
</div> </div>

View File

@ -39,6 +39,11 @@
->name('tenantpilot:review-pack:prune') ->name('tenantpilot:review-pack:prune')
->withoutOverlapping(); ->withoutOverlapping();
Schedule::command('tenantpilot:baseline-evidence:prune')
->daily()
->name('tenantpilot:baseline-evidence:prune')
->withoutOverlapping();
Schedule::call(function (): void { Schedule::call(function (): void {
$tenants = Tenant::query() $tenants = Tenant::query()
->whereHas('providerConnections', function ($q): void { ->whereHas('providerConnections', function ($q): void {

View File

@ -146,18 +146,18 @@ ## Phase 5: User Story 3 — Throttling-safe, resumable evidence capture (Priori
### Tests (write first) ### Tests (write first)
- [ ] T058 [P] [US3] Add “budget exhaustion produces resume token” test in `tests/Feature/Baselines/BaselineCompareResumeTokenTest.php` - [X] T058 [P] [US3] Add “budget exhaustion produces resume token” test in `tests/Feature/Baselines/BaselineCompareResumeTokenTest.php`
- [ ] T059 [P] [US3] Add “resume is idempotent” test in `tests/Feature/Baselines/BaselineCompareResumeIdempotencyTest.php` - [X] T059 [P] [US3] Add “resume is idempotent” test in `tests/Feature/Baselines/BaselineCompareResumeIdempotencyTest.php`
- [ ] T060 [P] [US3] Add resume token contract test in `tests/Feature/Baselines/BaselineEvidenceResumeTokenContractTest.php` (token is opaque; decode yields deterministic resume state) - [X] T060 [P] [US3] Add resume token contract test in `tests/Feature/Baselines/BaselineEvidenceResumeTokenContractTest.php` (token is opaque; decode yields deterministic resume state)
- [ ] T061 [P] [US3] Add run-detail resume action test in `tests/Feature/Filament/OperationRunResumeCaptureActionTest.php` - [X] T061 [P] [US3] Add run-detail resume action test in `tests/Feature/Filament/OperationRunResumeCaptureActionTest.php`
- [ ] T062 [P] [US3] Add audit event coverage for resume capture in `tests/Feature/Baselines/BaselineResumeCaptureAuditEventsTest.php` - [X] T062 [P] [US3] Add audit event coverage for resume capture in `tests/Feature/Baselines/BaselineResumeCaptureAuditEventsTest.php`
### Implementation ### Implementation
- [ ] T063 [US3] Implement budgets (items-per-run + concurrency + retries) + retry/backoff/jitter + throttling gap reasons + resume cursor handling in `app/Services/Baselines/BaselineContentCapturePhase.php` (use `BaselineEvidenceResumeToken` encode/decode) - [X] T063 [US3] Implement budgets (items-per-run + concurrency + retries) + retry/backoff/jitter + throttling gap reasons + resume cursor handling in `app/Services/Baselines/BaselineContentCapturePhase.php` (use `BaselineEvidenceResumeToken` encode/decode)
- [ ] T064 [US3] Add resume starter service in `app/Services/Baselines/BaselineEvidenceCaptureResumeService.php` (start follow-up `baseline_capture`/`baseline_compare` runs from a prior run + resume token; enforce RBAC; write audit events) - [X] T064 [US3] Add resume starter service in `app/Services/Baselines/BaselineEvidenceCaptureResumeService.php` (start follow-up `baseline_capture`/`baseline_compare` runs from a prior run + resume token; enforce RBAC; write audit events)
- [ ] T065 [US3] Add “Resume capture” header action for eligible runs in `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` (requires confirmation; uses Ops-UX queued toast + canonical view-run link) - [X] T065 [US3] Add “Resume capture” header action for eligible runs in `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` (requires confirmation; uses Ops-UX queued toast + canonical view-run link)
- [ ] T066 [US3] Wire resume token consumption + re-emission into `app/Jobs/CaptureBaselineSnapshotJob.php` (baseline capture) and `app/Jobs/CompareBaselineToTenantJob.php` (baseline compare) - [X] T066 [US3] Wire resume token consumption + re-emission into `app/Jobs/CaptureBaselineSnapshotJob.php` (baseline capture) and `app/Jobs/CompareBaselineToTenantJob.php` (baseline compare)
**Parallel execution example (US3)**: **Parallel execution example (US3)**:
@ -176,15 +176,15 @@ ## Phase 6: User Story 4 — “Why no findings?” is always clear (Priority: P
### Tests (write first) ### Tests (write first)
- [ ] T067 [P] [US4] Add reason-code coverage test for zero-subject / zero-findings / suppressed-by-coverage outcomes in `tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php` - [X] T067 [P] [US4] Add reason-code coverage test for zero-subject / zero-findings / suppressed-by-coverage outcomes in `tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php`
- [ ] T068 [P] [US4] Add UI assertion test for “why no findings” messaging in `tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php` - [X] T068 [P] [US4] Add UI assertion test for “why no findings” messaging in `tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php`
### Implementation ### Implementation
- [ ] T069 [US4] Populate `context.baseline_compare.reason_code` for all 0-subject / 0-findings outcomes in `app/Jobs/CompareBaselineToTenantJob.php` (use `BaselineCompareReasonCode`, including `coverage_unproven`/`rollout_disabled` where applicable) - [X] T069 [US4] Populate `context.baseline_compare.reason_code` for all 0-subject / 0-findings outcomes in `app/Jobs/CompareBaselineToTenantJob.php` (use `BaselineCompareReasonCode`, including `coverage_unproven`/`rollout_disabled` where applicable)
- [ ] T070 [US4] Render reason-code explanation + evidence context in Monitoring run detail in `app/Filament/Resources/OperationRunResource.php` - [X] T070 [US4] Render reason-code explanation + evidence context in Monitoring run detail in `app/Filament/Resources/OperationRunResource.php`
- [ ] T071 [US4] Replace “All clear” copy with reason-aware messaging on baseline compare landing in `resources/views/filament/pages/baseline-compare-landing.blade.php` (source reason code from `BaselineCompareStats`) - [X] T071 [US4] Replace “All clear” copy with reason-aware messaging on baseline compare landing in `resources/views/filament/pages/baseline-compare-landing.blade.php` (source reason code from `BaselineCompareStats`)
- [ ] T072 [US4] Propagate reason code + human message from run context in `app/Support/Baselines/BaselineCompareStats.php` - [X] T072 [US4] Propagate reason code + human message from run context in `app/Support/Baselines/BaselineCompareStats.php`
**Parallel execution example (US4)**: **Parallel execution example (US4)**:
@ -199,13 +199,13 @@ ## Phase 7: Polish & Cross-Cutting Concerns
**Purpose**: Guardrails, visibility, and validation across all stories. **Purpose**: Guardrails, visibility, and validation across all stories.
- [ ] T073 [P] Add Spec 118 no-legacy regression guard(s) in `tests/Feature/Guards/Spec118NoLegacyBaselineDriftGuardTest.php` (assert capture/compare do not implement hashing outside the provider/hasher pipeline and do not reference deprecated helpers) - [X] T073 [P] Add Spec 118 no-legacy regression guard(s) in `tests/Feature/Guards/Spec118NoLegacyBaselineDriftGuardTest.php` (assert capture/compare do not implement hashing outside the provider/hasher pipeline and do not reference deprecated helpers)
- [ ] T074 Update PolicyVersion listing to hide baseline-purpose evidence by default (unless the actor has `tenant.sync` or `tenant_findings.view`) in `app/Filament/Resources/PolicyVersionResource.php` - [X] T074 Update PolicyVersion listing to hide baseline-purpose evidence by default (unless the actor has `tenant.sync` or `tenant_findings.view`) in `app/Filament/Resources/PolicyVersionResource.php`
- [ ] T075 [P] Add visibility/authorization coverage for baseline-purpose PolicyVersions in `tests/Feature/Filament/PolicyVersionBaselineEvidenceVisibilityTest.php` (assert baseline-purpose rows are hidden for `tenant.view`-only actors) - [X] T075 [P] Add visibility/authorization coverage for baseline-purpose PolicyVersions in `tests/Feature/Filament/PolicyVersionBaselineEvidenceVisibilityTest.php` (assert baseline-purpose rows are hidden for `tenant.view`-only actors)
- [ ] T076 Implement baseline-purpose PolicyVersion retention enforcement in `app/Console/Commands/PruneBaselineEvidencePolicyVersionsCommand.php` and schedule it in `routes/console.php` (prune `baseline_capture`/`baseline_compare` older than configured retention; do not prune `backup`) + tests in `tests/Feature/Retention/PruneBaselineEvidencePolicyVersionsTest.php` and `tests/Feature/Scheduling/PruneBaselineEvidencePolicyVersionsScheduleTest.php` - [X] T076 Implement baseline-purpose PolicyVersion retention enforcement in `app/Console/Commands/PruneBaselineEvidencePolicyVersionsCommand.php` and schedule it in `routes/console.php` (prune `baseline_capture`/`baseline_compare` older than configured retention; do not prune `backup`) + tests in `tests/Feature/Retention/PruneBaselineEvidencePolicyVersionsTest.php` and `tests/Feature/Scheduling/PruneBaselineEvidencePolicyVersionsScheduleTest.php`
- [ ] T077 Add Baseline Snapshot list/detail surfaces with fidelity visibility in `app/Filament/Resources/BaselineSnapshotResource.php`, `app/Filament/Resources/BaselineSnapshotResource/Pages/ListBaselineSnapshots.php`, and `app/Filament/Resources/BaselineSnapshotResource/Pages/ViewBaselineSnapshot.php` (badge + counts by fidelity; “captured with gaps” state) + tests in `tests/Feature/Filament/BaselineSnapshotFidelityVisibilityTest.php` - [X] T077 Add Baseline Snapshot list/detail surfaces with fidelity visibility in `app/Filament/Resources/BaselineSnapshotResource.php`, `app/Filament/Resources/BaselineSnapshotResource/Pages/ListBaselineSnapshots.php`, and `app/Filament/Resources/BaselineSnapshotResource/Pages/ViewBaselineSnapshot.php` (badge + counts by fidelity; “captured with gaps” state) + tests in `tests/Feature/Filament/BaselineSnapshotFidelityVisibilityTest.php`
- [ ] T078 Run formatting on changed files using `vendor/bin/sail bin pint --dirty --format agent` (touchpoints include `app/Jobs/CaptureBaselineSnapshotJob.php`, `app/Jobs/CompareBaselineToTenantJob.php`, `app/Services/Baselines/BaselineContentCapturePhase.php`) - [X] T078 Run formatting on changed files using `vendor/bin/sail bin pint --dirty --format agent` (touchpoints include `app/Jobs/CaptureBaselineSnapshotJob.php`, `app/Jobs/CompareBaselineToTenantJob.php`, `app/Services/Baselines/BaselineContentCapturePhase.php`)
- [ ] T079 Run targeted test suite from `specs/118-baseline-drift-engine/quickstart.md` and update it if any step is inaccurate in `specs/118-baseline-drift-engine/quickstart.md` - [X] T079 Run targeted test suite from `specs/118-baseline-drift-engine/quickstart.md` and update it if any step is inaccurate in `specs/118-baseline-drift-engine/quickstart.md`
--- ---

View File

@ -0,0 +1,104 @@
<?php
use App\Jobs\CaptureBaselineSnapshotJob;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineSnapshotItem;
use App\Models\InventoryItem;
use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Baselines\InventoryMetaContract;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Support\Baselines\BaselineSubjectKey;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunType;
it('treats duplicate subject_key matches as an evidence gap and captures remaining subjects', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]);
$displayName = 'Duplicate Policy';
InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'external_id' => 'dup-1',
'policy_type' => 'deviceConfiguration',
'display_name' => $displayName,
'meta_jsonb' => ['etag' => 'E1'],
]);
InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'external_id' => 'dup-2',
'policy_type' => 'deviceConfiguration',
'display_name' => $displayName,
'meta_jsonb' => ['etag' => 'E2'],
]);
InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'external_id' => 'unique-1',
'policy_type' => 'deviceConfiguration',
'display_name' => 'Unique Policy',
'meta_jsonb' => ['etag' => 'E_UNIQUE'],
]);
$opService = app(OperationRunService::class);
$run = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: OperationRunType::BaselineCapture->value,
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'source_tenant_id' => (int) $tenant->getKey(),
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
],
initiator: $user,
);
(new CaptureBaselineSnapshotJob($run))->handle(
app(BaselineSnapshotIdentity::class),
app(InventoryMetaContract::class),
app(AuditLogger::class),
$opService,
);
$run->refresh();
expect($run->status)->toBe('completed');
expect($run->outcome)->toBe(OperationRunOutcome::PartiallySucceeded->value);
$counts = is_array($run->summary_counts) ? $run->summary_counts : [];
expect((int) ($counts['total'] ?? 0))->toBe(1);
expect((int) ($counts['succeeded'] ?? 0))->toBe(1);
$context = is_array($run->context) ? $run->context : [];
expect(data_get($context, 'baseline_capture.gaps.by_reason.ambiguous_match'))->toBe(1);
$snapshot = BaselineSnapshot::query()
->where('baseline_profile_id', (int) $profile->getKey())
->sole();
expect(
BaselineSnapshotItem::query()
->where('baseline_snapshot_id', (int) $snapshot->getKey())
->count(),
)->toBe(1);
$subjectKey = BaselineSubjectKey::fromDisplayName('Unique Policy');
expect($subjectKey)->not->toBeNull();
$workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId(
policyType: 'deviceConfiguration',
subjectKey: (string) $subjectKey,
);
BaselineSnapshotItem::query()
->where('baseline_snapshot_id', (int) $snapshot->getKey())
->where('subject_external_id', $workspaceSafeExternalId)
->sole();
});

View File

@ -0,0 +1,199 @@
<?php
use App\Jobs\CompareBaselineToTenantJob;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineSnapshotItem;
use App\Models\InventoryItem;
use App\Models\Policy;
use App\Services\Baselines\BaselineContentCapturePhase;
use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Intune\AuditLogger;
use App\Services\Intune\PolicyCaptureOrchestrator;
use App\Services\OperationRunService;
use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Baselines\BaselineSubjectKey;
use App\Support\Baselines\PolicyVersionCapturePurpose;
use App\Support\OperationRunType;
it('resumes full-content compare deterministically without re-capturing already-captured subjects', function (): void {
config()->set('tenantpilot.baselines.full_content_capture.enabled', true);
config()->set('tenantpilot.baselines.full_content_capture.max_items_per_run', 1);
config()->set('tenantpilot.baselines.full_content_capture.max_concurrency', 1);
config()->set('tenantpilot.baselines.full_content_capture.max_retries', 0);
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
'capture_mode' => BaselineCaptureMode::FullContent->value,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
'captured_at' => now()->subMinute(),
]);
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
$displayNames = ['Resume Idempotent A', 'Resume Idempotent B'];
foreach ($displayNames as $displayName) {
$subjectKey = BaselineSubjectKey::fromDisplayName($displayName);
expect($subjectKey)->not->toBeNull();
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $subjectKey),
'subject_key' => (string) $subjectKey,
'policy_type' => 'deviceConfiguration',
'baseline_hash' => hash('sha256', 'baseline'),
'meta_jsonb' => [
'display_name' => $displayName,
'evidence' => [
'fidelity' => 'content',
'source' => 'policy_version',
'observed_at' => now()->toIso8601String(),
],
],
]);
}
$policies = [];
foreach ($displayNames as $i => $displayName) {
$policies[] = Policy::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'policy_type' => 'deviceConfiguration',
'external_id' => $i === 0 ? 'resume-idem-a' : 'resume-idem-b',
'platform' => 'windows',
'display_name' => $displayName,
]);
}
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
tenant: $tenant,
statusByType: ['deviceConfiguration' => 'succeeded'],
);
foreach ($policies as $policy) {
InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'external_id' => (string) $policy->external_id,
'policy_type' => (string) $policy->policy_type,
'display_name' => (string) $policy->display_name,
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => fake()->uuid()],
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]);
}
$fakeOrchestrator = new class extends PolicyCaptureOrchestrator
{
public array $capturedExternalIds = [];
public function __construct() {}
public function capture(
Policy $policy,
\App\Models\Tenant $tenant,
bool $includeAssignments = false,
bool $includeScopeTags = false,
?string $createdBy = null,
array $metadata = [],
PolicyVersionCapturePurpose $capturePurpose = PolicyVersionCapturePurpose::Backup,
?int $operationRunId = null,
?int $baselineProfileId = null,
): array {
$this->capturedExternalIds[] = (string) $policy->external_id;
$version = \App\Models\PolicyVersion::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'policy_id' => (int) $policy->getKey(),
'policy_type' => (string) $policy->policy_type,
'platform' => (string) $policy->platform,
'captured_at' => now(),
'snapshot' => ['settings' => [['key' => 'k', 'value' => 1]]],
'assignments' => [],
'scope_tags' => [],
'capture_purpose' => $capturePurpose,
'operation_run_id' => $operationRunId,
'baseline_profile_id' => $baselineProfileId,
]);
return [
'version' => $version,
'captured' => [
'payload' => $version->snapshot,
'assignments' => [],
'scope_tags' => [],
],
];
}
};
$contentCapturePhase = new BaselineContentCapturePhase($fakeOrchestrator);
$opService = app(OperationRunService::class);
$firstRun = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: OperationRunType::BaselineCompare->value,
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
'capture_mode' => BaselineCaptureMode::FullContent->value,
],
initiator: $user,
);
(new CompareBaselineToTenantJob($firstRun))->handle(
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$opService,
contentCapturePhase: $contentCapturePhase,
);
$firstRun->refresh();
$firstContext = is_array($firstRun->context) ? $firstRun->context : [];
$resumeToken = $firstContext['baseline_compare']['resume_token'] ?? null;
expect($resumeToken)->toBeString();
expect($fakeOrchestrator->capturedExternalIds)->toHaveCount(1);
expect($fakeOrchestrator->capturedExternalIds[0])->toBe('resume-idem-a');
$secondRun = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: OperationRunType::BaselineCompare->value,
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
'capture_mode' => BaselineCaptureMode::FullContent->value,
'baseline_compare' => [
'resume_token' => (string) $resumeToken,
],
],
initiator: $user,
);
(new CompareBaselineToTenantJob($secondRun))->handle(
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$opService,
contentCapturePhase: $contentCapturePhase,
);
$secondRun->refresh();
$secondContext = is_array($secondRun->context) ? $secondRun->context : [];
expect($secondContext['baseline_compare']['resume_token'] ?? null)->toBeNull();
expect($fakeOrchestrator->capturedExternalIds)->toBe(['resume-idem-a', 'resume-idem-b']);
});

View File

@ -0,0 +1,172 @@
<?php
use App\Jobs\CompareBaselineToTenantJob;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineSnapshotItem;
use App\Models\InventoryItem;
use App\Models\Policy;
use App\Services\Baselines\BaselineContentCapturePhase;
use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Intune\AuditLogger;
use App\Services\Intune\PolicyCaptureOrchestrator;
use App\Services\OperationRunService;
use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Baselines\BaselineEvidenceResumeToken;
use App\Support\Baselines\BaselineSubjectKey;
use App\Support\Baselines\PolicyVersionCapturePurpose;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunType;
it('records a resume token when full-content compare cannot capture all subjects within budget', function (): void {
config()->set('tenantpilot.baselines.full_content_capture.enabled', true);
config()->set('tenantpilot.baselines.full_content_capture.max_items_per_run', 1);
config()->set('tenantpilot.baselines.full_content_capture.max_concurrency', 1);
config()->set('tenantpilot.baselines.full_content_capture.max_retries', 0);
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
'capture_mode' => BaselineCaptureMode::FullContent->value,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
'captured_at' => now()->subMinute(),
]);
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
$displayNames = ['Resume Token A', 'Resume Token B'];
foreach ($displayNames as $displayName) {
$subjectKey = BaselineSubjectKey::fromDisplayName($displayName);
expect($subjectKey)->not->toBeNull();
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $subjectKey),
'subject_key' => (string) $subjectKey,
'policy_type' => 'deviceConfiguration',
'baseline_hash' => hash('sha256', 'baseline'),
'meta_jsonb' => [
'display_name' => $displayName,
'evidence' => [
'fidelity' => 'content',
'source' => 'policy_version',
'observed_at' => now()->toIso8601String(),
],
],
]);
}
$policies = [];
foreach ($displayNames as $i => $displayName) {
$policies[] = Policy::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'policy_type' => 'deviceConfiguration',
'external_id' => $i === 0 ? 'resume-token-a' : 'resume-token-b',
'platform' => 'windows',
'display_name' => $displayName,
]);
}
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
tenant: $tenant,
statusByType: ['deviceConfiguration' => 'succeeded'],
);
foreach ($policies as $policy) {
InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'external_id' => (string) $policy->external_id,
'policy_type' => (string) $policy->policy_type,
'display_name' => (string) $policy->display_name,
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => fake()->uuid()],
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]);
}
$fakeOrchestrator = new class extends PolicyCaptureOrchestrator
{
public function __construct() {}
public function capture(
Policy $policy,
\App\Models\Tenant $tenant,
bool $includeAssignments = false,
bool $includeScopeTags = false,
?string $createdBy = null,
array $metadata = [],
PolicyVersionCapturePurpose $capturePurpose = PolicyVersionCapturePurpose::Backup,
?int $operationRunId = null,
?int $baselineProfileId = null,
): array {
$version = \App\Models\PolicyVersion::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'policy_id' => (int) $policy->getKey(),
'policy_type' => (string) $policy->policy_type,
'platform' => (string) $policy->platform,
'captured_at' => now(),
'snapshot' => ['settings' => [['key' => 'k', 'value' => 1]]],
'assignments' => [],
'scope_tags' => [],
'capture_purpose' => $capturePurpose,
'operation_run_id' => $operationRunId,
'baseline_profile_id' => $baselineProfileId,
]);
return [
'version' => $version,
'captured' => [
'payload' => $version->snapshot,
'assignments' => [],
'scope_tags' => [],
],
];
}
};
$contentCapturePhase = new BaselineContentCapturePhase($fakeOrchestrator);
$opService = app(OperationRunService::class);
$run = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: OperationRunType::BaselineCompare->value,
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
'capture_mode' => BaselineCaptureMode::FullContent->value,
],
initiator: $user,
);
(new CompareBaselineToTenantJob($run))->handle(
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$opService,
contentCapturePhase: $contentCapturePhase,
);
$run->refresh();
expect($run->outcome)->toBe(OperationRunOutcome::PartiallySucceeded->value);
$context = is_array($run->context) ? $run->context : [];
$token = $context['baseline_compare']['resume_token'] ?? null;
expect($token)->toBeString();
$state = BaselineEvidenceResumeToken::decode((string) $token);
expect($state)->toBeArray();
expect($state)->toHaveKey('offset');
expect($state['offset'])->toBe(1);
});

View File

@ -0,0 +1,242 @@
<?php
use App\Jobs\CompareBaselineToTenantJob;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineSnapshotItem;
use App\Models\InventoryItem;
use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Support\Baselines\BaselineCompareReasonCode;
use App\Support\Baselines\BaselineSubjectKey;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
it('records no_subjects_in_scope when the resolved subject list is empty', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
'scope_jsonb' => [
'policy_types' => ['deviceConfiguration'],
'foundation_types' => [],
],
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
'captured_at' => now()->subMinute(),
]);
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
$inventorySyncRun = \App\Models\OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => OperationRunType::InventorySync->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'completed_at' => now(),
'context' => [
'inventory' => [
'coverage' => [
'policy_types' => [
'deviceConfiguration' => ['status' => 'succeeded'],
],
'foundation_types' => [],
],
],
],
]);
$opService = app(OperationRunService::class);
$compareRun = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: OperationRunType::BaselineCompare->value,
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'effective_scope' => [
'policy_types' => ['deviceConfiguration'],
'foundation_types' => [],
],
],
initiator: $user,
);
(new CompareBaselineToTenantJob($compareRun))->handle(
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$opService,
);
$compareRun->refresh();
expect(data_get($compareRun->context, 'baseline_compare.subjects_total'))->toBe(0);
expect(data_get($compareRun->context, 'baseline_compare.reason_code'))->toBe(BaselineCompareReasonCode::NoSubjectsInScope->value);
});
it('records no_drift_detected when subjects are processed but no drift findings are produced', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
'scope_jsonb' => [
'policy_types' => ['deviceConfiguration'],
'foundation_types' => [],
],
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
'captured_at' => now()->subMinute(),
]);
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
$externalId = 'policy-uuid';
$displayName = 'Stable Policy';
$subjectKey = BaselineSubjectKey::fromDisplayName($displayName);
expect($subjectKey)->not->toBeNull();
$workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId(
policyType: 'deviceConfiguration',
subjectKey: (string) $subjectKey,
);
$metaJsonb = [
'odata_type' => '#microsoft.graph.deviceConfiguration',
'etag' => 'E_STABLE',
];
$baselineHash = app(BaselineSnapshotIdentity::class)->hashItemContent(
policyType: 'deviceConfiguration',
subjectExternalId: $externalId,
metaJsonb: $metaJsonb,
);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => $workspaceSafeExternalId,
'subject_key' => (string) $subjectKey,
'policy_type' => 'deviceConfiguration',
'baseline_hash' => $baselineHash,
'meta_jsonb' => [
'display_name' => $displayName,
'evidence' => [
'fidelity' => 'meta',
'source' => 'inventory',
'observed_at' => now()->toIso8601String(),
],
],
]);
$inventorySyncRun = \App\Models\OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => OperationRunType::InventorySync->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'completed_at' => now(),
'context' => [
'inventory' => [
'coverage' => [
'policy_types' => [
'deviceConfiguration' => ['status' => 'succeeded'],
],
'foundation_types' => [],
],
],
],
]);
InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'external_id' => $externalId,
'policy_type' => 'deviceConfiguration',
'display_name' => $displayName,
'meta_jsonb' => $metaJsonb,
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]);
$opService = app(OperationRunService::class);
$compareRun = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: OperationRunType::BaselineCompare->value,
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'effective_scope' => [
'policy_types' => ['deviceConfiguration'],
'foundation_types' => [],
],
],
initiator: $user,
);
(new CompareBaselineToTenantJob($compareRun))->handle(
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$opService,
);
$compareRun->refresh();
expect(data_get($compareRun->context, 'baseline_compare.subjects_total'))->toBe(1);
expect(data_get($compareRun->context, 'result.findings_total'))->toBe(0);
expect(data_get($compareRun->context, 'baseline_compare.reason_code'))->toBe(BaselineCompareReasonCode::NoDriftDetected->value);
});
it('records coverage_unproven when findings are suppressed due to missing coverage proof', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
'scope_jsonb' => [
'policy_types' => ['deviceConfiguration'],
'foundation_types' => [],
],
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
'captured_at' => now()->subMinute(),
]);
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
$opService = app(OperationRunService::class);
$compareRun = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: OperationRunType::BaselineCompare->value,
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'effective_scope' => [
'policy_types' => ['deviceConfiguration'],
'foundation_types' => [],
],
],
initiator: $user,
);
(new CompareBaselineToTenantJob($compareRun))->handle(
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$opService,
);
$compareRun->refresh();
expect($compareRun->outcome)->toBe(OperationRunOutcome::PartiallySucceeded->value);
expect(data_get($compareRun->context, 'baseline_compare.reason_code'))->toBe(BaselineCompareReasonCode::CoverageUnproven->value);
});

View File

@ -0,0 +1,31 @@
<?php
use App\Support\Baselines\BaselineEvidenceResumeToken;
it('encodes and decodes resume token state deterministically', function (): void {
$state = [
'offset' => 12,
'note' => 'opaque to callers',
];
$token = BaselineEvidenceResumeToken::encode($state);
expect($token)->toBeString();
expect($token)->not->toContain('+');
expect($token)->not->toContain('/');
expect($token)->not->toContain('=');
$decoded = BaselineEvidenceResumeToken::decode($token);
expect($decoded)->toBe($state);
});
it('returns null for invalid resume tokens', function (): void {
expect(BaselineEvidenceResumeToken::decode(''))->toBeNull();
expect(BaselineEvidenceResumeToken::decode('not-base64url'))->toBeNull();
$payload = json_encode(['v' => 999, 'state' => ['offset' => 1]], JSON_THROW_ON_ERROR);
$token = rtrim(strtr(base64_encode($payload), '+/', '-_'), '=');
expect(BaselineEvidenceResumeToken::decode($token))->toBeNull();
});

View File

@ -0,0 +1,60 @@
<?php
use App\Models\AuditLog;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\OperationRun;
use App\Services\Baselines\BaselineEvidenceCaptureResumeService;
use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Baselines\BaselineEvidenceResumeToken;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use Illuminate\Support\Facades\Queue;
it('writes an audit event when resuming evidence capture', function (): void {
Queue::fake();
config()->set('tenantpilot.baselines.full_content_capture.enabled', true);
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
'capture_mode' => BaselineCaptureMode::FullContent->value,
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
'captured_at' => now()->subMinute(),
]);
$run = OperationRun::factory()->for($tenant)->create([
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::PartiallySucceeded->value,
'user_id' => (int) $user->getKey(),
'context' => [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
'capture_mode' => BaselineCaptureMode::FullContent->value,
'baseline_compare' => [
'resume_token' => BaselineEvidenceResumeToken::encode(['offset' => 1]),
],
],
]);
$service = app(BaselineEvidenceCaptureResumeService::class);
$result = $service->resume($run, $user);
expect($result['ok'] ?? false)->toBeTrue();
$audit = AuditLog::query()
->where('tenant_id', (int) $tenant->getKey())
->where('action', 'baseline.evidence.resume.started')
->latest('id')
->first();
expect($audit)->not->toBeNull();
});

View File

@ -0,0 +1,58 @@
<?php
use App\Filament\Pages\BaselineCompareLanding;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineTenantAssignment;
use App\Models\InventoryItem;
use Filament\Facades\Filament;
use Livewire\Livewire;
it('shows a warning banner when duplicate policy names make baseline matching ambiguous', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
BaselineTenantAssignment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
$displayName = 'Duplicate Policy';
InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'external_id' => 'dup-1',
'policy_type' => 'deviceConfiguration',
'display_name' => $displayName,
]);
InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'external_id' => 'dup-2',
'policy_type' => 'deviceConfiguration',
'display_name' => $displayName,
]);
Livewire::test(BaselineCompareLanding::class)
->assertSee(__('baseline-compare.duplicate_warning_title'))
->assertSee('share the same display name')
->assertSee('cannot match them to the baseline');
});

View File

@ -0,0 +1,64 @@
<?php
use App\Filament\Pages\BaselineCompareLanding;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineTenantAssignment;
use App\Models\OperationRun;
use App\Support\Baselines\BaselineCompareReasonCode;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use Filament\Facades\Filament;
use Livewire\Livewire;
it('shows a clear why-no-findings explanation when the last run had zero drift', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
BaselineTenantAssignment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'completed_at' => now(),
'context' => [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'baseline_compare' => [
'reason_code' => BaselineCompareReasonCode::NoDriftDetected->value,
'coverage' => [
'effective_types' => ['deviceConfiguration'],
'covered_types' => ['deviceConfiguration'],
'uncovered_types' => [],
'proof' => true,
],
'fidelity' => 'meta',
],
],
]);
Livewire::test(BaselineCompareLanding::class)
->assertSee(BaselineCompareReasonCode::NoDriftDetected->message());
});

View File

@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\BaselineSnapshotResource;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('shows snapshot fidelity counts and gap state on list and view pages', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$complete = BaselineSnapshot::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
'captured_at' => now()->subHour(),
'summary_jsonb' => [
'total_items' => 5,
'fidelity_counts' => ['content' => 5, 'meta' => 0],
'gaps' => ['count' => 0, 'by_reason' => []],
],
]);
$withGaps = BaselineSnapshot::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
'captured_at' => now()->subMinutes(10),
'summary_jsonb' => [
'total_items' => 5,
'fidelity_counts' => ['content' => 3, 'meta' => 2],
'gaps' => ['count' => 2, 'by_reason' => ['meta_fallback' => 2]],
],
]);
$this->actingAs($user)
->get(BaselineSnapshotResource::getUrl(panel: 'admin'))
->assertOk()
->assertSee('Complete')
->assertSee('Captured with gaps')
->assertSee('Content 5, Meta 0')
->assertSee('Content 3, Meta 2');
$this->actingAs($user)
->get(BaselineSnapshotResource::getUrl('view', ['record' => $withGaps], panel: 'admin'))
->assertOk()
->assertSee('Captured with gaps')
->assertSee('Content 3, Meta 2')
->assertSee('Evidence gaps')
->assertSee('2');
$this->actingAs($user)
->get(BaselineSnapshotResource::getUrl('view', ['record' => $complete], panel: 'admin'))
->assertOk()
->assertSee('Complete')
->assertSee('Content 5, Meta 0')
->assertSee('Evidence gaps')
->assertSee('0');
});

View File

@ -0,0 +1,69 @@
<?php
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
use App\Jobs\CompareBaselineToTenantJob;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\OperationRun;
use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Baselines\BaselineEvidenceResumeToken;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
it('offers a Resume capture action on eligible baseline compare runs with a resume token', function (): void {
Queue::fake();
config()->set('tenantpilot.baselines.full_content_capture.enabled', true);
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
'capture_mode' => BaselineCaptureMode::FullContent->value,
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
'captured_at' => now()->subMinute(),
]);
$token = BaselineEvidenceResumeToken::encode(['offset' => 1]);
$run = OperationRun::factory()->for($tenant)->create([
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::PartiallySucceeded->value,
'user_id' => (int) $user->getKey(),
'context' => [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
'capture_mode' => BaselineCaptureMode::FullContent->value,
'baseline_compare' => [
'resume_token' => $token,
],
],
]);
Livewire::actingAs($user)
->test(TenantlessOperationRunViewer::class, ['run' => $run])
->assertActionVisible('resumeCapture')
->callAction('resumeCapture')
->assertStatus(200);
Queue::assertPushed(CompareBaselineToTenantJob::class);
$resumed = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', OperationRunType::BaselineCompare->value)
->where('status', OperationRunStatus::Queued->value)
->latest('id')
->first();
expect($resumed)->not->toBeNull();
$context = is_array($resumed?->context) ? $resumed->context : [];
expect($context['baseline_compare']['resume_token'] ?? null)->toBe($token);
});

View File

@ -26,7 +26,7 @@ function getPolicySyncHeaderAction(Testable $component, string $name): ?Action
return null; return null;
} }
it('shows sync only in empty state when policies table is empty', function (): void { it('shows sync in header and empty state when policies table is empty', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user); $this->actingAs($user);
@ -34,11 +34,11 @@ function getPolicySyncHeaderAction(Testable $component, string $name): ?Action
Filament::setTenant($tenant, true); Filament::setTenant($tenant, true);
$component = Livewire::test(ListPolicies::class) $component = Livewire::test(ListPolicies::class)
->assertTableEmptyStateActionsExistInOrder(['syncEmpty']); ->assertTableEmptyStateActionsExistInOrder(['sync']);
$headerSync = getPolicySyncHeaderAction($component, 'sync'); $headerSync = getPolicySyncHeaderAction($component, 'sync');
expect($headerSync)->not->toBeNull(); expect($headerSync)->not->toBeNull();
expect($headerSync?->isVisible())->toBeFalse(); expect($headerSync?->isVisible())->toBeTrue();
}); });
it('shows sync only in header when policies table is not empty', function (): void { it('shows sync only in header when policies table is not empty', function (): void {

View File

@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\PolicyVersionResource;
use App\Filament\Resources\PolicyVersionResource\Pages\ListPolicyVersions;
use App\Models\BaselineProfile;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\Baselines\PolicyVersionCapturePurpose;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('hides baseline-purpose policy versions for tenant.view-only actors', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$resolver = \Mockery::mock(CapabilityResolver::class);
$resolver->shouldReceive('isMember')->andReturnTrue();
$resolver->shouldReceive('primeMemberships')->andReturnNull();
$resolver->shouldReceive('can')
->andReturnUsing(static fn ($user, $tenant, string $capability): bool => $capability === Capabilities::TENANT_VIEW);
app()->instance(CapabilityResolver::class, $resolver);
Filament::setTenant($tenant, true);
$policy = Policy::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
]);
$baselineProfile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$backupVersion = PolicyVersion::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'policy_id' => (int) $policy->getKey(),
'version_number' => 1,
'capture_purpose' => PolicyVersionCapturePurpose::Backup->value,
]);
$baselinePurposeVersion = PolicyVersion::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'policy_id' => (int) $policy->getKey(),
'version_number' => 2,
'capture_purpose' => PolicyVersionCapturePurpose::BaselineCompare->value,
'baseline_profile_id' => (int) $baselineProfile->getKey(),
]);
Livewire::actingAs($user)
->test(ListPolicyVersions::class)
->assertCanSeeTableRecords([$backupVersion])
->assertCanNotSeeTableRecords([$baselinePurposeVersion]);
$this->actingAs($user)
->get(PolicyVersionResource::getUrl('view', ['record' => $baselinePurposeVersion], tenant: $tenant))
->assertNotFound();
});
it('shows baseline-purpose policy versions for actors with tenant.sync', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
app()->forgetInstance(CapabilityResolver::class);
Filament::setTenant($tenant, true);
$policy = Policy::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
]);
$baselineProfile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$backupVersion = PolicyVersion::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'policy_id' => (int) $policy->getKey(),
'version_number' => 1,
'capture_purpose' => PolicyVersionCapturePurpose::Backup->value,
]);
$baselinePurposeVersion = PolicyVersion::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'policy_id' => (int) $policy->getKey(),
'version_number' => 2,
'capture_purpose' => PolicyVersionCapturePurpose::BaselineCapture->value,
'baseline_profile_id' => (int) $baselineProfile->getKey(),
]);
Livewire::actingAs($user)
->test(ListPolicyVersions::class)
->assertCanSeeTableRecords([$backupVersion, $baselinePurposeVersion]);
});

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
it('prevents legacy fingerprinting/compare helpers from re-entering baseline orchestration (Spec 118)', function (): void {
$forbiddenTokens = [
'PolicyNormalizer',
'VersionDiff',
'flattenForDiff',
'SettingsNormalizer',
'ScopeTagsNormalizer',
'->hashNormalized(',
'::hashNormalized(',
];
$compareJob = file_get_contents(base_path('app/Jobs/CompareBaselineToTenantJob.php'));
expect($compareJob)->toBeString();
expect($compareJob)->toContain('CurrentStateHashResolver');
foreach ($forbiddenTokens as $token) {
expect($compareJob)->not->toContain($token);
}
$captureJob = file_get_contents(base_path('app/Jobs/CaptureBaselineSnapshotJob.php'));
expect($captureJob)->toBeString();
expect($captureJob)->toContain('CurrentStateHashResolver');
foreach ($forbiddenTokens as $token) {
expect($captureJob)->not->toContain($token);
}
});

View File

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
use App\Models\BaselineProfile;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Support\Baselines\PolicyVersionCapturePurpose;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('prunes baseline-purpose policy versions past retention but keeps backups', function (): void {
config()->set('tenantpilot.baselines.full_content_capture.retention_days', 30);
$tenant = Tenant::factory()->create();
$policy = Policy::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
]);
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$oldBaselineCompare = PolicyVersion::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'policy_id' => (int) $policy->getKey(),
'version_number' => 1,
'capture_purpose' => PolicyVersionCapturePurpose::BaselineCompare->value,
'baseline_profile_id' => (int) $profile->getKey(),
'captured_at' => now()->subDays(45),
]);
$oldBaselineCapture = PolicyVersion::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'policy_id' => (int) $policy->getKey(),
'version_number' => 2,
'capture_purpose' => PolicyVersionCapturePurpose::BaselineCapture->value,
'baseline_profile_id' => (int) $profile->getKey(),
'captured_at' => now()->subDays(45),
]);
$recentBaselineCompare = PolicyVersion::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'policy_id' => (int) $policy->getKey(),
'version_number' => 3,
'capture_purpose' => PolicyVersionCapturePurpose::BaselineCompare->value,
'baseline_profile_id' => (int) $profile->getKey(),
'captured_at' => now()->subDays(10),
]);
$oldBackup = PolicyVersion::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'policy_id' => (int) $policy->getKey(),
'version_number' => 4,
'capture_purpose' => PolicyVersionCapturePurpose::Backup->value,
'captured_at' => now()->subDays(45),
]);
$this->artisan('tenantpilot:baseline-evidence:prune')->assertExitCode(0);
expect($oldBaselineCompare->refresh()->trashed())->toBeTrue();
expect($oldBaselineCapture->refresh()->trashed())->toBeTrue();
expect($recentBaselineCompare->refresh()->trashed())->toBeFalse();
expect($oldBackup->refresh()->trashed())->toBeFalse();
});

View File

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
use Illuminate\Console\Scheduling\Schedule;
it('schedules baseline evidence policy version pruning daily without overlapping', function (): void {
/** @var Schedule $schedule */
$schedule = app(Schedule::class);
$event = collect($schedule->events())
->first(fn ($event) => ($event->description ?? null) === 'tenantpilot:baseline-evidence:prune');
expect($event)->not->toBeNull();
expect($event->withoutOverlapping)->toBeTrue();
});